diff --git a/PROJECT_NOTES.md b/PROJECT_NOTES.md index 9eace9a..b94deaa 100644 --- a/PROJECT_NOTES.md +++ b/PROJECT_NOTES.md @@ -47,8 +47,13 @@ - `src/scss/components/_buttons.scss` — варианты primary/secondary/tertiary/accent/success/warning/info/danger/error, размеры lg/md/sm/xs/xxs, состояния disabled/loading, destructive secondary/tertiary. - `src/vue/components/GnButton.js` — API-совместим с `gnexus-ui-kit` (`variant`, `size`, `icon`, `loading`, `disabled`, `type`). - `src/vue/components/Icon.js` — асинхронный inline SVG. - - `src/vue/utils.js` — `cx`, `normalizeVariant`, `normalizeSize`, `iconNode` с поддержкой legacy `ph-` имён. + - `src/vue/utils.js` — `cx`, `normalizeVariant`, `normalizeSize`, `iconNode`, `trapFocus` с поддержкой legacy `ph-` имён. - `demo/partials/buttons.html` — визуальные примеры. +- **Overlay-компоненты реализованы:** + - `src/scss/components/_modals.scss` + `src/vue/components/GnModal.js` — фокус-трап, Escape, клик по backdrop, телепорт в body. + - `src/scss/components/_drawer.scss` + `src/vue/components/GnDrawer.js` — выдвижная панель справа/слева. + - `src/scss/components/_toasts.scss` + `src/js/components/toasts.js` + `src/vue/components/GnToastProvider.js` + `src/vue/composables/useToast.js` — тосты с прогресс-баром и lifetime. + - `demo/partials/modals.html`, `drawer.html`, `toasts.html` — демо-примеры. - Проверены команды: - `npm run build` — проходит. - `npm run build:example:vue` / `npm run test:vue-adapter` — проходят. diff --git a/demo/index.html b/demo/index.html index a943ac6..9e40c22 100644 --- a/demo/index.html +++ b/demo/index.html @@ -25,6 +25,9 @@ @@include('./partials/cards.html') @@include('./partials/badges.html') @@include('./partials/alerts.html') + @@include('./partials/modals.html') + @@include('./partials/drawer.html') + @@include('./partials/toasts.html') diff --git a/demo/partials/drawer.html b/demo/partials/drawer.html new file mode 100644 index 0000000..4c075c0 --- /dev/null +++ b/demo/partials/drawer.html @@ -0,0 +1,46 @@ +
+

Drawers

+ +
+ + +
+ + + + +
diff --git a/demo/partials/modals.html b/demo/partials/modals.html new file mode 100644 index 0000000..bc13e75 --- /dev/null +++ b/demo/partials/modals.html @@ -0,0 +1,30 @@ +
+

Modals

+ +
+ +
+ + +
diff --git a/demo/partials/toasts.html b/demo/partials/toasts.html new file mode 100644 index 0000000..db5e9ab --- /dev/null +++ b/demo/partials/toasts.html @@ -0,0 +1,18 @@ +
+

Toasts

