diff --git a/.gitignore b/.gitignore index 858543d..70017bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ webclient/ node_modules/ dist/ +.tmp diff --git a/docs/vue.md b/docs/vue.md index 0a3e838..c0a081b 100644 --- a/docs/vue.md +++ b/docs/vue.md @@ -87,6 +87,8 @@ toast.success({ title: "Saved", text: "Changes applied" }); ``` +`GnToastProvider` intentionally keeps a single visible toast. Showing a new toast replaces the previous one. Use this contract for compact app feedback; add queue support in this repository if a project needs stacked notifications. + ## Build ```bash diff --git a/docs/vue/ai-usage-guide.md b/docs/vue/ai-usage-guide.md index dbc682f..3dfd160 100644 --- a/docs/vue/ai-usage-guide.md +++ b/docs/vue/ai-usage-guide.md @@ -117,3 +117,10 @@ Warnings about `/assets/fonts/...` in Vite builds are expected with the current CSS contract. They mean the host app must serve GNexus UI Kit assets at `/assets`. The local example clears `examples/vue/node_modules/.vite` before build because `gnexus-ui-kit` is linked from the repository root and Vite can otherwise reuse stale optimized metadata after `dist/vue` changes. + +## Behavior Contracts + +- `GnModal` and `GnDrawer` handle Escape, focus return, and Tab focus trapping. +- `GnToastProvider` is single-toast by design: a new toast replaces the current one. +- `GnCombobox` owns combobox/listbox ARIA and keyboard movement. +- `GnFileUpload` owns preview object URLs and cleans them up on remove/unmount. diff --git a/docs/vue/migration-policy.md b/docs/vue/migration-policy.md index 30daf48..ccf0108 100644 --- a/docs/vue/migration-policy.md +++ b/docs/vue/migration-policy.md @@ -32,6 +32,8 @@ - improving keyboard/focus behavior without changing public API; - adding docs and examples. +Document behavior changes when they affect user expectations, even if they are not breaking. Examples: focus trapping, toast queueing policy, or file preview cleanup. + ## Downstream Rules Vue projects should not: diff --git a/src/vue/components/GnCombobox.js b/src/vue/components/GnCombobox.js index 7c21ef0..42db564 100644 --- a/src/vue/components/GnCombobox.js +++ b/src/vue/components/GnCombobox.js @@ -1,6 +1,8 @@ import { computed, defineComponent, h, nextTick, ref } from "vue"; import { cx, eventValue, iconNode } from "../utils.js"; +let comboboxId = 0; + export default defineComponent({ name: "GnCombobox", inheritAttrs: false, @@ -16,6 +18,8 @@ }, emits: ["update:modelValue", "select"], setup(props, { attrs, emit }) { + const id = `gn-combobox-${++comboboxId}`; + const listboxId = `${id}-listbox`; const open = ref(false); const focused = ref(-1); const inputRef = ref(null); @@ -70,10 +74,16 @@ h("input", { ...attrs, ref: inputRef, + id, type: "text", value: props.modelValue, placeholder: props.placeholder, autocomplete: "off", + role: "combobox", + "aria-autocomplete": "list", + "aria-expanded": open.value ? "true" : "false", + "aria-controls": listboxId, + "aria-activedescendant": focused.value >= 0 ? `${id}-option-${focused.value}` : undefined, class: cx("input", attrs.class), onFocus: () => { open.value = true; @@ -95,8 +105,15 @@ h("div", { class: cx("advanced-select", { "a-show": open.value }) }, [ h("div", { class: "popup-options-container" }, [ h("div", { class: cx("not-found", { show: !filtered.value.length }) }, props.notFoundText), - h("div", { class: cx("options", { show: filtered.value.length }) }, filtered.value.map((option, index) => h("div", { + h("div", { + id: listboxId, + class: cx("options", { show: filtered.value.length }), + role: "listbox" + }, filtered.value.map((option, index) => h("div", { + id: `${id}-option-${index}`, class: cx("option", { focus: index === focused.value }), + role: "option", + "aria-selected": index === focused.value ? "true" : "false", "data-value": option.value, "data-display-value": option.label, onMousedown: event => { diff --git a/src/vue/components/GnDrawer.js b/src/vue/components/GnDrawer.js index 6e8f05d..b9c40a8 100644 --- a/src/vue/components/GnDrawer.js +++ b/src/vue/components/GnDrawer.js @@ -1,5 +1,5 @@ import { defineComponent, h, nextTick, onBeforeUnmount, ref, Teleport, watch } from "vue"; -import { cx, iconNode } from "../utils.js"; +import { cx, iconNode, trapFocus } from "../utils.js"; let drawerId = 0; @@ -23,6 +23,8 @@ if(event.key === "Escape") { event.preventDefault(); close(); + } else { + trapFocus(event, panelRef.value); } }; diff --git a/src/vue/components/GnFileUpload.js b/src/vue/components/GnFileUpload.js index 359eb39..afaff84 100644 --- a/src/vue/components/GnFileUpload.js +++ b/src/vue/components/GnFileUpload.js @@ -1,4 +1,4 @@ -import { defineComponent, h, ref } from "vue"; +import { defineComponent, h, onBeforeUnmount, ref, watch } from "vue"; import { iconNode } from "../utils.js"; import GnButton from "./GnButton.js"; import GnBadge from "./GnBadge.js"; @@ -34,12 +34,25 @@ emits: ["update:modelValue", "change"], setup(props, { emit, slots }) { const urls = ref(new Map()); + const revokeFile = file => { + const url = urls.value.get(file); + + if(url) { + URL.revokeObjectURL(url); + urls.value.delete(file); + } + }; + const revokeAll = () => { + urls.value.forEach(url => URL.revokeObjectURL(url)); + urls.value.clear(); + }; const setFiles = fileList => { const files = Array.from(fileList || []); emit("update:modelValue", files); emit("change", files); }; const remove = index => { + revokeFile(props.modelValue[index]); const files = props.modelValue.filter((_, itemIndex) => itemIndex !== index); emit("update:modelValue", files); emit("change", files); @@ -56,6 +69,17 @@ return urls.value.get(file); }; + watch(() => props.modelValue, files => { + const active = new Set(files); + [...urls.value.keys()].forEach(file => { + if(!active.has(file)) { + revokeFile(file); + } + }); + }); + + onBeforeUnmount(revokeAll); + return () => h("div", { class: "file-upload-panel" }, [ h("div", { class: "file-upload-form" }, [ h("div", { class: "file-upload-header" }, [ @@ -101,7 +125,10 @@ h(GnButton, { variant: "secondary", size: "sm", - onClick: () => setFiles([]) + onClick: () => { + revokeAll(); + setFiles([]); + } }, () => "Reset") ]) ]) diff --git a/src/vue/components/GnModal.js b/src/vue/components/GnModal.js index 304f23d..a5f10dc 100644 --- a/src/vue/components/GnModal.js +++ b/src/vue/components/GnModal.js @@ -1,5 +1,5 @@ import { defineComponent, h, nextTick, onBeforeUnmount, ref, Teleport, watch } from "vue"; -import { iconNode } from "../utils.js"; +import { iconNode, trapFocus } from "../utils.js"; let modalId = 0; @@ -23,6 +23,8 @@ if(event.key === "Escape") { event.preventDefault(); close(); + } else { + trapFocus(event, dialogRef.value); } }; const focusDialog = () => { diff --git a/src/vue/utils.js b/src/vue/utils.js index 1c1941f..d7565fb 100644 --- a/src/vue/utils.js +++ b/src/vue/utils.js @@ -66,3 +66,38 @@ return target.value; } + +export const focusableSelector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])" +].join(","); + +export function trapFocus(event, root) { + if(event.key !== "Tab" || !root) { + return; + } + + const focusable = [...root.querySelectorAll(focusableSelector)] + .filter(node => !node.hasAttribute("disabled") && node.offsetParent !== null); + + if(!focusable.length) { + event.preventDefault(); + root.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if(event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if(!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +}