diff --git a/PROJECT_NOTES.md b/PROJECT_NOTES.md index 158cae7..602fa5d 100644 --- a/PROJECT_NOTES.md +++ b/PROJECT_NOTES.md @@ -61,6 +61,15 @@ - `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` — демо-примеры. +- **Порция компонентов (Table, List, Skeleton, Tooltip, Dropdown, NavigationShell):** + - `src/scss/components/_tables.scss` + `src/js/components/tables.js` + `src/vue/components/GnTable.js`, `GnTableRow.js`, `GnTableHeader.js` — таблицы с сортировкой/выделением строк. + - `src/scss/components/_lists.scss` + `src/js/components/lists.js` + `src/vue/components/GnList.js`, `GnListItem.js` — списки с иконками и мета-текстом. + - `src/scss/components/_skeleton.scss` + `src/js/components/skeleton.js` + `src/vue/components/GnSkeleton.js` — skeleton placeholders. + - `src/scss/components/_tooltip.scss` + `src/js/components/tooltips.js` + `src/vue/components/GnTooltip.js` — тултипы 4 направления. + - `src/scss/components/_dropdown.scss` + `src/js/components/dropdowns.js` + `src/vue/components/GnDropdown.js`, `GnDropdownItem.js` — dropdown меню. + - `src/scss/components/_navigation-shell.scss` + `src/js/components/navigation-shell.js` + `src/vue/components/GnNavigationShell.js`, `GnNavigationItem.js` — layout shell с сайдбаром. + - Все компоненты подключены в `kit.scss`, Vue plugin, Vue entry и browser bundle. + - Добавлены demo partials: `tables.html`, `lists.html`, `skeleton.html`, `tooltips.html`, `dropdowns.html`, `navigation-shell.html`. - Проверены команды: - `npm run build` — проходит. - `npm run build:example:vue` / `npm run test:vue-adapter` — проходят. @@ -73,7 +82,8 @@ 3. Реализовать foundation-компоненты demo: Typography, Palette, Utilities. 4. ~~Реализовать базовые компоненты и Vue-адаптеры (Input, Badge, Alert и т.д.).~~ 5. ~~Обновить `docs/component-coverage.md` по мере добавления компонентов.~~ -6. Следующая порция: Table, List, Skeleton, Tooltip, Dropdown, NavigationShell. +6. ~~Следующая порция: Table, List, Skeleton, Tooltip, Dropdown, NavigationShell.~~ +7. Компонентный набор из первоначального плана покрыт. Дальнейшая работа: полировка токенов по PNG-экспортам Figma, доработка Vue demo example, визуальные Playwright-тесты. 6. Создать визуальные Playwright-тесты для сравнения компонентов с PNG-экспортами в `figma design parts/frames/`. ## Заметки по reference diff --git a/demo/index.html b/demo/index.html index 710f01b..163986b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -32,6 +32,12 @@ @@include('./partials/avatar.html') @@include('./partials/chips.html') @@include('./partials/progress.html') + @@include('./partials/tables.html') + @@include('./partials/lists.html') + @@include('./partials/skeleton.html') + @@include('./partials/tooltips.html') + @@include('./partials/dropdowns.html') + @@include('./partials/navigation-shell.html') diff --git a/demo/partials/dropdowns.html b/demo/partials/dropdowns.html new file mode 100644 index 0000000..d0376e0 --- /dev/null +++ b/demo/partials/dropdowns.html @@ -0,0 +1,22 @@ +
+ + + +
diff --git a/demo/partials/lists.html b/demo/partials/lists.html new file mode 100644 index 0000000..ef40328 --- /dev/null +++ b/demo/partials/lists.html @@ -0,0 +1,27 @@ +
+

Lists

+ +

Default list

+ + +

Compact divided list

+ +
diff --git a/demo/partials/navigation-shell.html b/demo/partials/navigation-shell.html new file mode 100644 index 0000000..5e7d92b --- /dev/null +++ b/demo/partials/navigation-shell.html @@ -0,0 +1,35 @@ +
+ + + +
diff --git a/demo/partials/skeleton.html b/demo/partials/skeleton.html new file mode 100644 index 0000000..337bc3e --- /dev/null +++ b/demo/partials/skeleton.html @@ -0,0 +1,18 @@ +
+

Skeleton

+ +

Text placeholders

+
+
+
+
+
+
+ +

Shape placeholders

+
+ + + +
+
diff --git a/demo/partials/tables.html b/demo/partials/tables.html new file mode 100644 index 0000000..50f8044 --- /dev/null +++ b/demo/partials/tables.html @@ -0,0 +1,61 @@ +
+

Tables

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameRoleStatusScore
AliceAdminActive94
BobEditorPending72
CarolViewerOffline88
+
+ +

Compact striped table

+
+ + + + + + + + + + + + + + + + + + + + +
IDItemQty
#101Widget12
#102Gadget5
+
+
diff --git a/demo/partials/tooltips.html b/demo/partials/tooltips.html new file mode 100644 index 0000000..5f184d5 --- /dev/null +++ b/demo/partials/tooltips.html @@ -0,0 +1,25 @@ +
+

Tooltips

