diff --git a/docs/index.md b/docs/index.md index 5b03977..e17f8cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ - [Style Guide](style-guide.md) - визуальные правила, токены, spacing и типографика. - [JavaScript API](javascript.md) - глобальный namespace и автоинициализация. - [Vue Adapter](vue.md) - официальный Vue слой поверх CSS/JS контракта kit. +- [Vue Migration Policy](vue/migration-policy.md) - правила совместимости Vue adapter. ## Компоненты diff --git a/docs/vue.md b/docs/vue.md index bc9420b..d1eba0c 100644 --- a/docs/vue.md +++ b/docs/vue.md @@ -157,3 +157,5 @@ ``` For AI agents and project-specific rules, see [Vue AI Usage Guide](vue/ai-usage-guide.md). + +For compatibility rules and breaking-change policy, see [Vue Migration Policy](vue/migration-policy.md). diff --git a/docs/vue/ai-usage-guide.md b/docs/vue/ai-usage-guide.md index 0326613..32e515e 100644 --- a/docs/vue/ai-usage-guide.md +++ b/docs/vue/ai-usage-guide.md @@ -63,11 +63,17 @@ - chips/filters: `GnChip`, `GnChipGroup` - user or entity identity: `GnAvatar`, `GnIdentity`, `GnAvatarStack` - activity timeline/log: `GnTimeline`, `GnActivityLog` +- dropdown menu: `GnDropdown` +- contextual panel: `GnPopover` +- compact hint: `GnTooltip` +- navigation list: `GnNavList` +- application shell navigation: `GnNavigationShell` ## 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 call `GNexusUIKit.Overlays.init()` or `GNexusUIKit.NavigationShell.init()` in Vue projects. - 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. diff --git a/docs/vue/component-map.md b/docs/vue/component-map.md index cd66b03..5a7440f 100644 --- a/docs/vue/component-map.md +++ b/docs/vue/component-map.md @@ -38,6 +38,11 @@ | `GnAvatarStack` | `.avatar-stack` | Compact avatar group. | | `GnTimeline` | `.timeline` | Activity/event timeline. | | `GnActivityLog` | `.activity-log` | Compact log rows. | +| `GnDropdown` | `.dropdown`, `.dropdown-menu` | Vue-native open state, outside click, Escape. | +| `GnPopover` | `.popover`, `.popover-panel` | Vue-native contextual panel. | +| `GnTooltip` | `.tooltip`, `.tooltip-panel` | Focus/hover tooltip wrapper. | +| `GnNavList` | `.list.list-nav` | Navigation list items. | +| `GnNavigationShell` | `.nav-topbar`, `.nav-drawer` | Vue-native app shell drawer. | ## Variant Names diff --git a/docs/vue/migration-policy.md b/docs/vue/migration-policy.md new file mode 100644 index 0000000..8e34aad --- /dev/null +++ b/docs/vue/migration-policy.md @@ -0,0 +1,61 @@ +# Vue Adapter Migration Policy + +The Vue adapter is the compatibility layer for Vue projects using GNexus UI Kit. Downstream projects should update this package rather than keeping local wrappers. + +## Source Of Truth + +- Visual design and class contracts live in `src/scss/components/`. +- Plain HTML behavior lives in `src/js/components/`. +- Vue behavior lives in `src/vue/`. +- Vue usage guidance lives in `docs/vue/`. + +The Vue adapter must render GNexus UI Kit classes. It should not introduce a parallel visual system. + +## Breaking Changes + +These changes are breaking and require a migration note: + +- renaming or removing a Vue component; +- changing a prop, emit, or slot name; +- changing `v-model` semantics; +- changing rendered class contracts in a way that affects styling; +- removing an exported component from `gnexus-ui-kit/vue`; +- requiring a new global setup step in host apps. + +## Non-Breaking Changes + +These are usually non-breaking: + +- adding new optional props; +- adding new named exports; +- adding new slots while preserving old slots; +- improving keyboard/focus behavior without changing public API; +- adding docs and examples. + +## Downstream Rules + +Vue projects should not: + +- copy demo partial markup for interactive components; +- monkey-patch GNexus component classes in scoped Vue styles; +- call plain HTML initializers such as `Overlays.init()` from Vue components; +- create project-local wrappers when a component belongs in `src/vue`. + +If a project needs a missing component, add it to this repository and consume it through `gnexus-ui-kit/vue`. + +## Adapter Update Checklist + +When changing a base kit component: + +1. Check the corresponding Vue component. +2. Update props/events/slots only when needed. +3. Update `docs/vue/component-map.md`. +4. Update `docs/vue/ai-usage-guide.md` if usage rules change. +5. Run: + +```bash +npm run build +npm run build:example:vue +``` + +Vite warnings about `/assets/fonts/...` are expected while the CSS asset contract uses absolute `/assets` paths. diff --git a/examples/vue/src/main.js b/examples/vue/src/main.js index 4791aef..0b26c64 100644 --- a/examples/vue/src/main.js +++ b/examples/vue/src/main.js @@ -9,13 +9,16 @@ GnChip, GnChipGroup, GnDescriptionList, + GnDropdown, GnEmptyState, GnInput, GnModal, + GnPopover, GnPageHeader, GnProgress, GnSearchField, GnTabs, + GnTooltip, GnToolbar, GnToastProvider, useToast @@ -30,13 +33,16 @@ GnChip, GnChipGroup, GnDescriptionList, + GnDropdown, GnEmptyState, GnInput, GnModal, + GnPopover, GnPageHeader, GnProgress, GnSearchField, - GnTabs + GnTabs, + GnTooltip, }, setup() { const activeTab = ref("overview"); @@ -58,6 +64,11 @@ { key: "created", time: "10:12", title: "Workspace created" }, { key: "synced", time: "10:18", title: "Tokens synced" } ]; + const menuItems = [ + { label: "Edit", icon: "ph-pencil-simple" }, + { label: "Duplicate", icon: "ph-copy" }, + { label: "Delete", icon: "ph-trash", danger: true } + ]; const save = () => { toast.success({ title: "Saved", text: `${name.value} updated` }); @@ -68,6 +79,7 @@ activeTab, activity, details, + menuItems, modalOpen, name, query, @@ -94,6 +106,11 @@ diff --git a/src/vue/components/GnDropdown.js b/src/vue/components/GnDropdown.js new file mode 100644 index 0000000..6415f34 --- /dev/null +++ b/src/vue/components/GnDropdown.js @@ -0,0 +1,74 @@ +import { defineComponent, h, onBeforeUnmount, ref } from "vue"; +import { cx, iconNode } from "../utils.js"; +import GnButton from "./GnButton.js"; + +export default defineComponent({ + name: "GnDropdown", + props: { + label: { type: String, default: "Actions" }, + icon: { type: String, default: "ph-dots-three-outline" }, + variant: { type: String, default: "secondary" }, + items: { type: Array, default: () => [] } + }, + emits: ["select"], + setup(props, { emit, slots }) { + const open = ref(false); + const root = ref(null); + const close = () => { + open.value = false; + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onKeydown); + }; + const onOutsideClick = event => { + if(root.value && !root.value.contains(event.target)) { + close(); + } + }; + const onKeydown = event => { + if(event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + const toggle = () => { + open.value = !open.value; + + if(open.value) { + setTimeout(() => document.addEventListener("click", onOutsideClick), 0); + document.addEventListener("keydown", onKeydown); + } else { + close(); + } + }; + const select = item => { + if(item.disabled) { + return; + } + + item.onSelect?.(item); + emit("select", item); + close(); + }; + + onBeforeUnmount(close); + + return () => h("div", { ref: root, class: cx("dropdown", { "is-open": open.value }) }, [ + slots.trigger?.({ open: open.value, toggle }) || h(GnButton, { + variant: props.variant, + icon: props.icon, + "aria-expanded": open.value ? "true" : "false", + onClick: toggle + }, () => props.label), + h("div", { class: "dropdown-menu", role: "menu" }, slots.default?.({ close }) || props.items.map(item => h("button", { + class: cx("dropdown-item", item.danger && "dropdown-item-danger"), + type: "button", + role: "menuitem", + disabled: item.disabled, + onClick: () => select(item) + }, [ + iconNode(item.icon), + item.label + ]))) + ]); + } +}); diff --git a/src/vue/components/GnNavList.js b/src/vue/components/GnNavList.js new file mode 100644 index 0000000..925672a --- /dev/null +++ b/src/vue/components/GnNavList.js @@ -0,0 +1,31 @@ +import { defineComponent, h } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnNavList", + props: { + items: { type: Array, default: () => [] } + }, + emits: ["select"], + setup(props, { attrs, emit, slots }) { + return () => h("ul", { ...attrs, class: cx("list list-nav", attrs.class) }, props.items.map(item => h("li", { + class: cx("list-item", { "list-item-active": item.active }) + }, [ + h(item.href ? "a" : "button", { + class: "list-action", + href: item.href, + type: item.href ? undefined : "button", + onClick: event => { + item.onSelect?.(item, event); + emit("select", item); + } + }, [ + h("span", { class: "list-label" }, [ + iconNode(item.icon), + slots.label?.({ item }) || item.label + ]), + (item.meta || slots.meta) && h("span", { class: "list-meta" }, slots.meta?.({ item }) || item.meta) + ]) + ]))); + } +}); diff --git a/src/vue/components/GnNavigationShell.js b/src/vue/components/GnNavigationShell.js new file mode 100644 index 0000000..0a73adc --- /dev/null +++ b/src/vue/components/GnNavigationShell.js @@ -0,0 +1,115 @@ +import { defineComponent, h, nextTick, onBeforeUnmount, ref, watch } from "vue"; +import { iconNode } from "../utils.js"; +import GnNavList from "./GnNavList.js"; + +let shellId = 0; + +export default defineComponent({ + name: "GnNavigationShell", + props: { + brand: { type: String, default: "GNexus UI Kit" }, + logoSrc: { type: String, default: "/assets/imgs/gnexus-mark.svg" }, + current: { type: String, default: "" }, + title: { type: String, default: "Sections" }, + subtitle: { type: String, default: "Navigation" }, + footerLeft: { type: String, default: "" }, + footerRight: { type: String, default: "" }, + items: { type: Array, default: () => [] } + }, + emits: ["select"], + setup(props, { emit, slots }) { + const open = ref(false); + const drawerId = `gn-nav-drawer-${++shellId}`; + const drawerRef = ref(null); + let previousFocus = null; + const close = () => { + open.value = false; + }; + const toggle = () => { + open.value = !open.value; + }; + const onKeydown = event => { + if(event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + + watch(open, isOpen => { + if(isOpen) { + previousFocus = document.activeElement; + document.body.classList.add("nav-drawer-open"); + document.addEventListener("keydown", onKeydown); + nextTick(() => drawerRef.value?.focus()); + } else { + document.body.classList.remove("nav-drawer-open"); + document.removeEventListener("keydown", onKeydown); + previousFocus?.focus?.(); + previousFocus = null; + } + }); + + onBeforeUnmount(() => { + document.body.classList.remove("nav-drawer-open"); + document.removeEventListener("keydown", onKeydown); + }); + + return () => [ + h("header", { class: "nav-topbar" }, [ + h("button", { + class: "nav-topbar-toggle", + type: "button", + "aria-controls": drawerId, + "aria-expanded": open.value ? "true" : "false", + onClick: toggle + }, [ + iconNode("ph-sidebar-simple"), + h("span", {}, "Menu") + ]), + h("div", { class: "nav-topbar-brand" }, [ + props.logoSrc && h("img", { src: props.logoSrc, alt: "", "aria-hidden": "true" }), + h("span", {}, slots.brand?.() || props.brand) + ]), + h("div", { class: "nav-topbar-current" }, slots.current?.() || props.current) + ]), + h("div", { class: "nav-drawer-backdrop", onClick: close }), + h("aside", { + ref: drawerRef, + class: ["nav-drawer", { "is-open": open.value }], + id: drawerId, + "aria-label": "Navigation", + "aria-hidden": open.value ? "false" : "true", + tabindex: "-1" + }, [ + h("header", { class: "nav-drawer-header" }, [ + h("div", {}, [ + h("div", { class: "nav-drawer-title" }, slots.title?.() || props.title), + h("div", { class: "nav-drawer-subtitle" }, slots.subtitle?.() || props.subtitle) + ]), + h("button", { + class: "nav-drawer-close", + type: "button", + "aria-label": "Close navigation", + onClick: close + }, [iconNode("ph-x")]) + ]), + h("nav", { class: "nav-drawer-body" }, [ + slots.default?.({ close }) || h(GnNavList, { + items: props.items, + onSelect: item => { + emit("select", item); + close(); + } + }) + ]), + (slots.footer || props.footerLeft || props.footerRight) && h("footer", { class: "nav-drawer-footer" }, + slots.footer?.() || [ + h("span", {}, props.footerLeft), + h("span", {}, props.footerRight) + ] + ) + ]), + slots.content?.() + ]; + } +}); diff --git a/src/vue/components/GnPopover.js b/src/vue/components/GnPopover.js new file mode 100644 index 0000000..f6bf32b --- /dev/null +++ b/src/vue/components/GnPopover.js @@ -0,0 +1,59 @@ +import { defineComponent, h, onBeforeUnmount, ref } from "vue"; +import { cx } from "../utils.js"; +import GnButton from "./GnButton.js"; + +export default defineComponent({ + name: "GnPopover", + props: { + label: { type: String, default: "Details" }, + title: { type: String, default: "" }, + text: { type: String, default: "" }, + icon: { type: String, default: "ph-info" }, + variant: { type: String, default: "accent" } + }, + setup(props, { slots }) { + const open = ref(false); + const root = ref(null); + const close = () => { + open.value = false; + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onKeydown); + }; + const onOutsideClick = event => { + if(root.value && !root.value.contains(event.target)) { + close(); + } + }; + const onKeydown = event => { + if(event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + const toggle = () => { + open.value = !open.value; + + if(open.value) { + setTimeout(() => document.addEventListener("click", onOutsideClick), 0); + document.addEventListener("keydown", onKeydown); + } else { + close(); + } + }; + + onBeforeUnmount(close); + + return () => h("div", { ref: root, class: cx("popover", { "is-open": open.value }) }, [ + slots.trigger?.({ open: open.value, toggle }) || h(GnButton, { + variant: props.variant, + icon: props.icon, + "aria-expanded": open.value ? "true" : "false", + onClick: toggle + }, () => props.label), + h("div", { class: "popover-panel" }, [ + (props.title || slots.title) && h("h3", { class: "popover-title" }, slots.title?.() || props.title), + (props.text || slots.default) && h("p", { class: "popover-text" }, slots.default?.() || props.text) + ]) + ]); + } +}); diff --git a/src/vue/components/GnTooltip.js b/src/vue/components/GnTooltip.js new file mode 100644 index 0000000..8274d1e --- /dev/null +++ b/src/vue/components/GnTooltip.js @@ -0,0 +1,26 @@ +import { defineComponent, h, ref } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnTooltip", + props: { + text: { type: String, default: "" } + }, + setup(props, { attrs, slots }) { + const open = ref(false); + + return () => h("span", { + ...attrs, + class: cx("tooltip", { "is-open": open.value }, attrs.class), + onFocusin: () => { + open.value = true; + }, + onFocusout: () => { + open.value = false; + } + }, [ + slots.default?.(), + h("span", { class: "tooltip-panel", role: "tooltip" }, slots.panel?.() || props.text) + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 28f0164..14b1f2d 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -11,6 +11,7 @@ export { default as GnChipGroup } from "./components/GnChipGroup.js"; export { default as GnConfirmDialog } from "./components/GnConfirmDialog.js"; export { default as GnDescriptionList } from "./components/GnDescriptionList.js"; +export { default as GnDropdown } from "./components/GnDropdown.js"; export { default as GnDrawer } from "./components/GnDrawer.js"; export { default as GnEmptyState } from "./components/GnEmptyState.js"; export { default as GnIconButton } from "./components/GnIconButton.js"; @@ -18,8 +19,11 @@ export { default as GnInput } from "./components/GnInput.js"; export { default as GnInputGroup } from "./components/GnInputGroup.js"; export { default as GnModal } from "./components/GnModal.js"; +export { default as GnNavList } from "./components/GnNavList.js"; +export { default as GnNavigationShell } from "./components/GnNavigationShell.js"; export { default as GnPageHeader } from "./components/GnPageHeader.js"; export { default as GnPagination } from "./components/GnPagination.js"; +export { default as GnPopover } from "./components/GnPopover.js"; export { default as GnProgress } from "./components/GnProgress.js"; export { default as GnSearchField } from "./components/GnSearchField.js"; export { default as GnSelect } from "./components/GnSelect.js"; @@ -30,6 +34,7 @@ export { default as GnTabs } from "./components/GnTabs.js"; export { default as GnTextarea } from "./components/GnTextarea.js"; export { default as GnTimeline } from "./components/GnTimeline.js"; +export { default as GnTooltip } from "./components/GnTooltip.js"; export { default as GnToolbar } from "./components/GnToolbar.js"; export { default as GnToastProvider } from "./components/GnToastProvider.js"; export { useToast } from "./composables/useToast.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 87e5286..2424c9e 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -11,6 +11,7 @@ import GnChipGroup from "./components/GnChipGroup.js"; import GnConfirmDialog from "./components/GnConfirmDialog.js"; import GnDescriptionList from "./components/GnDescriptionList.js"; +import GnDropdown from "./components/GnDropdown.js"; import GnDrawer from "./components/GnDrawer.js"; import GnEmptyState from "./components/GnEmptyState.js"; import GnIconButton from "./components/GnIconButton.js"; @@ -18,8 +19,11 @@ import GnInput from "./components/GnInput.js"; import GnInputGroup from "./components/GnInputGroup.js"; import GnModal from "./components/GnModal.js"; +import GnNavList from "./components/GnNavList.js"; +import GnNavigationShell from "./components/GnNavigationShell.js"; import GnPageHeader from "./components/GnPageHeader.js"; import GnPagination from "./components/GnPagination.js"; +import GnPopover from "./components/GnPopover.js"; import GnProgress from "./components/GnProgress.js"; import GnSearchField from "./components/GnSearchField.js"; import GnSelect from "./components/GnSelect.js"; @@ -30,6 +34,7 @@ import GnTabs from "./components/GnTabs.js"; import GnTextarea from "./components/GnTextarea.js"; import GnTimeline from "./components/GnTimeline.js"; +import GnTooltip from "./components/GnTooltip.js"; import GnToolbar from "./components/GnToolbar.js"; import GnToastProvider from "./components/GnToastProvider.js"; @@ -47,6 +52,7 @@ GnChipGroup, GnConfirmDialog, GnDescriptionList, + GnDropdown, GnDrawer, GnEmptyState, GnIconButton, @@ -54,8 +60,11 @@ GnInput, GnInputGroup, GnModal, + GnNavList, + GnNavigationShell, GnPageHeader, GnPagination, + GnPopover, GnProgress, GnSearchField, GnSelect, @@ -66,6 +75,7 @@ GnTabs, GnTextarea, GnTimeline, + GnTooltip, GnToolbar, GnToastProvider };