diff --git a/PROJECT_NOTES.md b/PROJECT_NOTES.md index 1143503..158cae7 100644 --- a/PROJECT_NOTES.md +++ b/PROJECT_NOTES.md @@ -55,6 +55,12 @@ - `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` — демо-примеры. +- **Новая порция компонентов (Tabs, Avatar, Chip, Progress):** + - `src/scss/components/_tabs.scss` + `src/js/components/tabs.js` + `src/vue/components/GnTabs.js`, `GnTab.js`, `GnTabPanel.js` — pill/compact/vertical табы с клавиатурной навигацией. + - `src/scss/components/_avatar.scss` + `src/vue/components/GnAvatar.js` — initials/image/icon аватар c индикатором статуса. + - `src/scss/components/_chips.scss` + `src/vue/components/GnChip.js` — removable/selectable чипсы. + - `src/scss/components/_progress.scss` + `src/js/components/progress.js` + `src/vue/components/GnProgress.js` — линейный прогресс и step-индикаторы. + - `demo/partials/tabs.html`, `avatar.html`, `chips.html`, `progress.html` — демо-примеры. - Проверены команды: - `npm run build` — проходит. - `npm run build:example:vue` / `npm run test:vue-adapter` — проходят. @@ -65,8 +71,9 @@ 1. ~~Уточнить radius-токены~~ — зафиксированы по `radius.tokens.json`. 2. Зафиксировать spacing scale (если в Figma есть отдельная страница — найти; иначе оставить текущую). 3. Реализовать foundation-компоненты demo: Typography, Palette, Utilities. -4. Реализовать базовые компоненты и Vue-адаптеры (Input, Badge, Alert и т.д.). -5. Обновить `docs/component-coverage.md` по мере добавления компонентов. +4. ~~Реализовать базовые компоненты и Vue-адаптеры (Input, Badge, Alert и т.д.).~~ +5. ~~Обновить `docs/component-coverage.md` по мере добавления компонентов.~~ +6. Следующая порция: Table, List, Skeleton, Tooltip, Dropdown, NavigationShell. 6. Создать визуальные Playwright-тесты для сравнения компонентов с PNG-экспортами в `figma design parts/frames/`. ## Заметки по reference diff --git a/demo/index.html b/demo/index.html index 9e40c22..710f01b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -28,6 +28,10 @@ @@include('./partials/modals.html') @@include('./partials/drawer.html') @@include('./partials/toasts.html') + @@include('./partials/tabs.html') + @@include('./partials/avatar.html') + @@include('./partials/chips.html') + @@include('./partials/progress.html') diff --git a/demo/partials/avatar.html b/demo/partials/avatar.html new file mode 100644 index 0000000..dd0e42e --- /dev/null +++ b/demo/partials/avatar.html @@ -0,0 +1,37 @@ +
+

Avatar

+ +

Sizes

+
+ AB + AB + AB + AB + AB +
+ +

Initials

+
+ JD + MK + VL +
+ +

Image

+
+ User avatar +
+ +

Icon avatar

+
+ +
+ +

Status dot

+
+ JD + MK + VL + AW +
+
diff --git a/demo/partials/chips.html b/demo/partials/chips.html new file mode 100644 index 0000000..bdc47dc --- /dev/null +++ b/demo/partials/chips.html @@ -0,0 +1,24 @@ +
+

Chips

+ +

Default chips

+
+ Label + Primary + Secondary + Accent +
+ +

Selectable chips

+
+ + + +
+ +

Small chips

+
+ Small + Tiny +
+
diff --git a/demo/partials/progress.html b/demo/partials/progress.html new file mode 100644 index 0000000..5a515fc --- /dev/null +++ b/demo/partials/progress.html @@ -0,0 +1,32 @@ +
+

Progress

+ +

Linear progress

+
+
+
+ +
+
+
+ +
+
+
+ +

Sizes

+
+
+
+
+
+
+ +

Step indicators

+
+ + 2 + 3 + 4 +
+
diff --git a/demo/partials/tabs.html b/demo/partials/tabs.html new file mode 100644 index 0000000..2a75637 --- /dev/null +++ b/demo/partials/tabs.html @@ -0,0 +1,63 @@ +
+

Tabs

