diff --git a/README.md b/README.md
index c7d9c86..ef71556 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
## Документация
Человеческая документация по подключению, стилю, JS API и компонентам находится в [`docs/`](docs/index.md).
+Официальный Vue 3 adapter описан в [`docs/vue.md`](docs/vue.md).
## Быстрый старт
@@ -27,6 +28,12 @@
npm run build
```
+Vue adapter only:
+
+```bash
+npm run build:vue
+```
+
## Структура
```text
@@ -44,6 +51,7 @@
src/scss/kit.scss Основной CSS UI-kit
src/scss/demo.scss CSS для demo/docs страницы
src/js/index.js Browser JS bundle entry
+src/vue/index.js Vue 3 adapter entry
demo/index.html Demo/docs страница
```
@@ -64,6 +72,7 @@
- `dist/css/kit.css`
- `dist/css/demo.css`
- `dist/js/gnexus-ui-kit.js`
+- `dist/vue/index.js`
- `dist/index.html`
- `dist/assets/*`
@@ -96,6 +105,18 @@
GNexusUIKit.Toasts.createInfo("Info", "Message").show();
```
+## Vue Adapter
+
+Vue projects should import CSS once and use the official adapter instead of hand-written component markup:
+
+```js
+import "gnexus-ui-kit/dist/css/kit.css";
+import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
+import { GnButton, GnTabs, GnexusUiVue } from "gnexus-ui-kit/vue";
+```
+
+See [`docs/vue.md`](docs/vue.md) and [`docs/vue/ai-usage-guide.md`](docs/vue/ai-usage-guide.md).
+
## JS API
Bundle публикует компоненты и helper-модули двумя способами:
diff --git a/docs/getting-started.md b/docs/getting-started.md
index ebea414..f2296bc 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -18,6 +18,7 @@
- `dist/css/kit.css`
- `dist/css/demo.css`
- `dist/js/gnexus-ui-kit.js`
+- `dist/vue/index.js`
- `dist/index.html`
- `dist/assets/*`
@@ -74,6 +75,18 @@
В приложении можно использовать свои пути, но классы с `.ph` предполагают, что Phosphor Icons подключены.
+## Vue
+
+Для Vue 3 проектов используйте официальный adapter:
+
+```js
+import "gnexus-ui-kit/dist/css/kit.css";
+import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
+import { GnButton, GnModal } from "gnexus-ui-kit/vue";
+```
+
+Подробнее: [Vue Adapter](vue.md).
+
## Версия
Текущая версия пакета: `0.2.0`.
diff --git a/docs/index.md b/docs/index.md
index 801f659..5b03977 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,6 +8,7 @@
- [Getting Started](getting-started.md) - подключение CSS, иконок и JS bundle.
- [Style Guide](style-guide.md) - визуальные правила, токены, spacing и типографика.
- [JavaScript API](javascript.md) - глобальный namespace и автоинициализация.
+- [Vue Adapter](vue.md) - официальный Vue слой поверх CSS/JS контракта kit.
## Компоненты
diff --git a/docs/vue-adapter-plan.md b/docs/vue-adapter-plan.md
new file mode 100644
index 0000000..69a5ed9
--- /dev/null
+++ b/docs/vue-adapter-plan.md
@@ -0,0 +1,101 @@
+# Vue Adapter Work Plan
+
+## Goal
+
+Create an official Vue adapter inside `gnexus-ui-kit` so Vue projects use one stable integration layer instead of repeating slightly different local adaptations.
+
+The adapter must keep the current GNexus UI Kit CSS and visual class names as the source of truth. Vue components should normalize markup, props, events, slots, and interactive behavior without becoming a separate design system.
+
+## Architecture
+
+Add a thin Vue layer:
+
+```text
+src/vue/
+ components/
+ composables/
+ plugin.js
+ index.js
+```
+
+The adapter exports named components and a plugin:
+
+```js
+import "gnexus-ui-kit/dist/css/kit.css";
+import { GnButton, GnTabs } from "gnexus-ui-kit/vue";
+
+app.use(GnexusUiVue);
+```
+
+Vue is treated as a peer/external dependency. The kit build should not bundle Vue.
+
+## Component Strategy
+
+Use Vue-native state for app-level interactive components:
+
+- tabs via `v-model`;
+- accordion via local controlled state;
+- modal via `v-model:open`;
+- drawer via `v-model:open`;
+- toast and confirm via composables/provider.
+
+Keep legacy browser JS for plain HTML demo and non-Vue usage.
+
+## First Implementation Wave
+
+Implement a practical base set:
+
+- `GnButton`
+- `GnIconButton`
+- `GnBadge`
+- `GnAlert`
+- `GnCard`
+- `GnTabs`
+- `GnAccordion`
+- `GnModal`
+- `GnDrawer`
+- `GnToastProvider`
+- `useToast`
+- `GnConfirmDialog`
+- `GnInput`
+- `GnTextarea`
+- `GnSelect`
+- `GnCheckbox`
+- `GnSwitch`
+- `GnTable`
+- `GnPageHeader`
+
+## Documentation
+
+Add Vue-specific docs:
+
+```text
+docs/vue.md
+docs/vue/component-map.md
+docs/vue/ai-usage-guide.md
+```
+
+The AI guide should clearly say which Vue components to use instead of hand-written GNexus markup, which classes are owned by the adapter, and how to import CSS/assets.
+
+## Build
+
+Add a Vue adapter build target that outputs:
+
+```text
+dist/vue/index.js
+```
+
+The normal `npm run build` should keep producing the existing kit output and also produce the Vue adapter.
+
+## Example
+
+Add a small `examples/vue` app that demonstrates imports and common usage. The example can have its own package metadata and depend on Vue/Vite.
+
+## Validation
+
+Minimum validation:
+
+- `npm run build`
+- `npm run build:vue`
+- smoke-check generated `dist/vue/index.js`
+- keep existing demo server working
diff --git a/docs/vue.md b/docs/vue.md
new file mode 100644
index 0000000..60c0e21
--- /dev/null
+++ b/docs/vue.md
@@ -0,0 +1,108 @@
+# Vue Adapter
+
+GNexus UI Kit ships a Vue 3 adapter for projects that need stable Vue components instead of hand-written kit markup in every codebase.
+
+The adapter is intentionally thin:
+
+- CSS classes and visual behavior still come from `dist/css/kit.css`;
+- Vue components normalize props, events, slots, and state;
+- Vue runtime is external and must be installed by the host app;
+- legacy `dist/js/gnexus-ui-kit.js` remains for plain HTML pages.
+
+## Install Contract
+
+In a Vue project, import CSS once near the app root:
+
+```js
+import "gnexus-ui-kit/dist/css/kit.css";
+import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
+```
+
+Then import components:
+
+```js
+import { GnButton, GnTabs } from "gnexus-ui-kit/vue";
+```
+
+Or register everything:
+
+```js
+import { createApp } from "vue";
+import { GnexusUiVue } from "gnexus-ui-kit/vue";
+import App from "./App.vue";
+
+createApp(App).use(GnexusUiVue).mount("#app");
+```
+
+## Example Usage
+
+```vue
+
+
+
+
+ Create
+
+
+
+ Overview content
+ Activity content
+
+
+
+ Modal content
+
+
+```
+
+## Toasts
+
+Wrap the app once:
+
+```vue
+
+
+
+
+
+```
+
+Use the composable inside descendants:
+
+```js
+import { useToast } from "gnexus-ui-kit/vue";
+
+const toast = useToast();
+toast.success({ title: "Saved", text: "Changes applied" });
+```
+
+## Build
+
+```bash
+npm run build:vue
+```
+
+Outputs:
+
+```text
+dist/vue/index.js
+```
+
+The normal `npm run build` also builds the Vue adapter.
+
+## Current Components
+
+See [Vue Component Map](vue/component-map.md).
+
+For AI agents and project-specific rules, see [Vue AI Usage Guide](vue/ai-usage-guide.md).
diff --git a/docs/vue/ai-usage-guide.md b/docs/vue/ai-usage-guide.md
new file mode 100644
index 0000000..9ac4c71
--- /dev/null
+++ b/docs/vue/ai-usage-guide.md
@@ -0,0 +1,77 @@
+# Vue AI Usage Guide
+
+This document is for AI agents working inside Vue projects that consume GNexus UI Kit.
+
+## Rule
+
+Prefer `gnexus-ui-kit/vue` components over hand-written kit markup.
+
+Do not recreate modal, drawer, tab, toast, form, button, or table markup manually unless the adapter does not expose the needed component yet.
+
+## Required Imports
+
+At app root:
+
+```js
+import "gnexus-ui-kit/dist/css/kit.css";
+import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
+```
+
+Use named imports:
+
+```js
+import { GnButton, GnInput, GnModal } from "gnexus-ui-kit/vue";
+```
+
+Or global registration:
+
+```js
+import { GnexusUiVue } from "gnexus-ui-kit/vue";
+app.use(GnexusUiVue);
+```
+
+## Component Selection
+
+Use these mappings:
+
+- button command: `GnButton`
+- icon-only command: `GnIconButton`
+- status label: `GnBadge`
+- message block: `GnAlert`
+- framed content: `GnCard`
+- page title/actions: `GnPageHeader`
+- text field: `GnInput`
+- multiline field: `GnTextarea`
+- select field: `GnSelect`
+- boolean control: `GnCheckbox` or `GnSwitch`
+- view switcher: `GnTabs`
+- disclosure group: `GnAccordion`
+- dialog: `GnModal`
+- side panel: `GnDrawer`
+- global notification: `GnToastProvider` + `useToast`
+- confirmation: `GnConfirmDialog`
+- structured rows: `GnTable`
+
+## Do Not
+
+- Do not copy raw modal markup from demo partials into Vue apps.
+- Do not call `GNexusUIKit.Modals.create()` from Vue components.
+- Do not run `Accordion.init()` or `Tabs.init()` inside Vue components.
+- Do not invent new variant names.
+- Do not duplicate GNexus CSS in Vue component scoped styles.
+- Do not bundle Vue into the UI-kit adapter.
+
+## Acceptable Raw Markup
+
+Raw classes are acceptable for layout-only wrappers and content that has no Vue adapter yet:
+
+```html
+
...
+...
+```
+
+For interactive components, add or extend the adapter first.
+
+## Update Policy
+
+When the base kit changes markup for a component, update the Vue adapter in this repository. Downstream projects should receive compatibility through dependency updates, not local rewrites.
diff --git a/docs/vue/component-map.md b/docs/vue/component-map.md
new file mode 100644
index 0000000..4ac73f6
--- /dev/null
+++ b/docs/vue/component-map.md
@@ -0,0 +1,59 @@
+# Vue Component Map
+
+This map defines the official Vue adapter surface. Vue projects should use these components before writing raw GNexus UI Kit markup.
+
+| Vue component | Kit contract | Notes |
+| --- | --- | --- |
+| `GnButton` | `.btn`, `.btn-*`, `.with-icon` | Supports `variant`, `size`, `icon`, `loading`. |
+| `GnIconButton` | `.btn-icon` | Icon-only button with required `label`. |
+| `GnBadge` | `.badge`, `.badge-*` | Supports variants and primary outline. |
+| `GnAlert` | `.alert`, `.alert-*` | Static feedback block. |
+| `GnCard` | `.card`, `.card-title`, `.card-content`, `.card-footer` | Uses `title`, default slot, `footer` slot. |
+| `GnPageHeader` | `.page-header` | Supports `kicker`, `title`, `subtitle`, `meta`, `actions`. |
+| `GnInput` | `.form-group`, `.label`, `.input` | `v-model`, label, icon, state, help. |
+| `GnTextarea` | `.form-group`, `.label`, `textarea.input` | `v-model`, label, icon, state, help. |
+| `GnSelect` | `.select-wrap`, `.input.select` | `v-model`, options prop or option slot. |
+| `GnCheckbox` | `.checkbox` | `v-model` boolean. |
+| `GnSwitch` | `.checkbox` | Alias for current switch-like checkbox styling. |
+| `GnTabs` | `.tabs`, `.tabs-list`, `.tab-panel` | Vue-native state through `v-model`. |
+| `GnAccordion` | `.accordion` | Vue-native open state. |
+| `GnModal` | `.modal` | Vue-native `v-model:open`; teleports to body. |
+| `GnDrawer` | `.drawer` | Vue-native `v-model:open`; teleports to body. |
+| `GnToastProvider` | `.toast` | Provides `useToast()`. |
+| `GnConfirmDialog` | `GnModal` + actions | Emits `confirm` / `cancel`. |
+| `GnTable` | `.table`, `.table-wrapper` | Columns/rows with scoped cell slots. |
+
+## Variant Names
+
+Use existing kit variants:
+
+```text
+primary
+secondary
+accent
+success
+warning
+danger
+error
+info
+```
+
+`danger` and `error` map to the same visual color in most components.
+
+## Slot Naming
+
+`GnTabs` uses tab ids as slot names:
+
+```vue
+
+ ...
+
+```
+
+`GnTable` uses `cell-${column.key}`:
+
+```vue
+
+ {{ value }}
+
+```
diff --git a/examples/vue/index.html b/examples/vue/index.html
new file mode 100644
index 0000000..b98ffed
--- /dev/null
+++ b/examples/vue/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ GNexus UI Kit Vue Example
+
+
+
+
+
+
diff --git a/examples/vue/package.json b/examples/vue/package.json
new file mode 100644
index 0000000..63b241a
--- /dev/null
+++ b/examples/vue/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "gnexus-ui-kit-vue-example",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@vitejs/plugin-vue": "^5.2.0",
+ "vite": "^6.0.0",
+ "vue": "^3.4.0",
+ "gnexus-ui-kit": "file:../.."
+ },
+ "devDependencies": {}
+}
diff --git a/examples/vue/src/main.js b/examples/vue/src/main.js
new file mode 100644
index 0000000..3f37cc2
--- /dev/null
+++ b/examples/vue/src/main.js
@@ -0,0 +1,95 @@
+import { createApp, ref } from "vue";
+import "gnexus-ui-kit/dist/css/kit.css";
+import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
+import {
+ GnButton,
+ GnInput,
+ GnModal,
+ GnPageHeader,
+ GnTabs,
+ GnToastProvider,
+ useToast
+} from "gnexus-ui-kit/vue";
+
+const DemoScreen = {
+ components: {
+ GnButton,
+ GnInput,
+ GnModal,
+ GnPageHeader,
+ GnTabs
+ },
+ setup() {
+ const activeTab = ref("overview");
+ const modalOpen = ref(false);
+ const name = ref("Launch Plan");
+ const toast = useToast();
+
+ const tabs = [
+ { id: "overview", label: "Overview", icon: "ph-chart-bar" },
+ { id: "activity", label: "Activity", icon: "ph-clock" }
+ ];
+
+ const save = () => {
+ toast.success({ title: "Saved", text: `${name.value} updated` });
+ modalOpen.value = false;
+ };
+
+ return {
+ activeTab,
+ modalOpen,
+ name,
+ tabs,
+ save
+ };
+ },
+ template: `
+
+
+
+
+ Create
+
+
+
+
+
+
+
+ Use adapter components instead of local one-off markup.
+
+
+ Interactive state belongs to Vue; visual classes stay in the kit.
+
+
+
+
+
+
+
+ Cancel
+ Save
+
+
+
+ `
+};
+
+const App = {
+ components: {
+ GnToastProvider,
+ DemoScreen
+ },
+ template: `
+
+
+
+ `
+};
+
+createApp(App).mount("#app");
diff --git a/gulpfile.js b/gulpfile.js
index f878d2c..715095f 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -20,6 +20,11 @@
entry: "src/js/index.js",
outfile: "dist/js/gnexus-ui-kit.js"
},
+ vue: {
+ watch: "src/vue/**/*.js",
+ entry: "src/vue/index.js",
+ outfile: "dist/vue/index.js"
+ },
html: {
watch: "demo/**/*.html",
entry: "demo/*.html",
@@ -90,6 +95,22 @@
});
}
+function vue() {
+ return esbuild.build({
+ entryPoints: [paths.vue.entry],
+ bundle: true,
+ minify: false,
+ sourcemap: true,
+ outfile: paths.vue.outfile,
+ target: ["es2018"],
+ platform: "browser",
+ format: "esm",
+ external: ["vue"]
+ }).then(() => {
+ browserSync.reload();
+ });
+}
+
function html() {
return gulp.src(paths.html.entry)
.pipe(fileInclude({
@@ -108,6 +129,7 @@
function watchFiles() {
gulp.watch(paths.styles.watch, styles);
gulp.watch(paths.scripts.watch, scripts);
+ gulp.watch(paths.vue.watch, vue);
gulp.watch(paths.html.watch, html);
gulp.watch(paths.assets.src, gulp.series(assets, reload));
}
@@ -129,10 +151,11 @@
done();
}
-const build = gulp.parallel(styles, scripts, html, assets);
+const build = gulp.parallel(styles, scripts, vue, html, assets);
exports.styles = styles;
exports.scripts = scripts;
+exports.vue = vue;
exports.html = html;
exports.assets = assets;
exports.build = build;
diff --git a/package-lock.json b/package-lock.json
index 64aceca..739bb97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,14 @@
"gulp-file-include": "^2.3.0",
"postcss": "^8.5.9",
"sass": "^1.68.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "vue": {
+ "optional": true
+ }
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -3076,7 +3084,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "github",
@@ -3283,7 +3291,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
+ "devOptional": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -3331,10 +3339,10 @@
}
},
"node_modules/postcss": {
- "version": "8.5.9",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
- "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
- "dev": true,
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -4009,7 +4017,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
+ "devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
diff --git a/package.json b/package.json
index f35f3b8..41bf189 100644
--- a/package.json
+++ b/package.json
@@ -2,11 +2,29 @@
"name": "gnexus-ui-kit",
"version": "0.2.0",
"private": true,
+ "style": "dist/css/kit.css",
+ "exports": {
+ ".": "./dist/js/gnexus-ui-kit.js",
+ "./vue": "./dist/vue/index.js",
+ "./dist/css/kit.css": "./dist/css/kit.css",
+ "./css/kit.css": "./dist/css/kit.css",
+ "./dist/assets/*": "./dist/assets/*",
+ "./package.json": "./package.json"
+ },
"scripts": {
"build": "gulp build",
+ "build:vue": "gulp vue",
"dev": "gulp serve",
"start": "gulp serve"
},
+ "peerDependencies": {
+ "vue": "^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "vue": {
+ "optional": true
+ }
+ },
"devDependencies": {
"autoprefixer": "^10.4.27",
"browser-sync": "^3.0.4",
diff --git a/src/vue/components/GnAccordion.js b/src/vue/components/GnAccordion.js
new file mode 100644
index 0000000..6e878f5
--- /dev/null
+++ b/src/vue/components/GnAccordion.js
@@ -0,0 +1,51 @@
+import { defineComponent, h, ref } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnAccordion",
+ props: {
+ items: { type: Array, required: true },
+ modelValue: { type: [String, Array], default: "" },
+ multiple: { type: Boolean, default: false }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { emit, slots }) {
+ const localOpen = ref(props.multiple ? [] : "");
+
+ const getOpen = () => props.modelValue || localOpen.value;
+ const isOpen = id => props.multiple ? getOpen().includes(id) : getOpen() === id;
+ const toggle = id => {
+ let next;
+
+ if(props.multiple) {
+ const current = [...getOpen()];
+ next = current.includes(id) ? current.filter(item => item !== id) : [...current, id];
+ } else {
+ next = isOpen(id) ? "" : id;
+ }
+
+ localOpen.value = next;
+ emit("update:modelValue", next);
+ };
+
+ return () => h("div", { class: "accordion" }, props.items.map(item => {
+ const open = isOpen(item.id);
+
+ return h("section", { class: "accordion-item", open: open ? "" : undefined }, [
+ h("button", {
+ class: "accordion-summary",
+ type: "button",
+ "aria-expanded": open ? "true" : "false",
+ onClick: () => toggle(item.id)
+ }, [
+ h("span", { class: "accordion-summary-content" }, [
+ iconNode(item.icon),
+ item.label
+ ]),
+ h("i", { class: cx("ph ph-caret-down accordion-icon", { "is-open": open }), "aria-hidden": "true" })
+ ]),
+ open && h("div", { class: "accordion-panel" }, slots[item.id]?.({ item, open }) || item.content)
+ ]);
+ }));
+ }
+});
diff --git a/src/vue/components/GnAlert.js b/src/vue/components/GnAlert.js
new file mode 100644
index 0000000..919c595
--- /dev/null
+++ b/src/vue/components/GnAlert.js
@@ -0,0 +1,21 @@
+import { defineComponent, h } from "vue";
+import { cx, normalizeVariant } from "../utils.js";
+
+export default defineComponent({
+ name: "GnAlert",
+ props: {
+ variant: { type: String, default: "primary" },
+ role: { type: String, default: "status" }
+ },
+ setup(props, { attrs, slots }) {
+ return () => {
+ const variant = normalizeVariant(props.variant);
+
+ return h("div", {
+ ...attrs,
+ role: props.role,
+ class: cx("alert", `alert-${variant}`, attrs.class)
+ }, slots.default?.());
+ };
+ }
+});
diff --git a/src/vue/components/GnBadge.js b/src/vue/components/GnBadge.js
new file mode 100644
index 0000000..3fac9e8
--- /dev/null
+++ b/src/vue/components/GnBadge.js
@@ -0,0 +1,24 @@
+import { defineComponent, h } from "vue";
+import { cx, normalizeVariant } from "../utils.js";
+
+export default defineComponent({
+ name: "GnBadge",
+ props: {
+ variant: { type: String, default: "primary" },
+ outline: { type: Boolean, default: false }
+ },
+ setup(props, { attrs, slots }) {
+ return () => {
+ const variant = normalizeVariant(props.variant);
+
+ return h("span", {
+ ...attrs,
+ class: cx(
+ "badge",
+ props.outline && variant === "primary" ? "badge-primary-outline" : `badge-${variant}`,
+ attrs.class
+ )
+ }, slots.default?.());
+ };
+ }
+});
diff --git a/src/vue/components/GnButton.js b/src/vue/components/GnButton.js
new file mode 100644
index 0000000..9d4c756
--- /dev/null
+++ b/src/vue/components/GnButton.js
@@ -0,0 +1,40 @@
+import { defineComponent, h } from "vue";
+import { cx, iconNode, normalizeVariant } from "../utils.js";
+
+export default defineComponent({
+ name: "GnButton",
+ props: {
+ variant: { type: String, default: "primary" },
+ size: { type: String, default: "md" },
+ icon: { type: String, default: "" },
+ loading: { type: Boolean, default: false },
+ disabled: { type: Boolean, default: false },
+ type: { type: String, default: "button" }
+ },
+ setup(props, { attrs, slots }) {
+ return () => {
+ const hasIcon = Boolean(props.icon || props.loading);
+ const variant = normalizeVariant(props.variant);
+
+ return h("button", {
+ ...attrs,
+ type: props.type,
+ disabled: props.disabled || props.loading,
+ class: cx(
+ "btn",
+ `btn-${variant}`,
+ {
+ "btn-small": props.size === "sm",
+ "btn-large": props.size === "lg",
+ "with-icon": hasIcon,
+ "loading-state": props.loading
+ },
+ attrs.class
+ )
+ }, [
+ props.loading ? iconNode("ph-bold ph-spinner") : iconNode(props.icon),
+ slots.default?.()
+ ]);
+ };
+ }
+});
diff --git a/src/vue/components/GnCard.js b/src/vue/components/GnCard.js
new file mode 100644
index 0000000..4f8d7bf
--- /dev/null
+++ b/src/vue/components/GnCard.js
@@ -0,0 +1,20 @@
+import { defineComponent, h } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnCard",
+ props: {
+ title: { type: String, default: "" },
+ variant: { type: String, default: "" }
+ },
+ setup(props, { attrs, slots }) {
+ return () => h("article", {
+ ...attrs,
+ class: cx("card", props.variant && `card-${props.variant}`, attrs.class)
+ }, [
+ (props.title || slots.title) && h("header", { class: "card-title" }, slots.title?.() || props.title),
+ h("div", { class: "card-content" }, slots.default?.()),
+ slots.footer && h("footer", { class: "card-footer" }, slots.footer())
+ ]);
+ }
+});
diff --git a/src/vue/components/GnCheckbox.js b/src/vue/components/GnCheckbox.js
new file mode 100644
index 0000000..8e06e22
--- /dev/null
+++ b/src/vue/components/GnCheckbox.js
@@ -0,0 +1,26 @@
+import { defineComponent, h } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnCheckbox",
+ inheritAttrs: false,
+ props: {
+ modelValue: { type: Boolean, default: false },
+ label: { type: String, default: "" },
+ disabled: { type: Boolean, default: false }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { attrs, emit, slots }) {
+ return () => h("label", { class: cx("checkbox", attrs.class) }, [
+ h("input", {
+ ...attrs,
+ type: "checkbox",
+ checked: props.modelValue,
+ disabled: props.disabled,
+ onChange: event => emit("update:modelValue", event.target.checked)
+ }),
+ h("span", { class: "checkbox-control", "aria-hidden": "true" }),
+ h("span", { class: "checkbox-label" }, slots.default?.() || props.label)
+ ]);
+ }
+});
diff --git a/src/vue/components/GnConfirmDialog.js b/src/vue/components/GnConfirmDialog.js
new file mode 100644
index 0000000..664343a
--- /dev/null
+++ b/src/vue/components/GnConfirmDialog.js
@@ -0,0 +1,39 @@
+import { defineComponent, h } from "vue";
+import GnButton from "./GnButton.js";
+import GnModal from "./GnModal.js";
+
+export default defineComponent({
+ name: "GnConfirmDialog",
+ props: {
+ open: { type: Boolean, default: false },
+ title: { type: String, default: "Requires confirmation" },
+ message: { type: String, default: "" },
+ confirmText: { type: String, default: "YES" },
+ cancelText: { type: String, default: "NO" },
+ confirmVariant: { type: String, default: "warning" }
+ },
+ emits: ["update:open", "confirm", "cancel"],
+ setup(props, { emit, slots }) {
+ const close = () => emit("update:open", false);
+ const cancel = () => {
+ emit("cancel");
+ close();
+ };
+ const confirm = () => {
+ emit("confirm");
+ close();
+ };
+
+ return () => h(GnModal, {
+ open: props.open,
+ title: props.title,
+ "onUpdate:open": value => emit("update:open", value)
+ }, {
+ default: () => slots.default?.() || h("p", {}, props.message),
+ actions: () => [
+ h(GnButton, { variant: "primary", onClick: cancel }, () => props.cancelText),
+ h(GnButton, { variant: props.confirmVariant, onClick: confirm }, () => props.confirmText)
+ ]
+ });
+ }
+});
diff --git a/src/vue/components/GnDrawer.js b/src/vue/components/GnDrawer.js
new file mode 100644
index 0000000..27f477c
--- /dev/null
+++ b/src/vue/components/GnDrawer.js
@@ -0,0 +1,48 @@
+import { defineComponent, h, Teleport } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+let drawerId = 0;
+
+export default defineComponent({
+ name: "GnDrawer",
+ props: {
+ open: { type: Boolean, default: false },
+ title: { type: String, default: "" },
+ position: { type: String, default: "right" }
+ },
+ emits: ["update:open", "close"],
+ setup(props, { emit, slots }) {
+ const titleId = `gn-drawer-title-${++drawerId}`;
+ const close = () => {
+ emit("update:open", false);
+ emit("close");
+ };
+
+ return () => props.open ? h(Teleport, { to: "body" }, [
+ h("div", {
+ class: cx("drawer a-show", { "drawer-left": props.position === "left" }),
+ "aria-hidden": "false"
+ }, [
+ h("div", { class: "drawer-backdrop", onClick: close }),
+ h("aside", {
+ class: "drawer-panel",
+ role: "dialog",
+ "aria-modal": "true",
+ "aria-labelledby": titleId
+ }, [
+ h("header", { class: "drawer-header" }, [
+ h("h4", { class: "drawer-title", id: titleId }, slots.title?.() || props.title),
+ h("button", {
+ class: "btn-icon drawer-close",
+ type: "button",
+ "aria-label": "Close",
+ onClick: close
+ }, [iconNode("ph-x")])
+ ]),
+ h("div", { class: "drawer-body" }, slots.default?.()),
+ slots.footer && h("footer", { class: "drawer-footer" }, slots.footer({ close }))
+ ])
+ ])
+ ]) : null;
+ }
+});
diff --git a/src/vue/components/GnIconButton.js b/src/vue/components/GnIconButton.js
new file mode 100644
index 0000000..4688a3a
--- /dev/null
+++ b/src/vue/components/GnIconButton.js
@@ -0,0 +1,20 @@
+import { defineComponent, h } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnIconButton",
+ props: {
+ icon: { type: String, required: true },
+ label: { type: String, required: true },
+ type: { type: String, default: "button" },
+ withoutHover: { type: Boolean, default: false }
+ },
+ setup(props, { attrs }) {
+ return () => h("button", {
+ ...attrs,
+ type: props.type,
+ "aria-label": props.label,
+ class: cx("btn-icon", { "without-hover": props.withoutHover }, attrs.class)
+ }, [iconNode(props.icon)]);
+ }
+});
diff --git a/src/vue/components/GnInput.js b/src/vue/components/GnInput.js
new file mode 100644
index 0000000..89881de
--- /dev/null
+++ b/src/vue/components/GnInput.js
@@ -0,0 +1,32 @@
+import { defineComponent, h } from "vue";
+import { cx, eventValue, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnInput",
+ inheritAttrs: false,
+ props: {
+ modelValue: { type: [String, Number], default: "" },
+ label: { type: String, default: "" },
+ type: { type: String, default: "text" },
+ icon: { type: String, default: "" },
+ state: { type: String, default: "" },
+ help: { type: String, default: "" }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { attrs, emit }) {
+ return () => h("div", { class: "form-group" }, [
+ h("label", { class: cx("label", props.state) }, [
+ props.label,
+ iconNode(props.icon),
+ h("input", {
+ ...attrs,
+ type: props.type,
+ value: props.modelValue,
+ class: cx("input", attrs.class),
+ onInput: event => emit("update:modelValue", eventValue(event))
+ })
+ ]),
+ props.help && h("div", { class: cx("input-info", props.state === "error" && "error") }, props.help)
+ ]);
+ }
+});
diff --git a/src/vue/components/GnModal.js b/src/vue/components/GnModal.js
new file mode 100644
index 0000000..2bbecb3
--- /dev/null
+++ b/src/vue/components/GnModal.js
@@ -0,0 +1,53 @@
+import { defineComponent, h, Teleport } from "vue";
+import { iconNode } from "../utils.js";
+
+let modalId = 0;
+
+export default defineComponent({
+ name: "GnModal",
+ props: {
+ open: { type: Boolean, default: false },
+ title: { type: String, default: "" },
+ closeOnBackdrop: { type: Boolean, default: true }
+ },
+ emits: ["update:open", "close"],
+ setup(props, { emit, slots }) {
+ const titleId = `gn-modal-title-${++modalId}`;
+ const close = () => {
+ emit("update:open", false);
+ emit("close");
+ };
+
+ return () => props.open ? h(Teleport, { to: "body" }, [
+ h("div", { class: "modal a-show", "aria-hidden": "false" }, [
+ h("div", {
+ class: "modal-backdrop",
+ onClick: () => props.closeOnBackdrop && close()
+ }),
+ h("div", {
+ class: "modal-dialog",
+ role: "dialog",
+ "aria-modal": "true",
+ "aria-labelledby": titleId
+ }, [
+ h("header", { class: "modal-header" }, [
+ h("h4", { class: "modal-title", id: titleId }, slots.title?.() || props.title),
+ h("button", {
+ class: "btn-icon modal-close",
+ type: "button",
+ "aria-label": "Close",
+ onClick: close
+ }, [iconNode("ph-x")])
+ ]),
+ h("div", { class: "modal-panel" }, [
+ h("div", { class: "modal-body" }, slots.default?.()),
+ (slots.footer || slots.actions) && h("footer", { class: "modal-footer" }, [
+ slots.footer?.(),
+ slots.actions && h("div", { class: "actions" }, slots.actions({ close }))
+ ])
+ ])
+ ])
+ ])
+ ]) : null;
+ }
+});
diff --git a/src/vue/components/GnPageHeader.js b/src/vue/components/GnPageHeader.js
new file mode 100644
index 0000000..40d2fde
--- /dev/null
+++ b/src/vue/components/GnPageHeader.js
@@ -0,0 +1,30 @@
+import { defineComponent, h } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnPageHeader",
+ props: {
+ title: { type: String, required: true },
+ subtitle: { type: String, default: "" },
+ kicker: { type: String, default: "" },
+ compact: { type: Boolean, default: false },
+ accent: { type: Boolean, default: false }
+ },
+ setup(props, { attrs, slots }) {
+ return () => h("header", {
+ ...attrs,
+ class: cx("page-header", {
+ "page-header-compact": props.compact,
+ "page-header-accent": props.accent
+ }, attrs.class)
+ }, [
+ h("div", { class: "page-header-content" }, [
+ (props.kicker || slots.kicker) && h("div", { class: "page-header-kicker" }, slots.kicker?.() || props.kicker),
+ h("h1", { class: "page-header-title" }, slots.title?.() || props.title),
+ (props.subtitle || slots.subtitle) && h("p", { class: "page-header-subtitle" }, slots.subtitle?.() || props.subtitle),
+ slots.meta && h("div", { class: "page-header-meta" }, slots.meta())
+ ]),
+ slots.actions && h("div", { class: "page-header-actions" }, slots.actions())
+ ]);
+ }
+});
diff --git a/src/vue/components/GnSelect.js b/src/vue/components/GnSelect.js
new file mode 100644
index 0000000..ded4e46
--- /dev/null
+++ b/src/vue/components/GnSelect.js
@@ -0,0 +1,40 @@
+import { defineComponent, h } from "vue";
+import { cx, eventValue, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnSelect",
+ inheritAttrs: false,
+ props: {
+ modelValue: { type: [String, Number], default: "" },
+ label: { type: String, default: "" },
+ icon: { type: String, default: "" },
+ state: { type: String, default: "" },
+ help: { type: String, default: "" },
+ options: { type: Array, default: () => [] }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { attrs, emit, slots }) {
+ const optionNodes = () => props.options.map(option => {
+ const value = typeof option === "object" ? option.value : option;
+ const label = typeof option === "object" ? option.label : option;
+
+ return h("option", { value }, label);
+ });
+
+ return () => h("div", { class: "form-group" }, [
+ h("label", { class: cx("label", props.state) }, [
+ props.label,
+ iconNode(props.icon),
+ h("div", { class: "select-wrap" }, [
+ h("select", {
+ ...attrs,
+ value: props.modelValue,
+ class: cx("input select", attrs.class),
+ onChange: event => emit("update:modelValue", eventValue(event))
+ }, slots.default?.() || optionNodes())
+ ])
+ ]),
+ props.help && h("div", { class: cx("input-info", props.state === "error" && "error") }, props.help)
+ ]);
+ }
+});
diff --git a/src/vue/components/GnSwitch.js b/src/vue/components/GnSwitch.js
new file mode 100644
index 0000000..8b604c6
--- /dev/null
+++ b/src/vue/components/GnSwitch.js
@@ -0,0 +1 @@
+export { default } from "./GnCheckbox.js";
diff --git a/src/vue/components/GnTable.js b/src/vue/components/GnTable.js
new file mode 100644
index 0000000..1764e6c
--- /dev/null
+++ b/src/vue/components/GnTable.js
@@ -0,0 +1,29 @@
+import { defineComponent, h } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnTable",
+ props: {
+ columns: { type: Array, required: true },
+ rows: { type: Array, default: () => [] },
+ caption: { type: String, default: "" },
+ emptyText: { type: String, default: "Empty" }
+ },
+ setup(props, { attrs, slots }) {
+ return () => h("div", { class: "table-wrapper" }, [
+ h("table", { class: cx("table data-list", { "table-empty": !props.rows.length }, attrs.class) }, [
+ props.caption && h("caption", { class: "table-caption" }, props.caption),
+ h("thead", { class: "table-head" }, [
+ h("tr", { class: "table-row" }, props.columns.map(column => h("th", { scope: "col" }, column.label)))
+ ]),
+ h("tbody", { class: "table-body" }, props.rows.length
+ ? props.rows.map(row => h("tr", { class: "table-row" }, props.columns.map(column => {
+ const name = `cell-${column.key}`;
+ return h("td", {}, slots[name]?.({ row, column, value: row[column.key] }) || row[column.key]);
+ })))
+ : h("tr", {}, [h("td", { class: "is-empty", colspan: props.columns.length }, slots.empty?.() || props.emptyText)])
+ )
+ ])
+ ]);
+ }
+});
diff --git a/src/vue/components/GnTabs.js b/src/vue/components/GnTabs.js
new file mode 100644
index 0000000..2f0217d
--- /dev/null
+++ b/src/vue/components/GnTabs.js
@@ -0,0 +1,59 @@
+import { computed, defineComponent, h } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnTabs",
+ props: {
+ modelValue: { type: String, default: "" },
+ items: { type: Array, required: true },
+ compact: { type: Boolean, default: false },
+ vertical: { type: Boolean, default: false },
+ ariaLabel: { type: String, default: "Tabs" }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { emit, slots }) {
+ const activeId = computed(() => props.modelValue || props.items.find(item => !item.disabled)?.id || props.items[0]?.id);
+
+ const activate = item => {
+ if(!item.disabled) {
+ emit("update:modelValue", item.id);
+ }
+ };
+
+ return () => h("div", {
+ class: cx("tabs", {
+ "tabs-compact": props.compact,
+ "tabs-vertical": props.vertical
+ })
+ }, [
+ h("div", { class: "tabs-list", role: "tablist", "aria-label": props.ariaLabel }, props.items.map(item => {
+ const active = item.id === activeId.value;
+ const panelId = `${item.id}-panel`;
+
+ return h("button", {
+ class: cx("tab", { "tab-active": active }),
+ type: "button",
+ role: "tab",
+ "aria-selected": active ? "true" : "false",
+ "aria-controls": panelId,
+ "aria-disabled": item.disabled ? "true" : undefined,
+ tabindex: active ? "0" : "-1",
+ onClick: () => activate(item)
+ }, [
+ iconNode(item.icon),
+ item.label
+ ]);
+ })),
+ h("div", { class: "tabs-panels" }, props.items.map(item => {
+ const active = item.id === activeId.value;
+
+ return h("div", {
+ id: `${item.id}-panel`,
+ class: cx("tab-panel", { "tab-panel-active": active }),
+ role: "tabpanel",
+ hidden: !active
+ }, slots[item.id]?.({ item, active }) || (active && slots.default?.({ item, active })));
+ }))
+ ]);
+ }
+});
diff --git a/src/vue/components/GnTextarea.js b/src/vue/components/GnTextarea.js
new file mode 100644
index 0000000..5ff989c
--- /dev/null
+++ b/src/vue/components/GnTextarea.js
@@ -0,0 +1,30 @@
+import { defineComponent, h } from "vue";
+import { cx, eventValue, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnTextarea",
+ inheritAttrs: false,
+ props: {
+ modelValue: { type: String, default: "" },
+ label: { type: String, default: "" },
+ icon: { type: String, default: "" },
+ state: { type: String, default: "" },
+ help: { type: String, default: "" }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { attrs, emit }) {
+ return () => h("div", { class: "form-group" }, [
+ h("label", { class: cx("label", props.state) }, [
+ props.label,
+ iconNode(props.icon),
+ h("textarea", {
+ ...attrs,
+ value: props.modelValue,
+ class: cx("input", attrs.class),
+ onInput: event => emit("update:modelValue", eventValue(event))
+ })
+ ]),
+ props.help && h("div", { class: cx("input-info", props.state === "error" && "error") }, props.help)
+ ]);
+ }
+});
diff --git a/src/vue/components/GnToastProvider.js b/src/vue/components/GnToastProvider.js
new file mode 100644
index 0000000..3b0340e
--- /dev/null
+++ b/src/vue/components/GnToastProvider.js
@@ -0,0 +1,82 @@
+import { defineComponent, h, provide, ref } from "vue";
+import { cx, iconNode, normalizeVariant } from "../utils.js";
+import { toastKey } from "../composables/toast-context.js";
+
+const iconByVariant = {
+ info: "ph-info",
+ success: "ph-check-circle",
+ warning: "ph-warning",
+ danger: "ph-warning-octagon",
+ error: "ph-warning-octagon",
+ primary: "ph-info",
+ secondary: "ph-info"
+};
+
+export default defineComponent({
+ name: "GnToastProvider",
+ props: {
+ lifetime: { type: Number, default: 4000 }
+ },
+ setup(props, { slots, expose }) {
+ const toast = ref(null);
+ let timer = null;
+
+ const close = () => {
+ toast.value = null;
+ window.clearTimeout(timer);
+ timer = null;
+ };
+
+ const show = options => {
+ const variant = normalizeVariant(options.variant || options.type || "info", "info");
+ toast.value = {
+ id: Date.now(),
+ variant: variant === "error" ? "danger" : variant,
+ title: options.title || "",
+ text: options.text || options.message || "",
+ icon: options.icon || iconByVariant[variant] || iconByVariant.info
+ };
+
+ window.clearTimeout(timer);
+
+ if(options.lifetime !== 0) {
+ timer = window.setTimeout(close, options.lifetime || props.lifetime);
+ }
+ };
+
+ const api = {
+ show,
+ close,
+ info: options => show({ ...options, variant: "info" }),
+ success: options => show({ ...options, variant: "success" }),
+ warning: options => show({ ...options, variant: "warning" }),
+ danger: options => show({ ...options, variant: "danger" }),
+ error: options => show({ ...options, variant: "danger" })
+ };
+
+ provide(toastKey, api);
+ expose(api);
+
+ return () => [
+ slots.default?.(),
+ toast.value && h("div", {
+ class: cx("toast a-show", `toast-${toast.value.variant}`),
+ role: "alert"
+ }, [
+ h("div", { class: "toast-content" }, [
+ h("h4", { class: "toast-title" }, [
+ iconNode(toast.value.icon),
+ toast.value.title
+ ]),
+ h("p", { class: "toast-text" }, toast.value.text)
+ ]),
+ h("button", {
+ class: "btn-icon toast-close",
+ type: "button",
+ "aria-label": "Close",
+ onClick: close
+ }, [iconNode("ph-x")])
+ ])
+ ];
+ }
+});
diff --git a/src/vue/composables/toast-context.js b/src/vue/composables/toast-context.js
new file mode 100644
index 0000000..43bf03b
--- /dev/null
+++ b/src/vue/composables/toast-context.js
@@ -0,0 +1 @@
+export const toastKey = Symbol("gnexus-ui-kit-toast");
diff --git a/src/vue/composables/useToast.js b/src/vue/composables/useToast.js
new file mode 100644
index 0000000..681a1ee
--- /dev/null
+++ b/src/vue/composables/useToast.js
@@ -0,0 +1,26 @@
+import { inject } from "vue";
+import { toastKey } from "./toast-context.js";
+
+export function useToast() {
+ const api = inject(toastKey, null);
+
+ if(api) {
+ return api;
+ }
+
+ const missingProvider = () => {
+ throw new Error("GNexus UI Kit: useToast() requires near the app root.");
+ };
+
+ return {
+ show: missingProvider,
+ info: missingProvider,
+ success: missingProvider,
+ warning: missingProvider,
+ danger: missingProvider,
+ error: missingProvider,
+ close: missingProvider
+ };
+}
+
+export default useToast;
diff --git a/src/vue/index.js b/src/vue/index.js
new file mode 100644
index 0000000..f9e3987
--- /dev/null
+++ b/src/vue/index.js
@@ -0,0 +1,20 @@
+export { default as GnAccordion } from "./components/GnAccordion.js";
+export { default as GnAlert } from "./components/GnAlert.js";
+export { default as GnBadge } from "./components/GnBadge.js";
+export { default as GnButton } from "./components/GnButton.js";
+export { default as GnCard } from "./components/GnCard.js";
+export { default as GnCheckbox } from "./components/GnCheckbox.js";
+export { default as GnConfirmDialog } from "./components/GnConfirmDialog.js";
+export { default as GnDrawer } from "./components/GnDrawer.js";
+export { default as GnIconButton } from "./components/GnIconButton.js";
+export { default as GnInput } from "./components/GnInput.js";
+export { default as GnModal } from "./components/GnModal.js";
+export { default as GnPageHeader } from "./components/GnPageHeader.js";
+export { default as GnSelect } from "./components/GnSelect.js";
+export { default as GnSwitch } from "./components/GnSwitch.js";
+export { default as GnTable } from "./components/GnTable.js";
+export { default as GnTabs } from "./components/GnTabs.js";
+export { default as GnTextarea } from "./components/GnTextarea.js";
+export { default as GnToastProvider } from "./components/GnToastProvider.js";
+export { useToast } from "./composables/useToast.js";
+export { components, default as GnexusUiVue } from "./plugin.js";
diff --git a/src/vue/plugin.js b/src/vue/plugin.js
new file mode 100644
index 0000000..e63f661
--- /dev/null
+++ b/src/vue/plugin.js
@@ -0,0 +1,47 @@
+import GnAccordion from "./components/GnAccordion.js";
+import GnAlert from "./components/GnAlert.js";
+import GnBadge from "./components/GnBadge.js";
+import GnButton from "./components/GnButton.js";
+import GnCard from "./components/GnCard.js";
+import GnCheckbox from "./components/GnCheckbox.js";
+import GnConfirmDialog from "./components/GnConfirmDialog.js";
+import GnDrawer from "./components/GnDrawer.js";
+import GnIconButton from "./components/GnIconButton.js";
+import GnInput from "./components/GnInput.js";
+import GnModal from "./components/GnModal.js";
+import GnPageHeader from "./components/GnPageHeader.js";
+import GnSelect from "./components/GnSelect.js";
+import GnSwitch from "./components/GnSwitch.js";
+import GnTable from "./components/GnTable.js";
+import GnTabs from "./components/GnTabs.js";
+import GnTextarea from "./components/GnTextarea.js";
+import GnToastProvider from "./components/GnToastProvider.js";
+
+export const components = {
+ GnAccordion,
+ GnAlert,
+ GnBadge,
+ GnButton,
+ GnCard,
+ GnCheckbox,
+ GnConfirmDialog,
+ GnDrawer,
+ GnIconButton,
+ GnInput,
+ GnModal,
+ GnPageHeader,
+ GnSelect,
+ GnSwitch,
+ GnTable,
+ GnTabs,
+ GnTextarea,
+ GnToastProvider
+};
+
+export default {
+ install(app) {
+ Object.entries(components).forEach(([name, component]) => {
+ app.component(name, component);
+ });
+ }
+};
diff --git a/src/vue/utils.js b/src/vue/utils.js
new file mode 100644
index 0000000..1c1941f
--- /dev/null
+++ b/src/vue/utils.js
@@ -0,0 +1,68 @@
+import { h } from "vue";
+
+export const variants = new Set([
+ "primary",
+ "secondary",
+ "accent",
+ "success",
+ "warning",
+ "danger",
+ "error",
+ "info"
+]);
+
+export function cx(...items) {
+ return items
+ .flatMap(item => {
+ if(!item) {
+ return [];
+ }
+
+ if(Array.isArray(item)) {
+ return item;
+ }
+
+ if(typeof item === "object") {
+ return Object.entries(item)
+ .filter(([, enabled]) => enabled)
+ .map(([name]) => name);
+ }
+
+ return [item];
+ })
+ .filter(Boolean)
+ .join(" ");
+}
+
+export function normalizeVariant(value, fallback = "primary") {
+ return variants.has(value) ? value : fallback;
+}
+
+export function iconNode(icon, extraClass = "") {
+ if(!icon) {
+ return null;
+ }
+
+ const iconClass = icon.includes("ph ") || icon.startsWith("ph-")
+ ? icon
+ : `ph ${icon}`;
+
+ return h("i", {
+ class: cx(iconClass, extraClass),
+ "aria-hidden": "true"
+ });
+}
+
+export function slotOrText(slots, name, text) {
+ return slots[name] ? slots[name]() : text;
+}
+
+export function eventValue(event) {
+ const target = event.target;
+
+ if(target.type === "checkbox") {
+ return target.checked;
+ }
+
+ return target.value;
+}