+ +
+ + + Tooltip on top + + + + + Tooltip on bottom + + + + + Tooltip on left + + + + + Tooltip on right + +
+
diff --git a/docs/component-coverage.md b/docs/component-coverage.md index bcc1157..506e332 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -28,15 +28,14 @@ | Drawer | done | GnDrawer | partial | | Toast | done | GnToastProvider, useToast | partial | | Tabs | done | GnTabs, GnTab, GnTabPanel | partial | -| Table | — | — | — | -| List | — | — | — | +| Table | done | GnTable, GnTableRow, GnTableHeader | partial | +| List | done | GnList, GnListItem | partial | | Avatar | done | GnAvatar | partial | | Chip | done | GnChip | partial | | Progress | done | GnProgress | partial | -| Skeleton | — | — | — | -| Skeleton | — | — | — | -| Tooltip | — | — | — | -| Dropdown | — | — | — | -| NavigationShell | — | — | — | +| Skeleton | done | GnSkeleton | partial | +| Tooltip | done | GnTooltip | partial | +| Dropdown | done | GnDropdown, GnDropdownItem | partial | +| NavigationShell | done | GnNavigationShell, GnNavigationItem | partial | Статусы: `—` не начато, `WIP` в работе, `done` готово, `redesigned` перерисован в новом стиле. diff --git a/src/js/components/dropdowns.js b/src/js/components/dropdowns.js new file mode 100644 index 0000000..a08764a --- /dev/null +++ b/src/js/components/dropdowns.js @@ -0,0 +1,65 @@ +/** + * VMK UI Kit browser dropdown helper. + * + * Mirrors gnexus-ui-kit dropdown helpers: + * init(root), toggle(trigger), open(trigger), close(trigger) + */ + +const initializedRoots = new WeakSet(); + +function findMenu(trigger) { + const dropdown = trigger.closest(".dropdown"); + return dropdown?.querySelector(".dropdown-menu"); +} + +function toggle(trigger) { + const menu = findMenu(trigger); + if (!menu) return; + menu.classList.toggle("is-open"); +} + +function open(trigger) { + findMenu(trigger)?.classList.add("is-open"); +} + +function close(trigger) { + findMenu(trigger)?.classList.remove("is-open"); +} + +function closeAll(root = document) { + root.querySelectorAll(".dropdown-menu.is-open").forEach(menu => menu.classList.remove("is-open")); +} + +function handleClick(event) { + const trigger = event.target.closest("[data-dropdown-trigger]"); + if (trigger) { + event.stopPropagation(); + toggle(trigger); + return; + } + + if (!event.target.closest(".dropdown-menu")) { + closeAll(document); + } +} + +function handleKeydown(event) { + if (event.key === "Escape") { + closeAll(document); + } +} + +function init(root = document) { + if (initializedRoots.has(root)) return; + + root.addEventListener("click", handleClick); + root.addEventListener("keydown", handleKeydown); + initializedRoots.add(root); +} + +export default { + init, + toggle, + open, + close +}; diff --git a/src/js/components/lists.js b/src/js/components/lists.js new file mode 100644 index 0000000..e0d4ff8 --- /dev/null +++ b/src/js/components/lists.js @@ -0,0 +1,42 @@ +/** + * VMK UI Kit browser list helper. + * + * Mirrors gnexus-ui-kit list helpers: + * init(root), activate(item) + */ + +const initializedRoots = new WeakSet(); + +function getItems(root) { + return [...root.querySelectorAll(".list-item")]; +} + +function activate(item, options = {}) { + if (!item || item.classList.contains("is-disabled")) return; + + const list = item.closest(".list"); + if (!list) return; + + getItems(list).forEach(i => i.classList.toggle("is-active", i === item)); + + if (options.focus !== false) { + item.focus?.(); + } +} + +function handleClick(event) { + const item = event.target.closest(".list-item"); + if (!item) return; + activate(item, { focus: false }); +} + +function init(root = document) { + if (initializedRoots.has(root)) return; + root.addEventListener("click", handleClick); + initializedRoots.add(root); +} + +export default { + init, + activate +}; diff --git a/src/js/components/navigation-shell.js b/src/js/components/navigation-shell.js new file mode 100644 index 0000000..6b0a88c --- /dev/null +++ b/src/js/components/navigation-shell.js @@ -0,0 +1,58 @@ +/** + * VMK UI Kit browser navigation shell helper. + * + * Mirrors gnexus-ui-kit NavigationShell helpers: + * init(root), toggle(root), open(root), close(root) + */ + +const initializedRoots = new WeakSet(); + +function getSidebar(root) { + return root.querySelector(".navigation-sidebar"); +} + +function getBackdrop(root) { + return root.querySelector(".navigation-backdrop"); +} + +function toggle(root = document) { + getSidebar(root)?.classList.toggle("is-open"); + getBackdrop(root)?.classList.toggle("is-visible"); +} + +function open(root = document) { + getSidebar(root)?.classList.add("is-open"); + getBackdrop(root)?.classList.add("is-visible"); +} + +function close(root = document) { + getSidebar(root)?.classList.remove("is-open"); + getBackdrop(root)?.classList.remove("is-visible"); +} + +function handleClick(event) { + const toggleBtn = event.target.closest("[data-navigation-toggle]"); + if (toggleBtn) { + const root = toggleBtn.closest(".navigation-shell") || document; + toggle(root); + return; + } + + if (event.target.closest(".navigation-backdrop")) { + const root = event.target.closest(".navigation-shell") || document; + close(root); + } +} + +function init(root = document) { + if (initializedRoots.has(root)) return; + root.addEventListener("click", handleClick); + initializedRoots.add(root); +} + +export default { + init, + toggle, + open, + close +}; diff --git a/src/js/components/skeleton.js b/src/js/components/skeleton.js new file mode 100644 index 0000000..6c43890 --- /dev/null +++ b/src/js/components/skeleton.js @@ -0,0 +1,27 @@ +/** + * VMK UI Kit browser skeleton helper. + * + * Simple utility to toggle skeleton placeholders. + */ + +function show(root = document) { + root.querySelectorAll(".skeleton").forEach(el => el.classList.remove("is-hidden")); +} + +function hide(root = document) { + root.querySelectorAll(".skeleton").forEach(el => el.classList.add("is-hidden")); +} + +function create(options = {}) { + const el = document.createElement("span"); + el.className = ["skeleton", options.type || "text", options.className].filter(Boolean).join(" "); + if (options.width) el.style.width = options.width; + if (options.height) el.style.height = options.height; + return el; +} + +export default { + show, + hide, + create +}; diff --git a/src/js/components/tables.js b/src/js/components/tables.js new file mode 100644 index 0000000..67c40e4 --- /dev/null +++ b/src/js/components/tables.js @@ -0,0 +1,95 @@ +/** + * VMK UI Kit browser table helper. + * + * Mirrors the public surface of gnexus-ui-kit tables: + * init(root), selectRow(row), sortBy(column) + */ + +const initializedRoots = new WeakSet(); + +function getRows(table) { + return [...table.querySelectorAll("tbody tr")]; +} + +function getHeaders(table) { + return [...table.querySelectorAll("thead th")]; +} + +function selectRow(row, options = {}) { + const table = row?.closest("table"); + if (!table) return; + + if (table.classList.contains("table-selectable")) { + getRows(table).forEach(r => r.classList.toggle("is-selected", r === row)); + } + + if (options.focus !== false) { + row.focus?.(); + } +} + +function parseValue(cell) { + const text = cell.textContent.trim(); + const num = Number(text.replace(/[^0-9.\-]/g, "")); + return Number.isNaN(num) ? text.toLowerCase() : num; +} + +function sortBy(table, columnIndex, direction = "asc") { + const rows = getRows(table); + const sorted = rows.slice().sort((a, b) => { + const aValue = parseValue(a.children[columnIndex] || a.children[0]); + const bValue = parseValue(b.children[columnIndex] || b.children[0]); + if (aValue < bValue) return direction === "asc" ? -1 : 1; + if (aValue > bValue) return direction === "asc" ? 1 : -1; + return 0; + }); + + const tbody = table.querySelector("tbody"); + sorted.forEach(row => tbody.appendChild(row)); + + getHeaders(table).forEach((header, index) => { + header.setAttribute("aria-sort", index === columnIndex ? direction : "none"); + }); +} + +function handleClick(event) { + const header = event.target.closest("thead th[data-sortable]"); + if (header) { + const table = header.closest("table"); + const index = getHeaders(table).indexOf(header); + const current = header.getAttribute("aria-sort") || "none"; + const direction = current === "asc" ? "desc" : "asc"; + sortBy(table, index, direction); + return; + } + + const row = event.target.closest("tbody tr"); + if (row) { + selectRow(row, { focus: false }); + } +} + +function prepare(table) { + if (table.classList.contains("table-sortable")) { + getHeaders(table).forEach((header, index) => { + if (header.hasAttribute("data-sortable")) return; + header.setAttribute("data-sortable", ""); + header.setAttribute("aria-sort", "none"); + header.style.cursor = "pointer"; + }); + } +} + +function init(root = document) { + if (initializedRoots.has(root)) return; + + root.querySelectorAll("table.table").forEach(prepare); + root.addEventListener("click", handleClick); + initializedRoots.add(root); +} + +export default { + init, + selectRow, + sortBy +}; diff --git a/src/js/components/tooltips.js b/src/js/components/tooltips.js new file mode 100644 index 0000000..5ffe3b6 --- /dev/null +++ b/src/js/components/tooltips.js @@ -0,0 +1,54 @@ +/** + * VMK UI Kit browser tooltip helper. + * + * Mirrors gnexus-ui-kit tooltip helpers: + * init(root), show(trigger), hide(trigger) + */ + +const initializedRoots = new WeakSet(); + +function show(trigger) { + trigger?.closest(".tooltip")?.classList.add("is-visible"); +} + +function hide(trigger) { + trigger?.closest(".tooltip")?.classList.remove("is-visible"); +} + +function handleMouseEnter(event) { + show(event.target.closest(".tooltip-trigger")); +} + +function handleMouseLeave(event) { + hide(event.target.closest(".tooltip-trigger")); +} + +function handleFocus(event) { + show(event.target.closest(".tooltip-trigger")); +} + +function handleBlur(event) { + hide(event.target.closest(".tooltip-trigger")); +} + +function init(root = document) { + if (initializedRoots.has(root)) return; + + root.querySelectorAll(".tooltip").forEach(wrapper => { + const trigger = wrapper.querySelector(".tooltip-trigger"); + if (!trigger) return; + + trigger.addEventListener("mouseenter", handleMouseEnter); + trigger.addEventListener("mouseleave", handleMouseLeave); + trigger.addEventListener("focus", handleFocus); + trigger.addEventListener("blur", handleBlur); + }); + + initializedRoots.add(root); +} + +export default { + init, + show, + hide +}; diff --git a/src/js/index.js b/src/js/index.js index 2caa306..b5bf923 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -7,12 +7,24 @@ import Toasts from "./components/toasts.js"; import Tabs from "./components/tabs.js"; import Progress from "./components/progress.js"; +import Tables from "./components/tables.js"; +import Lists from "./components/lists.js"; +import Skeleton from "./components/skeleton.js"; +import Tooltips from "./components/tooltips.js"; +import Dropdowns from "./components/dropdowns.js"; +import NavigationShell from "./components/navigation-shell.js"; const api = { Icons, Toasts, Tabs, Progress, + Tables, + Lists, + Skeleton, + Tooltips, + Dropdowns, + NavigationShell, Helper: { iconSvg: Icons.iconSvg, iconNode: Icons.iconNode, @@ -28,5 +40,5 @@ }); } -export { Icons, Toasts, Tabs, Progress }; +export { Icons, Toasts, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell }; export default api; diff --git a/src/scss/components/_dropdown.scss b/src/scss/components/_dropdown.scss new file mode 100644 index 0000000..97be422 --- /dev/null +++ b/src/scss/components/_dropdown.scss @@ -0,0 +1,101 @@ +/* ========================= + Dropdown + Figma: Dropdown / Select menu. +========================= */ + +@use "../kit-deps" as *; + +.dropdown { + position: relative; + display: inline-flex; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + min-width: 200px; + max-width: 320px; + margin-top: $space-1; + padding: $space-2 0; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-md; + background-color: $surface-panel-muted; + box-shadow: 0 8px 24px rgba($neutral-900, 0.12); + opacity: 0; + visibility: hidden; + transform: translateY(4px); + transition: + opacity $motion-fast $motion-ease, + visibility $motion-fast $motion-ease, + transform $motion-fast $motion-ease; + + &.is-open { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + + &[data-placement="right"] { + left: auto; + right: 0; + } + + &[data-placement="top"] { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: $space-1; + } +} + +.dropdown-item { + display: flex; + align-items: center; + gap: $space-3; + width: 100%; + padding: $space-3 $space-4; + border: 0; + background: transparent; + color: $neutral-900; + font-size: 14px; + text-align: left; + cursor: pointer; + transition: background-color $motion-base $motion-ease; + + &:hover:not(:disabled):not(.is-disabled) { + background-color: rgba($neutral-900, 0.04); + } + + &:focus-visible { + @include focus_ring; + outline-offset: -2px; + } + + &:disabled, + &.is-disabled { + color: $neutral-400; + cursor: not-allowed; + } + + &.is-active { + background-color: rgba($mint-500, 0.12); + } + + .dropdown-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: $neutral-600; + flex-shrink: 0; + } +} + +.dropdown-divider { + height: 1px; + margin: $space-2 $space-4; + background-color: $border-color-muted; +} diff --git a/src/scss/components/_lists.scss b/src/scss/components/_lists.scss new file mode 100644 index 0000000..39772ec --- /dev/null +++ b/src/scss/components/_lists.scss @@ -0,0 +1,91 @@ +/* ========================= + Lists + Figma: Lists / Menu items. +========================= */ + +@use "../kit-deps" as *; + +.list { + margin: 0; + padding: $space-2 0; + list-style: none; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-md; + background-color: $surface-panel-muted; + min-width: 240px; + max-width: 100%; +} + +.list-item { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-3 $space-4; + color: $neutral-900; + font-size: 14px; + line-height: 1.4; + cursor: pointer; + transition: background-color $motion-base $motion-ease; + + &:hover:not(.is-disabled) { + background-color: rgba($neutral-900, 0.04); + } + + &:focus-visible { + @include focus_ring; + outline-offset: -2px; + } + + &.is-active { + background-color: rgba($mint-500, 0.12); + color: $neutral-900; + } + + &.is-disabled { + color: $neutral-400; + cursor: not-allowed; + } + + .list-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: $neutral-600; + flex-shrink: 0; + } + + .list-item-meta { + margin-left: auto; + color: $neutral-500; + font-size: 12px; + font-weight: $font-weight-semibold; + } +} + +.list-compact { + padding: $space-1 0; + + .list-item { + padding: $space-2 $space-3; + font-size: 13px; + } +} + +.list-flush { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + + .list-item { + border-radius: $radius-md; + } +} + +.list-divided { + .list-item + .list-item { + border-top: $border-width-base $border-style-base $border-color-muted; + } +} diff --git a/src/scss/components/_navigation-shell.scss b/src/scss/components/_navigation-shell.scss new file mode 100644 index 0000000..baa3daa --- /dev/null +++ b/src/scss/components/_navigation-shell.scss @@ -0,0 +1,146 @@ +/* ========================= + NavigationShell + Figma: Navigation / Sidebar. +========================= */ + +@use "../kit-deps" as *; + +.navigation-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + grid-template-rows: auto 1fr; + grid-template-areas: + "sidebar topbar" + "sidebar content"; + min-height: 100vh; + background-color: $surface-page; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + grid-template-areas: + "topbar" + "content"; + + .navigation-sidebar { + position: fixed; + top: 0; + left: 0; + z-index: 1100; + width: 260px; + height: 100vh; + transform: translateX(-100%); + transition: transform $motion-base $motion-ease; + + &.is-open { + transform: translateX(0); + } + } + + .navigation-backdrop { + position: fixed; + inset: 0; + z-index: 1050; + background-color: rgba($neutral-900, 0.4); + opacity: 0; + visibility: hidden; + transition: opacity $motion-base $motion-ease; + + &.is-visible { + opacity: 1; + visibility: visible; + } + } + } +} + +.navigation-topbar { + grid-area: topbar; + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-4; + padding: $space-3 $space-5; + border-bottom: $border-width-base $border-style-base $border-color-muted; + background-color: $surface-panel-muted; +} + +.navigation-sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + gap: $space-4; + padding: $space-4; + border-right: $border-width-base $border-style-base $border-color-muted; + background-color: $surface-panel-muted; + overflow-y: auto; +} + +.navigation-brand { + display: flex; + align-items: center; + gap: $space-3; + font-weight: $font-weight-bold; + font-size: 18px; + color: $neutral-900; +} + +.navigation-content { + grid-area: content; + padding: $space-5; + overflow-y: auto; +} + +.navigation-menu { + display: flex; + flex-direction: column; + gap: $space-1; + list-style: none; + margin: 0; + padding: 0; +} + +.navigation-item { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-3 $space-4; + border-radius: $radius-md; + color: $neutral-700; + font-size: 14px; + font-weight: $font-weight-semibold; + text-decoration: none; + cursor: pointer; + transition: background-color $motion-base $motion-ease; + + &:hover:not(.is-active) { + background-color: rgba($neutral-900, 0.04); + color: $neutral-900; + } + + &:focus-visible { + @include focus_ring; + outline-offset: -2px; + } + + &.is-active { + background-color: $neutral-900; + color: $neutral-0; + } + + .navigation-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + } +} + +.navigation-toggle { + display: none; + + @media (max-width: 1024px) { + display: inline-flex; + } +} diff --git a/src/scss/components/_skeleton.scss b/src/scss/components/_skeleton.scss new file mode 100644 index 0000000..fa14c01 --- /dev/null +++ b/src/scss/components/_skeleton.scss @@ -0,0 +1,74 @@ +/* ========================= + Skeleton + Figma: Skeleton loaders. +========================= */ + +@use "../kit-deps" as *; + +@keyframes vmk-skeleton-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.skeleton { + display: inline-block; + border-radius: $radius-sm; + background: linear-gradient( + 90deg, + $neutral-100 0%, + $neutral-200 50%, + $neutral-100 100% + ); + background-size: 200% 100%; + animation: vmk-skeleton-shimmer 1.6s ease-in-out infinite; +} + +.skeleton-text { + height: 1em; + margin-bottom: $space-2; + border-radius: $radius-full; + + &:last-child { + margin-bottom: 0; + } +} + +.skeleton-title { + height: 1.25em; + width: 60%; + margin-bottom: $space-3; + border-radius: $radius-full; +} + +.skeleton-circle { + border-radius: 50%; + width: 48px; + height: 48px; +} + +.skeleton-rect { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: $radius-md; +} + +.skeleton-pill { + width: 80px; + height: 28px; + border-radius: $radius-full; +} + +.skeleton-xs { width: 24px; height: 24px; } +.skeleton-sm { width: 32px; height: 32px; } +.skeleton-md { width: 48px; height: 48px; } +.skeleton-lg { width: 64px; height: 64px; } +.skeleton-xl { width: 96px; height: 96px; } + +.skeleton-block { + display: block; + width: 100%; +} diff --git a/src/scss/components/_tables.scss b/src/scss/components/_tables.scss new file mode 100644 index 0000000..012d03f --- /dev/null +++ b/src/scss/components/_tables.scss @@ -0,0 +1,97 @@ +/* ========================= + Tables + Figma: Tables / Data grid. +========================= */ + +@use "../kit-deps" as *; + +.table-container { + width: 100%; + overflow-x: auto; + border: $border-width-base $border-style-base $border-color-muted; + border-radius: $radius-md; + background-color: $surface-panel-muted; +} + +.table { + width: 100%; + min-width: 520px; + border-collapse: collapse; + font-size: 14px; + line-height: 1.4; + + caption { + padding: $space-3 $space-4; + text-align: left; + font-weight: $font-weight-semibold; + color: $neutral-700; + } + + th, + td { + padding: $space-3 $space-4; + text-align: left; + vertical-align: middle; + border-bottom: $border-width-base $border-style-base $border-color-muted; + } + + thead th { + background-color: $neutral-100; + color: $neutral-700; + font-weight: $font-weight-semibold; + font-size: 13px; + text-transform: none; + letter-spacing: 0; + border-bottom: $border-width-base $border-style-base $neutral-200; + } + + tbody tr { + transition: background-color $motion-base $motion-ease; + + &:hover { + background-color: rgba($neutral-900, 0.03); + } + + &:last-child td { + border-bottom: 0; + } + } + + tfoot td { + background-color: $neutral-100; + font-weight: $font-weight-semibold; + border-top: $border-width-base $border-style-base $neutral-200; + } +} + +.table-compact { + th, + td { + padding: $space-2 $space-3; + font-size: 13px; + } +} + +.table-striped { + tbody tr:nth-child(even) { + background-color: rgba($neutral-900, 0.015); + + &:hover { + background-color: rgba($neutral-900, 0.04); + } + } +} + +.table-selectable { + tbody tr { + cursor: pointer; + + &.is-selected { + background-color: rgba($mint-500, 0.12); + + &:hover { + background-color: rgba($mint-500, 0.18); + } + } + } +} diff --git a/src/scss/components/_tooltip.scss b/src/scss/components/_tooltip.scss new file mode 100644 index 0000000..3b300d6 --- /dev/null +++ b/src/scss/components/_tooltip.scss @@ -0,0 +1,125 @@ +/* ========================= + Tooltip + Figma: Tooltips / Hint popovers. +========================= */ + +@use "../kit-deps" as *; + +.tooltip { + position: relative; + display: inline-flex; + + .tooltip-trigger { + display: inline-flex; + } + + .tooltip-content { + position: absolute; + z-index: 1000; + max-width: 260px; + padding: $space-2 $space-3; + border-radius: $radius-md; + background-color: $neutral-900; + color: $neutral-0; + font-size: 13px; + line-height: 1.4; + white-space: normal; + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: translateY(4px); + transition: + opacity $motion-fast $motion-ease, + visibility $motion-fast $motion-ease, + transform $motion-fast $motion-ease; + + &::before { + content: ""; + position: absolute; + width: 8px; + height: 8px; + background-color: $neutral-900; + transform: rotate(45deg); + } + } + + &[data-placement="top"], + &:not([data-placement]) { + .tooltip-content { + bottom: 100%; + left: 50%; + margin-bottom: $space-2; + transform: translateX(-50%) translateY(4px); + + &::before { + top: calc(100% - 4px); + left: 50%; + margin-left: -4px; + } + } + } + + &[data-placement="bottom"] { + .tooltip-content { + top: 100%; + left: 50%; + margin-top: $space-2; + transform: translateX(-50%) translateY(-4px); + + &::before { + bottom: calc(100% - 4px); + left: 50%; + margin-left: -4px; + } + } + } + + &[data-placement="left"] { + .tooltip-content { + top: 50%; + right: 100%; + margin-right: $space-2; + transform: translateY(-50%) translateX(4px); + + &::before { + top: 50%; + right: -4px; + margin-top: -4px; + } + } + } + + &[data-placement="right"] { + .tooltip-content { + top: 50%; + left: 100%; + margin-left: $space-2; + transform: translateY(-50%) translateX(-4px); + + &::before { + top: 50%; + left: -4px; + margin-top: -4px; + } + } + } + + &.is-visible .tooltip-content, + &:hover .tooltip-content, + .tooltip-trigger:focus + .tooltip-content, + .tooltip-trigger:focus-within + .tooltip-content { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + } + + &[data-placement="left"].is-visible .tooltip-content, + &[data-placement="left"]:hover .tooltip-content { + transform: translateY(-50%) translateX(0); + } + + &[data-placement="right"].is-visible .tooltip-content, + &[data-placement="right"]:hover .tooltip-content { + transform: translateY(-50%) translateX(0); + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 037c9b6..9b1a703 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -18,6 +18,12 @@ @use "components/avatar"; @use "components/chips"; @use "components/progress"; +@use "components/tables"; +@use "components/lists"; +@use "components/skeleton"; +@use "components/tooltip"; +@use "components/dropdown"; +@use "components/navigation-shell"; @use "utils"; * { diff --git a/src/vue/components/GnDropdown.js b/src/vue/components/GnDropdown.js new file mode 100644 index 0000000..72951a9 --- /dev/null +++ b/src/vue/components/GnDropdown.js @@ -0,0 +1,44 @@ +/** + * GnDropdown - Dropdown menu wrapper. + */ + +import { defineComponent, h, ref, provide, computed } from "vue"; +import { cx } from "../utils.js"; + +export const dropdownKey = Symbol("vmk-ui-kit-dropdown"); + +export default defineComponent({ + name: "GnDropdown", + props: { + placement: { type: String, default: "left" }, + open: { type: Boolean, default: false } + }, + emits: ["update:open"], + setup(props, { slots, emit }) { + const isOpen = ref(props.open); + + const toggle = () => { + isOpen.value = !isOpen.value; + emit("update:open", isOpen.value); + }; + + const close = () => { + isOpen.value = false; + emit("update:open", false); + }; + + provide(dropdownKey, { isOpen, close }); + + return () => h("div", { class: "dropdown" }, [ + h("button", { + type: "button", + "data-dropdown-trigger": "", + onClick: toggle + }, slots.trigger?.()), + h("div", { + class: cx("dropdown-menu", { "is-open": isOpen.value }), + "data-placement": props.placement + }, slots.default?.()) + ]); + } +}); diff --git a/src/vue/components/GnDropdownItem.js b/src/vue/components/GnDropdownItem.js new file mode 100644 index 0000000..f4083dc --- /dev/null +++ b/src/vue/components/GnDropdownItem.js @@ -0,0 +1,39 @@ +/** + * GnDropdownItem - Dropdown menu item. + */ + +import { defineComponent, h, computed, inject } from "vue"; +import { cx, iconNode } from "../utils.js"; +import { dropdownKey } from "./GnDropdown.js"; + +export default defineComponent({ + name: "GnDropdownItem", + props: { + icon: { type: String, default: "" }, + active: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false } + }, + emits: ["click"], + setup(props, { slots, emit }) { + const dropdown = inject(dropdownKey, null); + + const classes = computed(() => cx("dropdown-item", { + "is-active": props.active, + "is-disabled": props.disabled + })); + + return () => h("button", { + type: "button", + class: classes.value, + disabled: props.disabled, + onClick: () => { + if (props.disabled) return; + dropdown?.close?.(); + emit("click"); + } + }, [ + props.icon && h("span", { class: "dropdown-item-icon" }, [iconNode(props.icon)]), + h("span", { class: "dropdown-item-content" }, slots.default?.()) + ]); + } +}); diff --git a/src/vue/components/GnList.js b/src/vue/components/GnList.js new file mode 100644 index 0000000..201b86f --- /dev/null +++ b/src/vue/components/GnList.js @@ -0,0 +1,24 @@ +/** + * GnList - Container for list items. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnList", + props: { + compact: { type: Boolean, default: false }, + flush: { type: Boolean, default: false }, + divided: { type: Boolean, default: false } + }, + setup(props, { slots }) { + const classes = computed(() => cx("list", { + "list-compact": props.compact, + "list-flush": props.flush, + "list-divided": props.divided + })); + + return () => h("ul", { class: classes.value }, slots.default?.()); + } +}); diff --git a/src/vue/components/GnListItem.js b/src/vue/components/GnListItem.js new file mode 100644 index 0000000..c7f3e7c --- /dev/null +++ b/src/vue/components/GnListItem.js @@ -0,0 +1,33 @@ +/** + * GnListItem - Single list item with optional icon and meta. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnListItem", + props: { + icon: { type: String, default: "" }, + meta: { type: String, default: "" }, + active: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false } + }, + emits: ["click"], + setup(props, { slots, emit }) { + const classes = computed(() => cx("list-item", { + "is-active": props.active, + "is-disabled": props.disabled + })); + + return () => h("li", { + class: classes.value, + tabindex: props.disabled ? undefined : "0", + onClick: () => !props.disabled && emit("click") + }, [ + props.icon && h("span", { class: "list-item-icon" }, [iconNode(props.icon)]), + h("span", { class: "list-item-content" }, slots.default?.()), + props.meta && h("span", { class: "list-item-meta" }, props.meta) + ]); + } +}); diff --git a/src/vue/components/GnNavigationItem.js b/src/vue/components/GnNavigationItem.js new file mode 100644 index 0000000..b64a689 --- /dev/null +++ b/src/vue/components/GnNavigationItem.js @@ -0,0 +1,37 @@ +/** + * GnNavigationItem - Sidebar navigation item. + */ + +import { defineComponent, h, computed, inject } from "vue"; +import { cx, iconNode } from "../utils.js"; +import { navigationKey } from "./GnNavigationShell.js"; + +export default defineComponent({ + name: "GnNavigationItem", + props: { + href: { type: String, default: "" }, + icon: { type: String, default: "" }, + active: { type: Boolean, default: false } + }, + emits: ["click"], + setup(props, { slots, emit }) { + const navigation = inject(navigationKey, null); + + const classes = computed(() => cx("navigation-item", { "is-active": props.active })); + + const tag = props.href ? "a" : "button"; + + return () => h(tag, { + class: classes.value, + href: props.href || undefined, + type: props.href ? undefined : "button", + onClick: () => { + navigation?.close?.(); + emit("click"); + } + }, [ + props.icon && h("span", { class: "navigation-item-icon" }, [iconNode(props.icon)]), + h("span", { class: "navigation-item-label" }, slots.default?.()) + ]); + } +}); diff --git a/src/vue/components/GnNavigationShell.js b/src/vue/components/GnNavigationShell.js new file mode 100644 index 0000000..44400d1 --- /dev/null +++ b/src/vue/components/GnNavigationShell.js @@ -0,0 +1,56 @@ +/** + * GnNavigationShell - Application layout with topbar and sidebar. + */ + +import { defineComponent, h, ref, provide, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export const navigationKey = Symbol("vmk-ui-kit-navigation"); + +export default defineComponent({ + name: "GnNavigationShell", + props: { + brand: { type: String, default: "" }, + collapsed: { type: Boolean, default: false } + }, + emits: ["update:collapsed"], + setup(props, { slots, emit }) { + const isOpen = ref(false); + + const toggle = () => { + isOpen.value = !isOpen.value; + emit("update:collapsed", isOpen.value); + }; + + const close = () => { + isOpen.value = false; + emit("update:collapsed", false); + }; + + provide(navigationKey, { isOpen, close }); + + return () => h("div", { class: "navigation-shell" }, [ + h("header", { class: "navigation-topbar" }, [ + h("button", { + type: "button", + class: "navigation-toggle btn btn-sm btn-tertiary", + "data-navigation-toggle": "", + onClick: toggle + }, [iconNode("ph-list")]), + h("div", { class: "navigation-brand" }, props.brand || slots.brand?.()), + h("div", { class: "navigation-topbar-actions" }, slots.topbar?.()) + ]), + h("aside", { + class: cx("navigation-sidebar", { "is-open": isOpen.value }) + }, [ + props.brand && h("div", { class: "navigation-brand navigation-brand-sidebar" }, props.brand || slots.brand?.()), + h("nav", { class: "navigation-menu" }, slots.menu?.()) + ]), + h("div", { + class: cx("navigation-backdrop", { "is-visible": isOpen.value }), + onClick: close + }), + h("main", { class: "navigation-content" }, slots.default?.()) + ]); + } +}); diff --git a/src/vue/components/GnSkeleton.js b/src/vue/components/GnSkeleton.js new file mode 100644 index 0000000..d729579 --- /dev/null +++ b/src/vue/components/GnSkeleton.js @@ -0,0 +1,32 @@ +/** + * GnSkeleton - Placeholder loader. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnSkeleton", + props: { + type: { type: String, default: "text" }, + size: { type: String, default: "" }, + width: { type: String, default: "" }, + height: { type: String, default: "" }, + block: { type: Boolean, default: false } + }, + setup(props) { + const classes = computed(() => cx("skeleton", `skeleton-${props.type}`, { + [`skeleton-${props.size}`]: props.size, + "skeleton-block": props.block + })); + + return () => h("span", { + class: classes.value, + style: { + width: props.width, + height: props.height + }, + "aria-hidden": "true" + }); + } +}); diff --git a/src/vue/components/GnTable.js b/src/vue/components/GnTable.js new file mode 100644 index 0000000..1a19a2e --- /dev/null +++ b/src/vue/components/GnTable.js @@ -0,0 +1,28 @@ +/** + * GnTable - Table wrapper with optional sortable/selectable variants. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnTable", + props: { + compact: { type: Boolean, default: false }, + striped: { type: Boolean, default: false }, + sortable: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false } + }, + setup(props, { slots }) { + const classes = computed(() => cx("table", { + "table-compact": props.compact, + "table-striped": props.striped, + "table-sortable": props.sortable, + "table-selectable": props.selectable + })); + + return () => h("div", { class: "table-container" }, [ + h("table", { class: classes.value }, slots.default?.()) + ]); + } +}); diff --git a/src/vue/components/GnTableHeader.js b/src/vue/components/GnTableHeader.js new file mode 100644 index 0000000..cc32f30 --- /dev/null +++ b/src/vue/components/GnTableHeader.js @@ -0,0 +1,22 @@ +/** + * GnTableHeader - Table header cell with optional sorting. + */ + +import { defineComponent, h } from "vue"; + +export default defineComponent({ + name: "GnTableHeader", + props: { + sortable: { type: Boolean, default: false }, + sort: { type: String, default: "none" } + }, + emits: ["sort"], + setup(props, { slots, emit }) { + return () => h("th", { + "data-sortable": props.sortable ? "" : null, + "aria-sort": props.sortable ? props.sort : null, + style: props.sortable ? { cursor: "pointer" } : null, + onClick: () => props.sortable && emit("sort", props.sort === "asc" ? "desc" : "asc") + }, slots.default?.()); + } +}); diff --git a/src/vue/components/GnTableRow.js b/src/vue/components/GnTableRow.js new file mode 100644 index 0000000..88d5aa7 --- /dev/null +++ b/src/vue/components/GnTableRow.js @@ -0,0 +1,18 @@ +/** + * GnTableRow - Table row wrapper. + */ + +import { defineComponent, h, computed } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnTableRow", + props: { + selected: { type: Boolean, default: false } + }, + setup(props, { slots }) { + const classes = computed(() => cx({ "is-selected": props.selected })); + + return () => h("tr", { class: classes.value }, slots.default?.()); + } +}); diff --git a/src/vue/components/GnTooltip.js b/src/vue/components/GnTooltip.js new file mode 100644 index 0000000..d6d1516 --- /dev/null +++ b/src/vue/components/GnTooltip.js @@ -0,0 +1,46 @@ +/** + * GnTooltip - Tooltip wrapper for any trigger element. + */ + +import { defineComponent, h, ref, computed } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnTooltip", + props: { + placement: { type: String, default: "top" }, + visible: { type: Boolean, default: false } + }, + emits: ["update:visible"], + setup(props, { slots, emit }) { + const isVisible = ref(props.visible); + + const classes = computed(() => cx("tooltip", `tooltip-${props.placement}`, { + "is-visible": isVisible.value + })); + + const show = () => { + isVisible.value = true; + emit("update:visible", true); + }; + + const hide = () => { + isVisible.value = false; + emit("update:visible", false); + }; + + return () => h("span", { + class: classes.value, + "data-placement": props.placement + }, [ + h("span", { + class: "tooltip-trigger", + onMouseenter: show, + onMouseleave: hide, + onFocus: show, + onBlur: hide + }, slots.trigger?.()), + h("span", { class: "tooltip-content" }, slots.default?.()) + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 64a3d57..f406583 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -24,5 +24,16 @@ 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 { default as GnTable } from "./components/GnTable.js"; +export { default as GnTableRow } from "./components/GnTableRow.js"; +export { default as GnTableHeader } from "./components/GnTableHeader.js"; +export { default as GnList } from "./components/GnList.js"; +export { default as GnListItem } from "./components/GnListItem.js"; +export { default as GnSkeleton } from "./components/GnSkeleton.js"; +export { default as GnTooltip } from "./components/GnTooltip.js"; +export { default as GnDropdown } from "./components/GnDropdown.js"; +export { default as GnDropdownItem } from "./components/GnDropdownItem.js"; +export { default as GnNavigationShell } from "./components/GnNavigationShell.js"; +export { default as GnNavigationItem } from "./components/GnNavigationItem.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 4e00f86..b4cecf5 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -24,6 +24,17 @@ import GnAvatar from "./components/GnAvatar.js"; import GnChip from "./components/GnChip.js"; import GnProgress from "./components/GnProgress.js"; +import GnTable from "./components/GnTable.js"; +import GnTableRow from "./components/GnTableRow.js"; +import GnTableHeader from "./components/GnTableHeader.js"; +import GnList from "./components/GnList.js"; +import GnListItem from "./components/GnListItem.js"; +import GnSkeleton from "./components/GnSkeleton.js"; +import GnTooltip from "./components/GnTooltip.js"; +import GnDropdown from "./components/GnDropdown.js"; +import GnDropdownItem from "./components/GnDropdownItem.js"; +import GnNavigationShell from "./components/GnNavigationShell.js"; +import GnNavigationItem from "./components/GnNavigationItem.js"; export const components = { GnButton, @@ -46,7 +57,18 @@ GnTabPanel, GnAvatar, GnChip, - GnProgress + GnProgress, + GnTable, + GnTableRow, + GnTableHeader, + GnList, + GnListItem, + GnSkeleton, + GnTooltip, + GnDropdown, + GnDropdownItem, + GnNavigationShell, + GnNavigationItem }; export default {