-
-
-
+
+
+
+
+
+
+
+
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 @@
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";