diff --git a/demo/index.html b/demo/index.html index ac630dd..2a3eefe 100644 --- a/demo/index.html +++ b/demo/index.html @@ -149,6 +149,11 @@
  • + + Tabs + +
  • +
  • Drawer @@ -203,6 +208,7 @@ @@include("partials/avatar.html") @@include("partials/timeline.html") @@include("partials/accordion.html") + @@include("partials/tabs.html") @@include("partials/drawer.html") @@include("partials/toasts.html") @@include("partials/cards.html") diff --git a/demo/partials/tabs.html b/demo/partials/tabs.html new file mode 100644 index 0000000..5b7cb70 --- /dev/null +++ b/demo/partials/tabs.html @@ -0,0 +1,95 @@ +
    +

    Tabs

    +

    + Tabs переключают связанные панели внутри одного контекста. Компонент поддерживает click, keyboard navigation и ARIA state через Tabs.init(). +

    + +
    +
    +
    + + + +
    +
    +
    +

    Overview keeps the primary status, totals, and next actions visible without leaving the current screen.

    +
    +
    +

    Activity contains the latest events, audit notes, and handoff messages for the same record.

    +
    +
    +

    Settings groups secondary options that affect this context but do not need full page navigation.

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

    Use compact tabs in dense panels, settings pages, and narrow metadata blocks.

    +
    +
    +

    Disabled tabs can stay visible when a feature is unavailable for the current object.

    +
    +
    +

    Billing content is disabled in this example.

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

    Vertical tabs work well when labels are longer or the panel needs a stable left rail.

    +
    +
    +

    Use them for account details, admin records, or focused configuration groups.

    +
    +
    +

    On smaller screens the tab rail becomes a horizontal scrollable list.

    +
    +
    +
    +
    + +
    +
    + Tabs HTML + +
    +
    <div class="tabs" data-tabs>
    +  <div class="tabs-list" aria-label="Project sections">
    +    <button class="tab tab-active" type="button" aria-controls="panel-overview">Overview</button>
    +    <button class="tab" type="button" aria-controls="panel-activity">Activity</button>
    +  </div>
    +  <div class="tabs-panels">
    +    <div class="tab-panel tab-panel-active" id="panel-overview">Overview content</div>
    +    <div class="tab-panel" id="panel-activity">Activity content</div>
    +  </div>
    +</div>
    +
    +
    diff --git a/docs/component-coverage.md b/docs/component-coverage.md index 15c7e73..42a9a24 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -25,6 +25,7 @@ | Avatar / Identity | [Data Display](components/data-display.md) | | Timeline / Activity Log | [Data Display](components/data-display.md) | | Accordion / Disclosure | [Layout Patterns](components/layout-patterns.md) | +| Tabs | [Navigation](components/navigation.md) | | Drawer / Side Panel | [Navigation](components/navigation.md) | | Toasts | [Feedback](components/feedback.md) | | Cards | [Data Display](components/data-display.md) | diff --git a/docs/components/navigation.md b/docs/components/navigation.md index a8550f1..9d8f33c 100644 --- a/docs/components/navigation.md +++ b/docs/components/navigation.md @@ -85,13 +85,28 @@ Tabs подходят для близких представлений внутри одного контекста. ```html -
    - - - +
    +
    + + + +
    +
    +
    Overview content
    +
    Activity content
    +
    Settings content
    +
    ``` +Behavior: + +```js +GNexusUIKit.Tabs.init(); +``` + +`Tabs.init()` запускается автоматически, если подключен bundle. + ## Dropdown, Tooltip, Popover Эти паттерны живут в `Navigation & Overlays`. diff --git a/public/assets/imgs/gnexus-mark.svg b/public/assets/imgs/gnexus-mark.svg index 0417f8d..b481b23 100644 --- a/public/assets/imgs/gnexus-mark.svg +++ b/public/assets/imgs/gnexus-mark.svg @@ -1,6 +1,10 @@ GNexus UI Kit - - + + + + + + diff --git a/src/js/components/modals.js b/src/js/components/modals.js index b31ac54..78dc12f 100644 --- a/src/js/components/modals.js +++ b/src/js/components/modals.js @@ -22,11 +22,14 @@ const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; + const dialog = document.createElement("div"); + dialog.className = "modal-dialog"; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", `${id}-title`); + const panel = document.createElement("div"); panel.className = "modal-panel"; - panel.setAttribute("role", "dialog"); - panel.setAttribute("aria-modal", "true"); - panel.setAttribute("aria-labelledby", `${id}-title`); const header = document.createElement("header"); header.className = "modal-header"; @@ -50,8 +53,9 @@ appendContent(modalFooter, footer, props.footerMode ?? "html"); header.append(modalTitle, close); - panel.append(header, body, modalFooter); - modal.append(backdrop, panel); + panel.append(body, modalFooter); + dialog.append(header, panel); + modal.append(backdrop, dialog); return modal; } diff --git a/src/js/components/overlays.js b/src/js/components/overlays.js index 5621242..06d43b9 100644 --- a/src/js/components/overlays.js +++ b/src/js/components/overlays.js @@ -16,28 +16,6 @@ }); } -function initTabs(root = document) { - root.addEventListener("click", event => { - const tab = event.target.closest(".tab"); - - if(!tab) { - return; - } - - const tabs = tab.closest(".tabs"); - - if(!tabs) { - return; - } - - tabs.querySelectorAll(".tab").forEach(item => { - const isActive = item === tab; - item.classList.toggle("tab-active", isActive); - item.setAttribute("aria-selected", String(isActive)); - }); - }); -} - function initDropdowns(root = document) { root.addEventListener("click", event => { const trigger = event.target.closest("[data-dropdown-toggle]"); @@ -128,7 +106,6 @@ return; } - initTabs(root); initDropdowns(root); initPopovers(root); initTooltips(root); diff --git a/src/js/components/tabs.js b/src/js/components/tabs.js new file mode 100644 index 0000000..c93d832 --- /dev/null +++ b/src/js/components/tabs.js @@ -0,0 +1,151 @@ +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) { + 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); + + if(panel) { + 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"); + setPanelState(panel, activeTab ? panel === getPanel(root, activeTab) : panel.classList.contains("tab-panel-active")); + }); +} + +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 02eea4f..d70c06e 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -9,6 +9,7 @@ import Overlays from "./components/overlays.js"; import InputPatterns from "./components/input-patterns.js"; import Accordion from "./components/accordion.js"; +import Tabs from "./components/tabs.js"; import demoNavigation from "./demo-navigation.js"; import codeExamples from "./code-examples.js"; @@ -23,7 +24,8 @@ NavigationShell, Overlays, InputPatterns, - Accordion + Accordion, + Tabs }; window.GNexusUIKit = api; @@ -34,6 +36,7 @@ NavigationShell.init(); InputPatterns.init(); Accordion.init(); + Tabs.init(); demoNavigation(); codeExamples(); }); @@ -49,7 +52,8 @@ NavigationShell, Overlays, InputPatterns, - Accordion + Accordion, + Tabs }; export default api; diff --git a/src/scss/components/_modals.scss b/src/scss/components/_modals.scss index 8de4318..8bc9014 100644 --- a/src/scss/components/_modals.scss +++ b/src/scss/components/_modals.scss @@ -29,7 +29,7 @@ transition-property: opacity; } - .modal-panel { + .modal-dialog { position: relative; z-index: 1020; @@ -37,18 +37,14 @@ max-width: 960px; margin: $space-5; height: auto; - min-height: 200px; max-height: calc(100vh - #{$space-10}); padding: 0; display: flex; flex-direction: column; - gap: $space-4; + gap: 0; margin-top: 200px; - background: $surface-page; - border: $border-width-base solid $color-text-light; - border-left-width: $border-width-accent; opacity: 0; transition-duration: $motion-slow; @@ -60,7 +56,7 @@ flex-direction: row; justify-content: space-between; align-items: center; - padding-right: $space-4; + gap: $space-4; .modal-title { padding: $space-3 $space-4; @@ -69,6 +65,24 @@ text-transform: uppercase; letter-spacing: $letter-spacing-wide; } + + .modal-close { + flex: 0 0 auto; + color: $color-text-light; + border-color: $border-color-muted; + background: $surface-page; + } + } + + .modal-panel { + min-height: 200px; + display: flex; + flex-direction: column; + gap: $space-4; + overflow: hidden; + background: $surface-page; + border: $border-width-base solid $color-text-light; + border-left-width: $border-width-accent; } .modal-body { @@ -95,7 +109,7 @@ opacity: 1; } - .modal-panel { + .modal-dialog { opacity: 1; margin-top: 0; } @@ -106,7 +120,7 @@ opacity: 0; } - .modal-panel { + .modal-dialog { opacity: 0; margin-top: -200px; } diff --git a/src/scss/components/_tabs.scss b/src/scss/components/_tabs.scss new file mode 100644 index 0000000..848e13d --- /dev/null +++ b/src/scss/components/_tabs.scss @@ -0,0 +1,161 @@ +@use "../kit-deps" as *; +@use "typography" as *; + +.tabs { + display: grid; + gap: $space-4; + width: 100%; + max-width: 900px; +} + +.tabs-list { + display: flex; + align-items: stretch; + gap: 0; + max-width: 100%; + overflow-x: auto; + border: $border-width-base solid $border-color-muted; + border-left-width: $border-width-accent; + background: $surface-panel-muted; + 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-right: $border-width-base solid rgba($color-text-light, 0.08); + border-radius: 0; + color: $color-text-medium; + background: transparent; + font-family: $font-family-base; + font-size: $font-size-sm; + font-weight: $font-weight-bold; + line-height: $line-height-base; + text-transform: uppercase; + white-space: nowrap; + cursor: pointer; + transition-duration: $motion-base; + transition-timing-function: $motion-ease; + transition-property: color, background, opacity; + + .ph, + .ph-bold { + font-size: $icon-size-sm; + } + + &:focus-visible { + @include focus_ring; + z-index: 1; + } + + @include hover_touch { + color: $color-black; + background: $color-secondary; + } + + &:disabled, + &[aria-disabled="true"] { + color: $color-text-dark; + cursor: not-allowed; + opacity: 0.62; + + @include hover_touch { + color: $color-text-dark; + background: transparent; + } + } +} + +.tab-active, +.tab[aria-selected="true"] { + color: $color-black; + background: $color-primary; +} + +.tabs-panels { + border: $border-width-base solid $border-color-muted; + border-left-width: $border-width-accent; + background: $surface-panel-muted; +} + +.tab-panel { + display: none; + padding: $space-4; + color: $color-text-medium; + font-size: $font-size-sm; + line-height: $line-height-relaxed; + + p { + margin-top: 0; + } + + p:last-child { + margin-bottom: 0; + } +} + +.tab-panel-active { + display: block; +} + +.tabs-compact { + max-width: 620px; + + .tabs-list { + border-left-width: $border-width-base; + } + + .tab { + min-height: $control-height-sm; + padding: $space-2 $space-3; + } + + .tabs-panels { + border-left-width: $border-width-base; + } + + .tab-panel { + padding: $space-3; + } +} + +.tabs-vertical { + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + align-items: start; + + .tabs-list { + flex-direction: column; + overflow-x: visible; + } + + .tab { + justify-content: flex-start; + border-right: 0; + border-bottom: $border-width-base solid rgba($color-text-light, 0.08); + text-align: left; + } +} + +@include media_down("md") { + .tabs-vertical { + grid-template-columns: 1fr; + + .tabs-list { + flex-direction: row; + overflow-x: auto; + } + + .tab { + justify-content: center; + border-right: $border-width-base solid rgba($color-text-light, 0.08); + border-bottom: 0; + text-align: center; + } + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 4932de3..4ac9ca7 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -18,6 +18,7 @@ @use "components/stepper"; @use "components/timeline"; @use "components/accordion"; +@use "components/tabs"; @use "components/drawer"; @use "components/navigation-shell"; @use "components/toasts";