+ +
+ + + + +
+
diff --git a/docs/component-coverage.md b/docs/component-coverage.md index ee0574e..c442098 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -24,9 +24,9 @@ | Switch | done | GnSwitch | partial | | Textarea | done | GnTextarea | partial | | Card | done | GnCard | partial | -| Modal | — | — | — | -| Drawer | — | — | — | -| Toast | — | — | — | +| Modal | done | GnModal | partial | +| Drawer | done | GnDrawer | partial | +| Toast | done | GnToastProvider, useToast | partial | | Tabs | — | — | — | | Table | — | — | — | | List | — | — | — | diff --git a/src/js/components/toasts.js b/src/js/components/toasts.js new file mode 100644 index 0000000..da8e671 --- /dev/null +++ b/src/js/components/toasts.js @@ -0,0 +1,213 @@ +/** + * VMK UI Kit browser toast helper. + * + * Mirrors the public surface of gnexus-ui-kit/src/js/components/toasts.js: + * create, createInfo, createSuccess, createWarning, createError, createDanger + */ + +const ICON_BY_TYPE = { + info: "essentials/information/circle", + success: "essentials/check/circle", + warning: "essentials/alert-sign", + danger: "essentials/alert/circle", + error: "essentials/alert/circle" +}; + +const TITLE_BY_TYPE = { + info: "Info", + success: "Success", + warning: "Warning", + danger: "Error", + error: "Error" +}; + +let indexPromise = null; + +function loadIconIndex() { + if (indexPromise) return indexPromise; + indexPromise = fetch("/assets/icons/index.json") + .then(r => r.json()) + .catch(err => { + console.error(err); + return {}; + }); + return indexPromise; +} + +function svgUrl(relativePath) { + return `/assets/icons/svg/${relativePath}`; +} + +function sanitizeSvg(svgText) { + return svgText + .replace(//gi, "") + .replace(/on\w+="[^"]*"/gi, "") + .replace(/on\w+='[^']*'/gi, "") + .replace(//gi, "") + .replace(//g, ""); +} + +async function fetchIconSvg(name) { + const index = await loadIconIndex(); + const rel = index[name]; + if (!rel) return ""; + const res = await fetch(svgUrl(rel)); + if (!res.ok) return ""; + return sanitizeSvg(await res.text()); +} + +function appendIcon(container, svgText) { + if (!svgText) return; + const wrap = document.createElement("span"); + wrap.innerHTML = svgText; + const svg = wrap.querySelector("svg"); + if (svg) { + svg.setAttribute("aria-hidden", "true"); + svg.classList.add("vmk-icon"); + container.append(svg); + } +} + +function template(type, iconSvg, title, text) { + const toast = document.createElement("div"); + toast.className = `toast toast-${type}`; + toast.setAttribute("role", "alert"); + + const content = document.createElement("div"); + content.className = "toast-content"; + + const header = document.createElement("div"); + header.className = "toast-header"; + appendIcon(header, iconSvg); + header.append(document.createTextNode(` ${title ?? ""}`)); + + content.append(header); + + if (text) { + const toastText = document.createElement("p"); + toastText.className = "toast-text"; + toastText.textContent = text; + content.append(toastText); + } + + const progress = document.createElement("div"); + progress.className = "toast-progress"; + + const progressBar = document.createElement("div"); + progressBar.className = "toast-progress-bar"; + progress.append(progressBar); + + const close = document.createElement("button"); + close.className = "btn-icon toast-close"; + close.type = "button"; + close.setAttribute("aria-label", "Close"); + + fetchIconSvg("essentials/close-cross").then(svg => { + appendIcon(close, svg); + }); + + toast.append(content, close, progress); + + return toast; +} + +function init(toast, props) { + const lifetime = props?.lifetime !== undefined ? props.lifetime : 4000; + + if (props?.alone) { + document.querySelectorAll(".toast").forEach(i => i.close?.()); + } + + const progressBar = toast.querySelector(".toast-progress-bar"); + if (progressBar && lifetime > 0) { + progressBar.style.animationDuration = `${lifetime}ms`; + } + + toast.close = function () { + this.classList.add("a-hide"); + setTimeout(() => { + this.remove(); + }, 300); + }; + + toast.show = function () { + document.querySelector("body").append(toast); + setTimeout(() => { + toast.classList.add("a-show"); + }, 10); + }; + + toast.addEventListener("mouseover", () => { + toast.ishovered = true; + }); + toast.addEventListener("mouseout", () => { + toast.ishovered = false; + }); + + toast.querySelector(".toast-close").addEventListener("click", () => { + toast.close(); + }); + + if (lifetime > 0) { + let elapsed = 0; + const interval = setInterval(() => { + elapsed += lifetime; + if (!toast.ishovered && elapsed >= lifetime) { + toast.close(); + clearInterval(interval); + } + }, lifetime); + } + + return toast; +} + +async function create(type, iconOverride, title, text, props) { + const resolvedType = type === "error" ? "danger" : type; + const iconName = iconOverride || ICON_BY_TYPE[resolvedType] || ICON_BY_TYPE.info; + const iconSvg = await fetchIconSvg(iconName); + const toast = template(resolvedType, iconSvg, title, text); + return init(toast, props); +} + +function applyDefaults(props) { + if (typeof props === "undefined") { + props = {}; + } + if (typeof props.lifetime === "undefined") { + props.lifetime = 4000; + } + if (typeof props.alone === "undefined") { + props.alone = true; + } + return props; +} + +async function createSuccess(title, text, props) { + props = applyDefaults(props); + return create("success", null, title, text, props); +} + +async function createInfo(title, text, props) { + props = applyDefaults(props); + return create("info", null, title, text, props); +} + +async function createWarning(title, text, props) { + props = applyDefaults(props); + return create("warning", null, title, text, props); +} + +async function createError(title, text, props) { + props = applyDefaults(props); + return create("danger", null, title, text, props); +} + +export default { + create, + createInfo, + createSuccess, + createWarning, + createError, + createDanger: createError +}; diff --git a/src/js/index.js b/src/js/index.js index 82e36d3..a09c398 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -4,9 +4,16 @@ */ import * as Icons from "./components/icon-helper.js"; +import Toasts from "./components/toasts.js"; const api = { - Icons + Icons, + Toasts, + Helper: { + iconSvg: Icons.iconSvg, + iconNode: Icons.iconNode, + iconHtml: Icons.iconHtml + } }; if (typeof window !== "undefined") { @@ -17,5 +24,5 @@ }); } -export { Icons }; +export { Icons, Toasts }; export default api; diff --git a/src/scss/components/_drawer.scss b/src/scss/components/_drawer.scss new file mode 100644 index 0000000..15c5481 --- /dev/null +++ b/src/scss/components/_drawer.scss @@ -0,0 +1,120 @@ +/* ========================= + Drawers +========================= */ + +@use "../kit-deps" as *; + +.drawer { + position: fixed; + inset: 0; + z-index: 1000; + + &.a-show .drawer-backdrop { + opacity: 1; + } + &.a-show .drawer-panel { + transform: translateX(0); + } + + &.a-hide .drawer-backdrop { + opacity: 0; + } + &.a-hide .drawer-panel { + transform: translateX(100%); + } + + &.drawer-left.a-show .drawer-panel { + transform: translateX(0); + } + &.drawer-left.a-hide .drawer-panel { + transform: translateX(-100%); + } + + .drawer-backdrop { + position: absolute; + inset: 0; + background-color: rgba($neutral-900, 0.48); + opacity: 0; + transition: opacity $motion-base $motion-ease; + } + + .drawer-panel { + position: absolute; + top: 0; + right: 0; + width: 100%; + max-width: 420px; + height: 100%; + display: flex; + flex-direction: column; + background-color: $neutral-0; + box-shadow: -16px 0 48px rgba($neutral-900, 0.16); + transform: translateX(100%); + transition: transform $motion-base $motion-ease; + + .drawer-left & { + right: auto; + left: 0; + transform: translateX(-100%); + box-shadow: 16px 0 48px rgba($neutral-900, 0.16); + } + } + + .drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + padding: $space-4 $space-5; + border-bottom: 1px solid $neutral-100; + } + + .drawer-title { + margin: 0; + font-size: 18px; + font-weight: $font-weight-bold; + color: $neutral-900; + } + + .drawer-close { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: $radius-full; + background-color: transparent; + color: $neutral-700; + cursor: pointer; + transition: background-color $motion-base $motion-ease, color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-100; + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + } + + .drawer-body { + flex: 1; + overflow-y: auto; + padding: $space-5; + font-size: 15px; + line-height: 1.5; + color: $neutral-800; + } + + .drawer-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $space-3; + padding: $space-3 $space-5; + border-top: 1px solid $neutral-100; + background-color: $neutral-50; + } +} diff --git a/src/scss/components/_modals.scss b/src/scss/components/_modals.scss new file mode 100644 index 0000000..cf19115 --- /dev/null +++ b/src/scss/components/_modals.scss @@ -0,0 +1,121 @@ +/* ========================= + Modals +========================= */ + +@use "../kit-deps" as *; + +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: $space-4; + + &.a-show .modal-backdrop { + opacity: 1; + } + &.a-show .modal-dialog { + opacity: 1; + transform: translateY(0) scale(1); + } + + &.a-hide .modal-backdrop { + opacity: 0; + } + &.a-hide .modal-dialog { + opacity: 0; + transform: translateY(-8px) scale(0.98); + } + + .modal-backdrop { + position: absolute; + inset: 0; + background-color: rgba($neutral-900, 0.48); + opacity: 0; + transition: opacity $motion-base $motion-ease; + } + + .modal-dialog { + position: relative; + z-index: 1; + width: 100%; + max-width: 560px; + max-height: calc(100vh - #{$space-8}); + display: flex; + flex-direction: column; + background-color: $neutral-0; + border-radius: $radius-lg; + box-shadow: 0 24px 64px rgba($neutral-900, 0.24); + overflow: hidden; + opacity: 0; + transform: translateY(-8px) scale(0.98); + transition: + opacity $motion-base $motion-ease, + transform $motion-base $motion-ease; + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + padding: $space-4 $space-5; + border-bottom: 1px solid $neutral-100; + } + + .modal-title { + margin: 0; + font-size: 18px; + font-weight: $font-weight-bold; + color: $neutral-900; + } + + .modal-close { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: $radius-full; + background-color: transparent; + color: $neutral-700; + cursor: pointer; + transition: background-color $motion-base $motion-ease, color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-100; + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + } + + .modal-panel { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + } + + .modal-body { + padding: $space-5; + font-size: 15px; + line-height: 1.5; + color: $neutral-800; + } + + .modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $space-3; + padding: $space-3 $space-5; + border-top: 1px solid $neutral-100; + background-color: $neutral-50; + } +} diff --git a/src/scss/components/_toasts.scss b/src/scss/components/_toasts.scss new file mode 100644 index 0000000..ac68487 --- /dev/null +++ b/src/scss/components/_toasts.scss @@ -0,0 +1,169 @@ +/* ========================= + Toasts +========================= */ + +@use "../kit-deps" as *; + +.toast { + position: fixed; + z-index: 1100; + bottom: -100px; + right: $space-4; + max-width: 420px; + width: calc(100% - #{$space-8}); + + background: $neutral-0; + border: $border-width-base $border-style-base $border-color-muted; + border-left-width: $border-width-accent; + border-radius: $radius-md; + padding: 0; + opacity: 0; + overflow: hidden; + box-shadow: 0 16px 40px rgba($neutral-900, 0.16); + + transition: + opacity $motion-slow $motion-ease, + bottom $motion-slow $motion-ease; + + &.a-show { + bottom: $space-4; + opacity: 1; + } + + &.a-hide { + bottom: $space-4 - 60px; + opacity: 0; + } + + .toast-content { + display: flex; + flex-direction: column; + gap: 0; + padding: $space-3 $space-4; + padding-right: $space-10; + + .toast-header { + display: flex; + align-items: center; + gap: $space-2; + font-size: 14px; + font-weight: $font-weight-bold; + text-transform: uppercase; + color: $neutral-900; + line-height: 1; + + .vmk-icon { + width: $icon-size-md; + height: $icon-size-md; + flex-shrink: 0; + } + } + + .toast-text { + font-size: 14px; + padding: $space-2 0 0; + margin: 0; + color: $neutral-700; + line-height: 1.5; + } + } + + .toast-close { + position: absolute; + top: $space-1; + right: $space-2; + color: $neutral-600; + width: 32px; + height: 32px; + border-color: transparent; + background: transparent; + border-radius: $radius-full; + cursor: pointer; + transition: + background-color $motion-base $motion-ease, + color $motion-base $motion-ease; + + &:hover { + background-color: $neutral-100; + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + } + } + + .toast-progress { + height: 3px; + width: 100%; + background: $neutral-100; + overflow: hidden; + margin-top: 1px; + + .toast-progress-bar { + height: 100%; + width: 100%; + transform-origin: left; + animation: toast-progress linear forwards; + background: $neutral-900; + } + } + + &.toast-info { + border-color: $color-info; + background: $info-bg; + + .toast-header .vmk-icon { + color: $color-info; + } + + .toast-progress-bar { + background: $color-info; + } + } + + &.toast-success { + border-color: $color-success; + background: $success-bg; + + .toast-header .vmk-icon { + color: $color-success; + } + + .toast-progress-bar { + background: $color-success; + } + } + + &.toast-warning { + border-color: $color-warning; + background: $warning-bg; + + .toast-header .vmk-icon { + color: $color-warning; + } + + .toast-progress-bar { + background: $color-warning; + } + } + + &.toast-danger, + &.toast-error { + border-color: $color-error; + background: $danger-bg; + + .toast-header .vmk-icon { + color: $color-error; + } + + .toast-progress-bar { + background: $color-error; + } + } +} + +@keyframes toast-progress { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 48f9c0f..206288c 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -11,6 +11,9 @@ @use "components/radio"; @use "components/switch"; @use "components/cards"; +@use "components/modals"; +@use "components/drawer"; +@use "components/toasts"; @use "utils"; * { diff --git a/src/vue/components/GnDrawer.js b/src/vue/components/GnDrawer.js new file mode 100644 index 0000000..6f17685 --- /dev/null +++ b/src/vue/components/GnDrawer.js @@ -0,0 +1,94 @@ +import { defineComponent, h, nextTick, onBeforeUnmount, ref, Teleport, watch } from "vue"; +import { cx, iconNode, trapFocus } 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 panelRef = ref(null); + const visible = ref(false); + const closing = ref(false); + let previousFocus = null; + let closeTimer = null; + + const close = () => { + emit("update:open", false); + emit("close"); + }; + + const onKeydown = event => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } else { + trapFocus(event, panelRef.value); + } + }; + + watch(() => props.open, open => { + if (open) { + closing.value = true; + visible.value = true; + nextTick(() => { + requestAnimationFrame(() => { + closing.value = false; + }); + }); + previousFocus = document.activeElement; + document.addEventListener("keydown", onKeydown); + nextTick(() => panelRef.value?.focus()); + } else { + closing.value = true; + document.removeEventListener("keydown", onKeydown); + previousFocus?.focus?.(); + previousFocus = null; + closeTimer = window.setTimeout(() => { + visible.value = false; + closing.value = false; + }, 300); + } + }, { flush: "post" }); + + onBeforeUnmount(() => { + document.removeEventListener("keydown", onKeydown); + window.clearTimeout(closeTimer); + }); + + return () => visible.value ? h(Teleport, { to: "body" }, [ + h("div", { + class: cx("drawer", closing.value ? "a-hide" : "a-show", { "drawer-left": props.position === "left" }), + "aria-hidden": "false" + }, [ + h("div", { class: "drawer-backdrop", onClick: close }), + h("aside", { + ref: panelRef, + class: "drawer-panel", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": titleId, + tabindex: "-1" + }, [ + h("header", { class: "drawer-header" }, [ + h("h4", { class: "drawer-title", id: titleId }, slots.title?.() || props.title), + h("button", { + class: "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/GnModal.js b/src/vue/components/GnModal.js new file mode 100644 index 0000000..9515c7a --- /dev/null +++ b/src/vue/components/GnModal.js @@ -0,0 +1,121 @@ +/** + * GnModal - Accessible modal dialog with focus trapping and teleport. + * + * @typedef {Object} GnModalProps + * @property {boolean} [open=false] - Dialog visibility + * @property {string} [title=''] - Dialog title + * @property {boolean} [closeOnBackdrop=true] - Click backdrop to close + * + * @slots default - Modal body content + * @slots title - Override header title + * @slots footer - Footer content + * @slots actions - Action buttons (receives { close }) + * @emits update:open + * @emits close + */ + +import { defineComponent, h, nextTick, onBeforeUnmount, ref, Teleport, watch } from "vue"; +import { cx, iconNode, trapFocus } 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 dialogRef = ref(null); + const visible = ref(false); + const closing = ref(false); + let previousFocus = null; + let closeTimer = null; + + const close = () => { + emit("update:open", false); + emit("close"); + }; + + const onKeydown = event => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } else { + trapFocus(event, dialogRef.value); + } + }; + + const focusDialog = () => { + nextTick(() => { + dialogRef.value?.focus(); + }); + }; + + watch(() => props.open, open => { + if (open) { + closing.value = true; + visible.value = true; + nextTick(() => { + requestAnimationFrame(() => { + closing.value = false; + }); + }); + previousFocus = document.activeElement; + document.addEventListener("keydown", onKeydown); + focusDialog(); + } else { + closing.value = true; + document.removeEventListener("keydown", onKeydown); + previousFocus?.focus?.(); + previousFocus = null; + closeTimer = window.setTimeout(() => { + visible.value = false; + closing.value = false; + }, 300); + } + }, { flush: "post" }); + + onBeforeUnmount(() => { + document.removeEventListener("keydown", onKeydown); + window.clearTimeout(closeTimer); + }); + + return () => visible.value ? h(Teleport, { to: "body" }, [ + h("div", { class: cx("modal", closing.value ? "a-hide" : "a-show"), "aria-hidden": "false" }, [ + h("div", { + class: "modal-backdrop", + onClick: () => props.closeOnBackdrop && close() + }), + h("div", { + ref: dialogRef, + class: "modal-dialog", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": titleId, + tabindex: "-1" + }, [ + h("header", { class: "modal-header" }, [ + h("h4", { class: "modal-title", id: titleId }, slots.title?.() || props.title), + h("button", { + class: "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/GnToastProvider.js b/src/vue/components/GnToastProvider.js new file mode 100644 index 0000000..c1a1163 --- /dev/null +++ b/src/vue/components/GnToastProvider.js @@ -0,0 +1,147 @@ +/** + * GnToastProvider - Renderless-ish toast host for Vue. + * + * Renders slot content plus a single toast at the bottom-right. + * Exposes an imperative API via provide/inject and component expose. + */ + +import { defineComponent, h, provide, ref, nextTick } 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); + const closing = ref(false); + const showing = ref(false); + let timer = null; + let closeTimer = null; + let progressTimer = null; + const progress = ref(100); + + const dismiss = () => { + window.clearTimeout(closeTimer); + window.clearInterval(progressTimer); + closing.value = true; + showing.value = false; + closeTimer = window.setTimeout(() => { + toast.value = null; + closing.value = false; + progress.value = 100; + window.clearTimeout(timer); + timer = null; + }, 300); + }; + + const close = () => { + window.clearTimeout(closeTimer); + window.clearTimeout(timer); + window.clearInterval(progressTimer); + closing.value = false; + showing.value = false; + progress.value = 100; + toast.value = null; + }; + + const show = options => { + window.clearTimeout(closeTimer); + window.clearInterval(progressTimer); + closing.value = false; + showing.value = false; + progress.value = 100; + const variant = normalizeVariant(options.variant || options.type || "info", "info"); + const lifetime = options.lifetime !== undefined ? options.lifetime : props.lifetime; + 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, + lifetime + }; + + window.clearTimeout(timer); + + if (lifetime !== 0) { + const step = 100; + const totalSteps = lifetime / step; + progress.value = 100; + progressTimer = window.setInterval(() => { + progress.value -= 100 / totalSteps; + if (progress.value <= 0) { + window.clearInterval(progressTimer); + } + }, step); + + timer = window.setTimeout(dismiss, lifetime); + } + + nextTick(() => { + requestAnimationFrame(() => { + showing.value = true; + }); + }); + }; + + 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); + + const toastClass = () => { + if (closing.value) return "a-hide"; + if (showing.value) return "a-show"; + return ""; + }; + + return () => [ + slots.default?.(), + toast.value && h("div", { + class: cx("toast", toastClass(), `toast-${toast.value.variant}`), + role: "alert" + }, [ + h("div", { class: "toast-content" }, [ + h("div", { class: "toast-header" }, [ + iconNode(toast.value.icon), + toast.value.title + ]), + toast.value.text && h("p", { class: "toast-text" }, toast.value.text) + ]), + h("button", { + class: "btn-icon toast-close", + type: "button", + "aria-label": "Close", + onClick: dismiss + }, [iconNode("ph-x")]), + toast.value.lifetime !== 0 && h("div", { class: "toast-progress" }, [ + h("div", { + class: "toast-progress-bar", + style: { transform: `scaleX(${Math.max(0, progress.value / 100)})` } + }) + ]) + ]) + ]; + } +}); diff --git a/src/vue/composables/toast-context.js b/src/vue/composables/toast-context.js new file mode 100644 index 0000000..7f008ce --- /dev/null +++ b/src/vue/composables/toast-context.js @@ -0,0 +1,5 @@ +/** + * Injection key for the toast API provided by GnToastProvider. + */ + +export const toastKey = Symbol("vmk-ui-kit-toast"); diff --git a/src/vue/composables/useToast.js b/src/vue/composables/useToast.js new file mode 100644 index 0000000..d6c4aa9 --- /dev/null +++ b/src/vue/composables/useToast.js @@ -0,0 +1,33 @@ +/** + * useToast composable. + * + * Injects the toast API provided by . + * Returns no-op placeholders with a helpful error when called outside a provider. + */ + +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("VMK 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 index 7b96712..41e2e59 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -15,4 +15,8 @@ export { default as GnSelect } from "./components/GnSelect.js"; export { default as GnTextarea } from "./components/GnTextarea.js"; export { default as GnCard } from "./components/GnCard.js"; +export { default as GnModal } from "./components/GnModal.js"; +export { default as GnDrawer } from "./components/GnDrawer.js"; +export { default as GnToastProvider } from "./components/GnToastProvider.js"; +export { useToast } from "./composables/useToast.js"; export { components, default as VMKUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 571f84a..9452297 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -15,6 +15,9 @@ import GnSelect from "./components/GnSelect.js"; import GnTextarea from "./components/GnTextarea.js"; import GnCard from "./components/GnCard.js"; +import GnModal from "./components/GnModal.js"; +import GnDrawer from "./components/GnDrawer.js"; +import GnToastProvider from "./components/GnToastProvider.js"; export const components = { GnButton, @@ -28,7 +31,10 @@ GnSwitch, GnSelect, GnTextarea, - GnCard + GnCard, + GnModal, + GnDrawer, + GnToastProvider }; export default { diff --git a/src/vue/utils.js b/src/vue/utils.js index d999a42..8444b44 100644 --- a/src/vue/utils.js +++ b/src/vue/utils.js @@ -65,6 +65,40 @@ return target.value; } +/** + * Keep keyboard focus inside a modal/drawer container when Tab is pressed. + */ +export function trapFocus(event, container) { + if (!container || event.key !== "Tab") return; + + const focusable = Array.from( + container.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter(el => el.offsetParent !== null); + + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey) { + if (active === first || !container.contains(active)) { + event.preventDefault(); + last.focus(); + } + } else { + if (active === last || !container.contains(active)) { + event.preventDefault(); + first.focus(); + } + } +} + const PHOSPHOR_TO_VMK = { spinner: "essentials/loading", loader: "essentials/loading",