+ +

Pill tabs

+
+
+ + + +
+
+
+

Overview content for pill tabs.

+
+ + +
+
+ +

Compact tabs

+
+
+ + + +
+
+
+

First compact panel.

+
+ + +
+
+ +

Vertical tabs

+
+
+ + + +
+
+
+

Account settings.

+
+ + +
+
+
diff --git a/docs/component-coverage.md b/docs/component-coverage.md index c442098..bcc1157 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -27,12 +27,13 @@ | Modal | done | GnModal | partial | | Drawer | done | GnDrawer | partial | | Toast | done | GnToastProvider, useToast | partial | -| Tabs | — | — | — | +| Tabs | done | GnTabs, GnTab, GnTabPanel | partial | | Table | — | — | — | | List | — | — | — | -| Avatar | — | — | — | -| Chip | — | — | — | -| Progress | — | — | — | +| Avatar | done | GnAvatar | partial | +| Chip | done | GnChip | partial | +| Progress | done | GnProgress | partial | +| Skeleton | — | — | — | | Skeleton | — | — | — | | Tooltip | — | — | — | | Dropdown | — | — | — | diff --git a/src/js/components/progress.js b/src/js/components/progress.js new file mode 100644 index 0000000..71101fe --- /dev/null +++ b/src/js/components/progress.js @@ -0,0 +1,22 @@ +/** + * VMK UI Kit browser progress helper. + * + * Simple utility to update .progress-bar widths. + */ + +function setValue(element, value, max = 100) { + const progress = typeof element === "string" ? document.querySelector(element) : element; + if (!progress) return; + + const bar = progress.querySelector(".progress-bar"); + if (!bar) return; + + const percentage = Math.max(0, Math.min(100, (value / max) * 100)); + bar.style.width = `${percentage}%`; + bar.setAttribute("aria-valuenow", String(value)); + bar.setAttribute("aria-valuemax", String(max)); +} + +export default { + setValue +}; diff --git a/src/js/components/tabs.js b/src/js/components/tabs.js new file mode 100644 index 0000000..071ebba --- /dev/null +++ b/src/js/components/tabs.js @@ -0,0 +1,134 @@ +/** + * VMK UI Kit browser tabs helper. + * + * Mirrors the public surface of gnexus-ui-kit/src/js/components/tabs.js: + * init(root), activate(tab) + */ + +const initializedRoots = new WeakSet(); + +function getTabs(root) { + return [...root.querySelectorAll('[role="tab"], .tab')]; +} + +function getPanels(root) { + return [...root.querySelectorAll('[role="tabpanel"], .tab-panel')]; +} + +function getPanel(root, tab) { + const panelId = tab.getAttribute("aria-controls"); + if (!panelId) return null; + return root.querySelector(`#${CSS.escape(panelId)}`); +} + +function setTabState(tab, isActive) { + tab.classList.toggle("tab-active", isActive); + tab.setAttribute("aria-selected", isActive ? "true" : "false"); + tab.setAttribute("tabindex", isActive ? "0" : "-1"); +} + +function setPanelState(panel, isActive) { + if (!panel) return; + panel.classList.toggle("tab-panel-active", isActive); + panel.toggleAttribute("hidden", !isActive); +} + +function activate(tab, options = {}) { + if (!tab || tab.disabled || tab.getAttribute("aria-disabled") === "true") { + return; + } + + const root = tab.closest(".tabs") || tab.closest('[role="tablist"]')?.parentElement; + if (!root) return; + + getTabs(root).forEach(item => setTabState(item, item === tab)); + getPanels(root).forEach(panel => setPanelState(panel, false)); + + const panel = getPanel(root, tab); + setPanelState(panel, true); + + if (options.focus !== false) { + tab.focus(); + } +} + +function getNextEnabledTab(tabs, activeIndex, direction) { + for (let offset = 1; offset <= tabs.length; offset++) { + const index = (activeIndex + (offset * direction) + tabs.length) % tabs.length; + const tab = tabs[index]; + if (!tab.disabled && tab.getAttribute("aria-disabled") !== "true") { + return tab; + } + } + return tabs[activeIndex]; +} + +function handleKeydown(event) { + const tab = event.target.closest('[role="tab"], .tab'); + if (!tab) return; + + const root = tab.closest(".tabs") || tab.closest('[role="tablist"]')?.parentElement; + const tabs = root ? getTabs(root) : []; + const activeIndex = tabs.indexOf(tab); + if (activeIndex < 0) return; + + let nextTab = null; + + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + nextTab = getNextEnabledTab(tabs, activeIndex, 1); + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + nextTab = getNextEnabledTab(tabs, activeIndex, -1); + } else if (event.key === "Home") { + nextTab = getNextEnabledTab(tabs, -1, 1); + } else if (event.key === "End") { + nextTab = getNextEnabledTab(tabs, 0, -1); + } + + if (!nextTab) return; + + event.preventDefault(); + activate(nextTab); +} + +function prepare(root) { + const tabs = getTabs(root); + const activeTab = tabs.find(tab => tab.classList.contains("tab-active") || tab.getAttribute("aria-selected") === "true") + || tabs.find(tab => !tab.disabled && tab.getAttribute("aria-disabled") !== "true"); + + tabs.forEach(tab => { + tab.setAttribute("role", "tab"); + setTabState(tab, tab === activeTab); + }); + + root.querySelectorAll(".tabs-list").forEach(list => { + list.setAttribute("role", "tablist"); + }); + + getPanels(root).forEach(panel => { + panel.setAttribute("role", "tabpanel"); + const isActive = activeTab ? panel === getPanel(root, activeTab) : panel.classList.contains("tab-panel-active"); + setPanelState(panel, isActive); + }); +} + +function init(root = document) { + if (initializedRoots.has(root)) return; + + root.querySelectorAll(".tabs").forEach(prepare); + + root.addEventListener("click", event => { + const tab = event.target.closest('[role="tab"], .tab'); + if (!tab || !root.contains(tab)) return; + event.preventDefault(); + activate(tab, { focus: false }); + }); + + root.addEventListener("keydown", handleKeydown); + + initializedRoots.add(root); +} + +export default { + init, + activate +}; diff --git a/src/js/index.js b/src/js/index.js index a09c398..2caa306 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -5,10 +5,14 @@ import * as Icons from "./components/icon-helper.js"; import Toasts from "./components/toasts.js"; +import Tabs from "./components/tabs.js"; +import Progress from "./components/progress.js"; const api = { Icons, Toasts, + Tabs, + Progress, Helper: { iconSvg: Icons.iconSvg, iconNode: Icons.iconNode, @@ -24,5 +28,5 @@ }); } -export { Icons, Toasts }; +export { Icons, Toasts, Tabs, Progress }; export default api; diff --git a/src/scss/components/_avatar.scss b/src/scss/components/_avatar.scss new file mode 100644 index 0000000..0775ba5 --- /dev/null +++ b/src/scss/components/_avatar.scss @@ -0,0 +1,101 @@ +/* ========================= + Avatar + Figma: Avatar. +========================= */ + +@use "../kit-deps" as *; + +.avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: $radius-full; + background-color: $mint-100; + color: $mint-700; + font-family: $font-family-base; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 1; + text-transform: uppercase; + overflow: hidden; + user-select: none; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-status { + position: absolute; + top: 0; + right: 0; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid $neutral-0; + background-color: $mint-600; + transform: translate(15%, -15%); + + &.is-offline { background-color: $neutral-400; } + &.is-busy { background-color: $warning-text; } + &.is-away { background-color: $yellow-500; } + &.is-danger { background-color: $danger-text; } + } + + // Sizes + &.avatar-sm { + width: 32px; + height: 32px; + font-size: 12px; + + .avatar-status { + width: 8px; + height: 8px; + border-width: 1px; + } + } + + &.avatar-md { + width: 48px; + height: 48px; + font-size: 16px; + } + + &.avatar-lg { + width: 64px; + height: 64px; + font-size: 20px; + } + + &.avatar-xl { + width: 96px; + height: 96px; + font-size: 32px; + } + + // Variants + &.avatar-neutral { + background-color: $neutral-100; + color: $neutral-700; + } + + &.avatar-mint { + background-color: $mint-100; + color: $mint-700; + } + + &.avatar-yellow { + background-color: $yellow-100; + color: $yellow-800; + } + + &.avatar-lavender { + background-color: $lavender-100; + color: $lavender-700; + } +} diff --git a/src/scss/components/_chips.scss b/src/scss/components/_chips.scss new file mode 100644 index 0000000..4653994 --- /dev/null +++ b/src/scss/components/_chips.scss @@ -0,0 +1,97 @@ +/* ========================= + Chips + Figma: Badge type / filter chips. +========================= */ + +@use "../kit-deps" as *; + +.chip { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $space-1; + min-height: 28px; + padding: $space-1 $space-3; + font-family: $font-family-base; + font-size: 13px; + font-weight: $font-weight-medium; + line-height: 1; + text-transform: none; + white-space: nowrap; + border-radius: $radius-full; + background-color: $neutral-100; + color: $neutral-900; + transition: + background-color $motion-base $motion-ease, + color $motion-base $motion-ease; + + .chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + margin-left: $space-1; + border: 0; + border-radius: 50%; + background: transparent; + color: currentColor; + cursor: pointer; + opacity: 0.7; + transition: opacity $motion-base $motion-ease; + + &:hover { + opacity: 1; + } + + &:focus-visible { + @include focus_ring; + } + } + + // Variants + &.chip-primary { + background-color: $neutral-900; + color: $neutral-25; + } + + &.chip-secondary { + background-color: $mint-300; + color: $neutral-900; + } + + &.chip-accent { + background-color: $yellow-300; + color: $neutral-900; + } + + &.chip-outline { + background-color: transparent; + border: $border-width-base $border-style-base $neutral-300; + color: $neutral-700; + + &:hover { + border-color: $neutral-400; + color: $neutral-900; + } + } + + // Sizes + &.chip-sm { + min-height: 22px; + padding: 2px $space-2; + font-size: 11px; + + .chip-remove { + width: 12px; + height: 12px; + } + } + + &.chip-lg { + min-height: 34px; + padding: $space-1 $space-4; + font-size: 14px; + } +} diff --git a/src/scss/components/_progress.scss b/src/scss/components/_progress.scss new file mode 100644 index 0000000..9b84763 --- /dev/null +++ b/src/scss/components/_progress.scss @@ -0,0 +1,82 @@ +/* ========================= + Progress + Figma: Number Progress Indicator + linear progress bars. +========================= */ + +@use "../kit-deps" as *; + +// Linear progress +.progress { + width: 100%; + height: 8px; + border-radius: $radius-full; + background-color: $neutral-100; + overflow: hidden; + + .progress-bar { + height: 100%; + border-radius: $radius-full; + background-color: $mint-600; + transition: width $motion-base $motion-ease; + } + + // Variants + &.progress-primary .progress-bar { background-color: $neutral-900; } + &.progress-accent .progress-bar { background-color: $yellow-300; } + &.progress-success .progress-bar { background-color: $success-text; } + &.progress-warning .progress-bar { background-color: $warning-text; } + &.progress-danger .progress-bar { background-color: $danger-text; } + + // Sizes + &.progress-sm { height: 4px; } + &.progress-lg { height: 12px; } +} + +// Circular / step indicator +.progress-step { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: $radius-full; + background-color: $neutral-0; + border: $border-width-base $border-style-base $neutral-300; + color: $neutral-500; + font-family: $font-family-base; + font-size: 13px; + font-weight: $font-weight-bold; + line-height: 1; + + &.is-active { + background-color: $mint-600; + border-color: $mint-600; + color: $neutral-0; + } + + &.is-complete { + background-color: $success-text; + border-color: $success-text; + color: $neutral-0; + } + + &.is-accent { + background-color: $yellow-300; + border-color: $yellow-300; + color: $neutral-900; + } + + // Sizes + &.progress-step-sm { + width: 24px; + height: 24px; + font-size: 11px; + } + + &.progress-step-lg { + width: 48px; + height: 48px; + font-size: 18px; + } +} diff --git a/src/scss/components/_tabs.scss b/src/scss/components/_tabs.scss new file mode 100644 index 0000000..766d891 --- /dev/null +++ b/src/scss/components/_tabs.scss @@ -0,0 +1,126 @@ +/* ========================= + Tabs + Figma: Tabs / Navigation. +========================= */ + +@use "../kit-deps" as *; + +.tabs { + width: 100%; + max-width: 900px; + + .tabs-list { + display: flex; + align-items: stretch; + gap: $space-1; + max-width: 100%; + overflow-x: auto; + scrollbar-width: thin; + } + + .tab { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: $space-2; + min-height: $control-height-md; + padding: $space-3 $space-4; + border: 0; + border-radius: $radius-full; + color: $neutral-700; + background: transparent; + font-family: $font-family-base; + font-size: 14px; + font-weight: $font-weight-semibold; + line-height: 1; + text-transform: none; + white-space: nowrap; + cursor: pointer; + transition: + color $motion-base $motion-ease, + background-color $motion-base $motion-ease; + + &:hover:not(:disabled):not([aria-disabled="true"]) { + color: $neutral-900; + background-color: $neutral-100; + } + + &:focus-visible { + @include focus_ring; + z-index: 1; + } + + &:disabled, + &[aria-disabled="true"] { + color: $neutral-500; + cursor: not-allowed; + opacity: 0.62; + + &:hover { + background: transparent; + } + } + } + + .tab-active, + .tab[aria-selected="true"] { + color: $neutral-900; + background-color: $neutral-100; + } + + .tabs-panels { + padding-top: $space-4; + } + + .tab-panel { + display: none; + + &.tab-panel-active { + display: block; + } + } + + // Compact variant + &.tabs-compact { + max-width: 620px; + + .tab { + min-height: $control-height-sm; + padding: $space-2 $space-3; + font-size: 13px; + } + } + + // Vertical layout + &.tabs-vertical { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + align-items: start; + gap: $space-5; + + .tabs-list { + flex-direction: column; + overflow-x: visible; + } + + .tab { + justify-content: flex-start; + text-align: left; + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + + .tabs-list { + flex-direction: row; + overflow-x: auto; + } + + .tab { + justify-content: center; + text-align: center; + } + } + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 206288c..037c9b6 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -14,6 +14,10 @@ @use "components/modals"; @use "components/drawer"; @use "components/toasts"; +@use "components/tabs"; +@use "components/avatar"; +@use "components/chips"; +@use "components/progress"; @use "utils"; * { diff --git a/src/vue/components/GnAvatar.js b/src/vue/components/GnAvatar.js new file mode 100644 index 0000000..aff5e8e --- /dev/null +++ b/src/vue/components/GnAvatar.js @@ -0,0 +1,47 @@ +/** + * GnAvatar - Avatar with initials, image, icon and status indicator. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnAvatar", + props: { + src: { type: String, default: "" }, + alt: { type: String, default: "" }, + initials: { type: String, default: "" }, + icon: { type: String, default: "" }, + size: { type: String, default: "md" }, + variant: { type: String, default: "mint" }, + status: { type: String, default: "" }, + square: { type: Boolean, default: false } + }, + setup(props, { slots }) { + const classes = computed(() => cx("avatar", `avatar-${props.size}`, `avatar-${props.variant}`, { + "avatar-square": props.square + })); + + const content = computed(() => { + if (slots.default) return slots.default(); + if (props.src) return h("img", { src: props.src, alt: props.alt }); + if (props.icon) return iconNode(props.icon); + return props.initials?.slice(0, 2).toUpperCase(); + }); + + const statusClass = () => { + switch (props.status) { + case "offline": return "is-offline"; + case "busy": return "is-busy"; + case "away": return "is-away"; + case "danger": return "is-danger"; + default: return ""; + } + }; + + return () => h("span", { class: classes.value }, [ + content.value, + props.status && h("span", { class: cx("avatar-status", statusClass()) }) + ]); + } +}); diff --git a/src/vue/components/GnChip.js b/src/vue/components/GnChip.js new file mode 100644 index 0000000..98dcc77 --- /dev/null +++ b/src/vue/components/GnChip.js @@ -0,0 +1,39 @@ +/** + * GnChip - Compact removable tag/filter chip. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnChip", + props: { + variant: { type: String, default: "default" }, + size: { type: String, default: "md" }, + icon: { type: String, default: "" }, + removable: { type: Boolean, default: false } + }, + emits: ["remove"], + setup(props, { emit, slots }) { + const classes = computed(() => cx("chip", `chip-${props.variant}`, `chip-${props.size}`)); + + const removeButton = () => { + if (!props.removable) return null; + return h("button", { + class: "chip-remove", + type: "button", + "aria-label": "Remove", + onClick: event => { + event.stopPropagation(); + emit("remove", event); + } + }, [iconNode("ph-x")]); + }; + + return () => h("span", { class: classes.value }, [ + props.icon && iconNode(props.icon), + slots.default?.(), + removeButton() + ]); + } +}); diff --git a/src/vue/components/GnProgress.js b/src/vue/components/GnProgress.js new file mode 100644 index 0000000..e3d45ef --- /dev/null +++ b/src/vue/components/GnProgress.js @@ -0,0 +1,54 @@ +/** + * GnProgress - Linear progress bar and circular step indicator. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnProgress", + props: { + value: { type: Number, default: 0 }, + max: { type: Number, default: 100 }, + variant: { type: String, default: "default" }, + size: { type: String, default: "md" }, + step: { type: Boolean, default: false }, + stepNumber: { type: [String, Number], default: "" }, + state: { type: String, default: "" } + }, + setup(props, { slots }) { + const percentage = computed(() => { + const pct = (props.value / props.max) * 100; + return Math.max(0, Math.min(100, pct)); + }); + + const linearClasses = computed(() => cx("progress", `progress-${props.variant}`, `progress-${props.size}`)); + + const stepClasses = computed(() => cx("progress-step", `progress-step-${props.size}`, { + "is-active": props.state === "active", + "is-complete": props.state === "complete", + "is-accent": props.state === "accent" + })); + + return () => { + if (props.step) { + return h("span", { class: stepClasses.value }, [ + props.state === "complete" ? iconNode("ph-check") : (slots.default?.() || props.stepNumber) + ]); + } + + return h("div", { + class: linearClasses.value, + role: "progressbar", + "aria-valuenow": props.value, + "aria-valuemin": 0, + "aria-valuemax": props.max + }, [ + h("div", { + class: "progress-bar", + style: { width: `${percentage.value}%` } + }) + ]); + }; + } +}); diff --git a/src/vue/components/GnTab.js b/src/vue/components/GnTab.js new file mode 100644 index 0000000..17482ec --- /dev/null +++ b/src/vue/components/GnTab.js @@ -0,0 +1,42 @@ +/** + * GnTab - Individual tab button. Must be used inside GnTabs. + */ + +import { defineComponent, h, inject, onMounted } from "vue"; +import { cx, iconNode } from "../utils.js"; +import { tabsKey } from "./GnTabs.js"; + +export default defineComponent({ + name: "GnTab", + props: { + name: { type: [String, Number], required: true }, + label: { type: String, default: "" }, + icon: { type: String, default: "" }, + disabled: { type: Boolean, default: false } + }, + setup(props, { slots }) { + const tabs = inject(tabsKey, null); + + onMounted(() => { + tabs?.registerTab(props.name, props.label); + }); + + return () => { + if (!tabs) return null; + const active = tabs.isActive(props.name); + + return h("button", { + class: cx("tab", { "tab-active": active }), + type: "button", + role: "tab", + "aria-selected": active ? "true" : "false", + tabindex: active ? "0" : "-1", + disabled: props.disabled, + onClick: () => tabs.select(props.name) + }, [ + props.icon && iconNode(props.icon), + slots.default?.() || props.label + ]); + }; + } +}); diff --git a/src/vue/components/GnTabPanel.js b/src/vue/components/GnTabPanel.js new file mode 100644 index 0000000..a95246a --- /dev/null +++ b/src/vue/components/GnTabPanel.js @@ -0,0 +1,31 @@ +/** + * GnTabPanel - Panel container for a tab. Must be used inside GnTabs slots.panels. + */ + +import { defineComponent, h, inject, onMounted } from "vue"; +import { tabsKey } from "./GnTabs.js"; + +export default defineComponent({ + name: "GnTabPanel", + props: { + name: { type: [String, Number], required: true } + }, + setup(props, { slots }) { + const tabs = inject(tabsKey, null); + + onMounted(() => { + tabs?.registerPanel(props.name); + }); + + return () => { + if (!tabs) return null; + const active = tabs.isActive(props.name); + + return h("div", { + class: ["tab-panel", { "tab-panel-active": active }], + role: "tabpanel", + hidden: !active + }, slots.default?.()); + }; + } +}); diff --git a/src/vue/components/GnTabs.js b/src/vue/components/GnTabs.js new file mode 100644 index 0000000..5ede037 --- /dev/null +++ b/src/vue/components/GnTabs.js @@ -0,0 +1,97 @@ +/** + * GnTabs - Accessible tab interface for Vue. + * + * @slots default - GnTab components + * @slots panels - GnTabPanel components (optional) + * @emits change + */ + +import { defineComponent, h, provide, ref, computed, onMounted, watch } from "vue"; +import { cx, normalizeVariant } from "../utils.js"; + +export const tabsKey = Symbol("vmk-ui-kit-tabs"); + +export default defineComponent({ + name: "GnTabs", + props: { + modelValue: { type: [String, Number], default: null }, + variant: { type: String, default: "default" }, + vertical: { type: Boolean, default: false } + }, + emits: ["update:modelValue", "change"], + setup(props, { emit, slots }) { + const activeKey = ref(props.modelValue); + const tablistRef = ref(null); + let tabItems = []; + let panelItems = []; + + const registerTab = (key, label) => { + tabItems.push({ key, label }); + }; + + const registerPanel = key => { + panelItems.push({ key }); + }; + + const select = key => { + activeKey.value = key; + emit("update:modelValue", key); + emit("change", key); + }; + + const isActive = key => activeKey.value === key; + + const classes = computed(() => cx("tabs", { + "tabs-compact": props.variant === "compact", + "tabs-vertical": props.vertical + })); + + const focusTab = direction => { + const enabled = tabItems.filter((_, i) => { + const el = tablistRef.value?.children[i]; + return el && !el.disabled && el.getAttribute("aria-disabled") !== "true"; + }); + const current = enabled.findIndex(item => item.key === activeKey.value); + const nextIndex = (current + direction + enabled.length) % enabled.length; + const next = enabled[nextIndex]; + if (next) select(next.key); + }; + + watch(() => props.modelValue, value => { + activeKey.value = value; + }); + + provide(tabsKey, { + activeKey, + registerTab, + registerPanel, + select, + isActive + }); + + return () => h("div", { class: classes.value }, [ + h("div", { + ref: tablistRef, + class: "tabs-list", + role: "tablist", + ariaOrientation: props.vertical ? "vertical" : "horizontal", + onKeydown: event => { + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + event.preventDefault(); + focusTab(1); + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + event.preventDefault(); + focusTab(-1); + } else if (event.key === "Home") { + event.preventDefault(); + focusTab(1 - tabItems.length); + } else if (event.key === "End") { + event.preventDefault(); + focusTab(-1); + } + } + }, slots.default?.()), + slots.panels && h("div", { class: "tabs-panels" }, slots.panels()) + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 41e2e59..64a3d57 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -18,5 +18,11 @@ 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 { default as GnTabs } from "./components/GnTabs.js"; +export { default as GnTab } from "./components/GnTab.js"; +export { default as GnTabPanel } from "./components/GnTabPanel.js"; +export { default as GnAvatar } from "./components/GnAvatar.js"; +export { default as GnChip } from "./components/GnChip.js"; +export { default as GnProgress } from "./components/GnProgress.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 9452297..4e00f86 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -18,6 +18,12 @@ import GnModal from "./components/GnModal.js"; import GnDrawer from "./components/GnDrawer.js"; import GnToastProvider from "./components/GnToastProvider.js"; +import GnTabs from "./components/GnTabs.js"; +import GnTab from "./components/GnTab.js"; +import GnTabPanel from "./components/GnTabPanel.js"; +import GnAvatar from "./components/GnAvatar.js"; +import GnChip from "./components/GnChip.js"; +import GnProgress from "./components/GnProgress.js"; export const components = { GnButton, @@ -34,7 +40,13 @@ GnCard, GnModal, GnDrawer, - GnToastProvider + GnToastProvider, + GnTabs, + GnTab, + GnTabPanel, + GnAvatar, + GnChip, + GnProgress }; export default {