@extrachill/components 0.4.23 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.23] - 2026-03-25
4
+
5
+ ### Changed
6
+ - Refine responsive accordion spacing and interaction styling
7
+
8
+ ## [0.4.22] - 2026-03-25
9
+
10
+ ### Changed
11
+ - Preserve local accordion collapse state on mobile
12
+
13
+ ## [0.4.21] - 2026-03-25
14
+
15
+ ### Changed
16
+ - Allow responsive accordion items to collapse on second tap
17
+
18
+ ## [0.4.20] - 2026-03-25
19
+
20
+ ### Changed
21
+ - Keep responsive tabs root structural only
22
+
23
+ ## [0.4.19] - 2026-03-25
24
+
25
+ ### Changed
26
+ - Limit responsive tabs accordion mode to phone breakpoint
27
+
28
+ ## [0.4.18] - 2026-03-25
29
+
30
+ ### Changed
31
+ - Remove mobile color overrides from shared surfaces
32
+
33
+ ## [0.4.17] - 2026-03-25
34
+
35
+ ### Changed
36
+ - Fix responsive tabs mobile interaction and shared hover styling
37
+
3
38
  ## [0.4.16] - 2026-03-25
4
39
 
5
40
  ### Changed
@@ -12,6 +12,8 @@ export interface ResponsiveTabsProps {
12
12
  mobileBreakpoint?: number;
13
13
  accordionClassName?: string;
14
14
  showDesktopTabs?: boolean;
15
+ syncWithHash?: boolean;
16
+ hashPrefix?: string;
15
17
  }
16
- export declare function ResponsiveTabs({ tabs, active, onChange, renderPanel, className, classPrefix, tabsClassName, tabsClassPrefix, mobileBreakpoint, accordionClassName, showDesktopTabs, }: ResponsiveTabsProps): import("react/jsx-runtime").JSX.Element | null;
18
+ export declare function ResponsiveTabs({ tabs, active, onChange, renderPanel, className, classPrefix, tabsClassName, tabsClassPrefix, mobileBreakpoint, accordionClassName, showDesktopTabs, syncWithHash, hashPrefix, }: ResponsiveTabsProps): import("react/jsx-runtime").JSX.Element | null;
17
19
  //# sourceMappingURL=ResponsiveTabs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ResponsiveTabs.d.ts","sourceRoot":"","sources":["../src/ResponsiveTabs.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,CAAE,EAAE,EAAE,MAAM,KAAM,IAAI,CAAC;IACjC,WAAW,EAAE,CAAE,EAAE,EAAE,MAAM,KAAM,SAAS,CAAC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,wBAAgB,cAAc,CAAE,EAC/B,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,WAAW,EACX,SAAc,EACd,WAAkC,EAClC,aAAkB,EAClB,eAA2B,EAC3B,gBAAsB,EACtB,kBAAuB,EACvB,eAAsB,GACtB,EAAE,mBAAmB,kDAiGrB"}
1
+ {"version":3,"file":"ResponsiveTabs.d.ts","sourceRoot":"","sources":["../src/ResponsiveTabs.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,CAAE,EAAE,EAAE,MAAM,KAAM,IAAI,CAAC;IACjC,WAAW,EAAE,CAAE,EAAE,EAAE,MAAM,KAAM,SAAS,CAAC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,cAAc,CAAE,EAC/B,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,WAAW,EACX,SAAc,EACd,WAAkC,EAClC,aAAkB,EAClB,eAA2B,EAC3B,gBAAsB,EACtB,kBAAuB,EACvB,eAAsB,EACtB,YAAoB,EACpB,UAAmB,GACnB,EAAE,mBAAmB,kDA6IrB"}
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from 'react';
3
3
  import { Tabs } from "./Tabs.js";
4
- export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className = '', classPrefix = 'ec-responsive-tabs', tabsClassName = '', tabsClassPrefix = 'ec-tabs', mobileBreakpoint = 480, accordionClassName = '', showDesktopTabs = true, }) {
4
+ export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className = '', classPrefix = 'ec-responsive-tabs', tabsClassName = '', tabsClassPrefix = 'ec-tabs', mobileBreakpoint = 480, accordionClassName = '', showDesktopTabs = true, syncWithHash = false, hashPrefix = 'tab-', }) {
5
5
  const [isMobile, setIsMobile] = useState(() => {
6
6
  if (typeof window === 'undefined') {
7
7
  return false;
@@ -9,6 +9,24 @@ export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className
9
9
  return window.innerWidth < mobileBreakpoint;
10
10
  });
11
11
  const [mobileActive, setMobileActive] = useState(active);
12
+ const resolveTabIdFromHash = (hash) => {
13
+ const normalized = hash.replace(/^#/, '');
14
+ if (!normalized.startsWith(hashPrefix)) {
15
+ return null;
16
+ }
17
+ const id = normalized.slice(hashPrefix.length);
18
+ return tabs.some((tab) => tab.id === id) ? id : null;
19
+ };
20
+ const updateHash = (id) => {
21
+ if (!syncWithHash || typeof window === 'undefined') {
22
+ return;
23
+ }
24
+ window.location.hash = `${hashPrefix}${id}`;
25
+ };
26
+ const handleChange = (id) => {
27
+ onChange(id);
28
+ updateHash(id);
29
+ };
12
30
  useEffect(() => {
13
31
  if (typeof window === 'undefined') {
14
32
  return undefined;
@@ -25,6 +43,22 @@ export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className
25
43
  setMobileActive(active);
26
44
  }
27
45
  }, [active, isMobile]);
46
+ useEffect(() => {
47
+ if (!syncWithHash || typeof window === 'undefined' || tabs.length === 0) {
48
+ return undefined;
49
+ }
50
+ const syncFromHash = () => {
51
+ const id = resolveTabIdFromHash(window.location.hash);
52
+ if (!id) {
53
+ return;
54
+ }
55
+ onChange(id);
56
+ setMobileActive(id);
57
+ };
58
+ syncFromHash();
59
+ window.addEventListener('hashchange', syncFromHash);
60
+ return () => window.removeEventListener('hashchange', syncFromHash);
61
+ }, [hashPrefix, onChange, syncWithHash, tabs]);
28
62
  const rootClass = useMemo(() => [
29
63
  classPrefix,
30
64
  isMobile ? `${classPrefix}--mobile` : `${classPrefix}--desktop`,
@@ -36,7 +70,7 @@ export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className
36
70
  return null;
37
71
  }
38
72
  if (!isMobile) {
39
- return (_jsxs("div", { className: rootClass, children: [showDesktopTabs && (_jsx(Tabs, { tabs: tabs, active: active, onChange: onChange, className: tabsClassName, classPrefix: tabsClassPrefix })), _jsx("div", { className: `${classPrefix}__desktop-panel`, children: renderPanel(active) })] }));
73
+ return (_jsxs("div", { className: rootClass, children: [showDesktopTabs && (_jsx(Tabs, { tabs: tabs, active: active, onChange: handleChange, className: tabsClassName, classPrefix: tabsClassPrefix })), _jsx("div", { className: `${classPrefix}__desktop-panel`, children: renderPanel(active) })] }));
40
74
  }
41
75
  return (_jsx("div", { className: rootClass, children: _jsx("div", { className: [`${classPrefix}__accordion`, accordionClassName].filter(Boolean).join(' '), children: tabs.map((tab) => {
42
76
  const isActive = tab.id === mobileActive;
@@ -46,7 +80,7 @@ export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className
46
80
  return;
47
81
  }
48
82
  setMobileActive(tab.id);
49
- onChange(tab.id);
83
+ handleChange(tab.id);
50
84
  }, children: [_jsx("span", { children: tab.label }), _jsx("span", { className: `${classPrefix}__arrow`, "aria-hidden": "true", children: isActive ? '▲' : '▼' })] }), isActive && _jsx("div", { className: `${classPrefix}__panel`, children: renderPanel(tab.id) })] }, tab.id));
51
85
  }) }) }));
52
86
  }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { SearchBox, type SearchBoxProps } from './SearchBox.tsx';
9
9
  export { Modal, type ModalProps } from './Modal.tsx';
10
10
  export { Tabs, type TabsProps, type TabItem } from './Tabs.tsx';
11
11
  export { ResponsiveTabs, type ResponsiveTabsProps } from './ResponsiveTabs.tsx';
12
+ export { initResponsiveTabsDom, type ResponsiveTabsDomOptions } from './initResponsiveTabsDom.ts';
12
13
  export { ShellTabs, type ShellTabsProps } from './ShellTabs.tsx';
13
14
  export { Panel, type PanelProps } from './Panel.tsx';
14
15
  export { Surface, type SurfaceProps } from './Surface.tsx';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,qBAAqB,EAAE,KAAK,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAClG,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { SearchBox } from "./SearchBox.js";
9
9
  export { Modal } from "./Modal.js";
10
10
  export { Tabs } from "./Tabs.js";
11
11
  export { ResponsiveTabs } from "./ResponsiveTabs.js";
12
+ export { initResponsiveTabsDom } from "./initResponsiveTabsDom.js";
12
13
  export { ShellTabs } from "./ShellTabs.js";
13
14
  export { Panel } from "./Panel.js";
14
15
  export { Surface } from "./Surface.js";
@@ -0,0 +1,8 @@
1
+ export interface ResponsiveTabsDomOptions {
2
+ selector?: string;
3
+ mobileBreakpoint?: number;
4
+ hashPrefix?: string;
5
+ onPanelRender?: (panel: HTMLElement, tabId: string, root: HTMLElement) => void;
6
+ }
7
+ export declare function initResponsiveTabsDom(options?: ResponsiveTabsDomOptions): void;
8
+ //# sourceMappingURL=initResponsiveTabsDom.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"initResponsiveTabsDom.d.ts","sourceRoot":"","sources":["../src/initResponsiveTabsDom.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,CAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAM,IAAI,CAAC;CACjF;AA2KD,wBAAgB,qBAAqB,CAAE,OAAO,GAAE,wBAA6B,QAO5E"}
@@ -0,0 +1,139 @@
1
+ const DEFAULT_SELECTOR = '[data-ec-responsive-tabs]';
2
+ function createNodeFromTemplate(template) {
3
+ const fragment = template.content.cloneNode(true);
4
+ const wrapper = document.createElement('div');
5
+ wrapper.appendChild(fragment);
6
+ return wrapper.firstElementChild instanceof HTMLElement ? wrapper.firstElementChild : wrapper;
7
+ }
8
+ function renderDesktopPanel(root, tabId, onPanelRender) {
9
+ const desktopPanel = root.querySelector('.ec-responsive-tabs__desktop-panel');
10
+ const template = root.querySelector(`template[data-tab-panel="${tabId}"]`);
11
+ if (!desktopPanel || !template) {
12
+ return;
13
+ }
14
+ desktopPanel.innerHTML = '';
15
+ const content = createNodeFromTemplate(template);
16
+ if (!content) {
17
+ return;
18
+ }
19
+ desktopPanel.appendChild(content);
20
+ onPanelRender?.(desktopPanel, tabId, root);
21
+ }
22
+ function renderAccordion(root, activeTabId, hashPrefix, onPanelRender) {
23
+ const accordion = root.querySelector('.ec-responsive-tabs__accordion');
24
+ const tabs = Array.from(root.querySelectorAll('.ec-tabs__tab[data-tab-id]'));
25
+ if (!accordion) {
26
+ return;
27
+ }
28
+ accordion.innerHTML = '';
29
+ tabs.forEach((tab) => {
30
+ const tabId = tab.dataset.tabId;
31
+ if (!tabId) {
32
+ return;
33
+ }
34
+ const label = tab.textContent?.trim() || tabId;
35
+ const item = document.createElement('div');
36
+ item.className = `ec-responsive-tabs__item${tabId === activeTabId ? ' is-active' : ''}`;
37
+ const trigger = document.createElement('button');
38
+ trigger.type = 'button';
39
+ trigger.className = `ec-responsive-tabs__trigger${tabId === activeTabId ? ' is-active' : ''}`;
40
+ trigger.setAttribute('aria-expanded', tabId === activeTabId ? 'true' : 'false');
41
+ trigger.dataset.tabId = tabId;
42
+ trigger.innerHTML = `<span>${label}</span><span class="ec-responsive-tabs__arrow" aria-hidden="true">${tabId === activeTabId ? '▲' : '▼'}</span>`;
43
+ item.appendChild(trigger);
44
+ if (tabId === activeTabId) {
45
+ const template = root.querySelector(`template[data-tab-panel="${tabId}"]`);
46
+ if (template) {
47
+ const panel = document.createElement('div');
48
+ panel.className = 'ec-responsive-tabs__panel';
49
+ const content = createNodeFromTemplate(template);
50
+ if (content) {
51
+ panel.appendChild(content);
52
+ item.appendChild(panel);
53
+ onPanelRender?.(panel, tabId, root);
54
+ }
55
+ }
56
+ }
57
+ trigger.addEventListener('click', () => {
58
+ const isActive = root.dataset.activeTab === tabId;
59
+ if (isActive) {
60
+ root.dataset.activeTab = '';
61
+ window.location.hash = '';
62
+ renderAccordion(root, null, hashPrefix, onPanelRender);
63
+ return;
64
+ }
65
+ root.dataset.activeTab = tabId;
66
+ window.location.hash = `${hashPrefix}${tabId}`;
67
+ renderDesktopPanel(root, tabId, onPanelRender);
68
+ renderAccordion(root, tabId, hashPrefix, onPanelRender);
69
+ setActiveDesktopTab(root, tabId);
70
+ });
71
+ accordion.appendChild(item);
72
+ });
73
+ }
74
+ function setActiveDesktopTab(root, activeTabId) {
75
+ root.querySelectorAll('.ec-tabs__tab[data-tab-id]').forEach((tab) => {
76
+ tab.classList.toggle('is-active', tab.dataset.tabId === activeTabId);
77
+ if (tab.dataset.tabId === activeTabId) {
78
+ tab.setAttribute('aria-selected', 'true');
79
+ }
80
+ else {
81
+ tab.setAttribute('aria-selected', 'false');
82
+ }
83
+ });
84
+ }
85
+ function resolveHashTabId(root, hashPrefix) {
86
+ const normalized = window.location.hash.replace(/^#/, '');
87
+ if (!normalized.startsWith(hashPrefix)) {
88
+ return null;
89
+ }
90
+ const id = normalized.slice(hashPrefix.length);
91
+ return root.querySelector(`.ec-tabs__tab[data-tab-id="${id}"]`) ? id : null;
92
+ }
93
+ function initResponsiveTabsRoot(root, options) {
94
+ if (root.dataset.ecResponsiveTabsInitialized === '1') {
95
+ return;
96
+ }
97
+ root.dataset.ecResponsiveTabsInitialized = '1';
98
+ const hashPrefix = root.dataset.hashPrefix || options.hashPrefix || 'tab-';
99
+ const tabs = Array.from(root.querySelectorAll('.ec-tabs__tab[data-tab-id]'));
100
+ const firstTabId = tabs[0]?.dataset.tabId || '';
101
+ const initialTabId = resolveHashTabId(root, hashPrefix) || root.dataset.activeTab || firstTabId;
102
+ if (!initialTabId) {
103
+ return;
104
+ }
105
+ root.dataset.activeTab = initialTabId;
106
+ setActiveDesktopTab(root, initialTabId);
107
+ renderDesktopPanel(root, initialTabId, options.onPanelRender);
108
+ renderAccordion(root, initialTabId, hashPrefix, options.onPanelRender);
109
+ tabs.forEach((tab) => {
110
+ tab.addEventListener('click', () => {
111
+ const tabId = tab.dataset.tabId;
112
+ if (!tabId) {
113
+ return;
114
+ }
115
+ root.dataset.activeTab = tabId;
116
+ window.location.hash = `${hashPrefix}${tabId}`;
117
+ setActiveDesktopTab(root, tabId);
118
+ renderDesktopPanel(root, tabId, options.onPanelRender);
119
+ renderAccordion(root, tabId, hashPrefix, options.onPanelRender);
120
+ });
121
+ });
122
+ window.addEventListener('hashchange', () => {
123
+ const tabId = resolveHashTabId(root, hashPrefix);
124
+ if (!tabId) {
125
+ return;
126
+ }
127
+ root.dataset.activeTab = tabId;
128
+ setActiveDesktopTab(root, tabId);
129
+ renderDesktopPanel(root, tabId, options.onPanelRender);
130
+ renderAccordion(root, tabId, hashPrefix, options.onPanelRender);
131
+ });
132
+ }
133
+ export function initResponsiveTabsDom(options = {}) {
134
+ if (typeof document === 'undefined') {
135
+ return;
136
+ }
137
+ const selector = options.selector || DEFAULT_SELECTOR;
138
+ document.querySelectorAll(selector).forEach((root) => initResponsiveTabsRoot(root, options));
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@extrachill/components",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "Shared React components for the Extra Chill Platform.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,6 +14,8 @@ export interface ResponsiveTabsProps {
14
14
  mobileBreakpoint?: number;
15
15
  accordionClassName?: string;
16
16
  showDesktopTabs?: boolean;
17
+ syncWithHash?: boolean;
18
+ hashPrefix?: string;
17
19
  }
18
20
 
19
21
  export function ResponsiveTabs( {
@@ -28,6 +30,8 @@ export function ResponsiveTabs( {
28
30
  mobileBreakpoint = 480,
29
31
  accordionClassName = '',
30
32
  showDesktopTabs = true,
33
+ syncWithHash = false,
34
+ hashPrefix = 'tab-',
31
35
  }: ResponsiveTabsProps ) {
32
36
  const [ isMobile, setIsMobile ] = useState( () => {
33
37
  if ( typeof window === 'undefined' ) {
@@ -38,6 +42,29 @@ export function ResponsiveTabs( {
38
42
  } );
39
43
  const [ mobileActive, setMobileActive ] = useState<string | null>( active );
40
44
 
45
+ const resolveTabIdFromHash = ( hash: string ) => {
46
+ const normalized = hash.replace( /^#/, '' );
47
+ if ( ! normalized.startsWith( hashPrefix ) ) {
48
+ return null;
49
+ }
50
+
51
+ const id = normalized.slice( hashPrefix.length );
52
+ return tabs.some( ( tab ) => tab.id === id ) ? id : null;
53
+ };
54
+
55
+ const updateHash = ( id: string ) => {
56
+ if ( ! syncWithHash || typeof window === 'undefined' ) {
57
+ return;
58
+ }
59
+
60
+ window.location.hash = `${ hashPrefix }${ id }`;
61
+ };
62
+
63
+ const handleChange = ( id: string ) => {
64
+ onChange( id );
65
+ updateHash( id );
66
+ };
67
+
41
68
  useEffect( () => {
42
69
  if ( typeof window === 'undefined' ) {
43
70
  return undefined;
@@ -59,6 +86,27 @@ export function ResponsiveTabs( {
59
86
  }
60
87
  }, [ active, isMobile ] );
61
88
 
89
+ useEffect( () => {
90
+ if ( ! syncWithHash || typeof window === 'undefined' || tabs.length === 0 ) {
91
+ return undefined;
92
+ }
93
+
94
+ const syncFromHash = () => {
95
+ const id = resolveTabIdFromHash( window.location.hash );
96
+ if ( ! id ) {
97
+ return;
98
+ }
99
+
100
+ onChange( id );
101
+ setMobileActive( id );
102
+ };
103
+
104
+ syncFromHash();
105
+ window.addEventListener( 'hashchange', syncFromHash );
106
+
107
+ return () => window.removeEventListener( 'hashchange', syncFromHash );
108
+ }, [ hashPrefix, onChange, syncWithHash, tabs ] );
109
+
62
110
  const rootClass = useMemo(
63
111
  () => [
64
112
  classPrefix,
@@ -81,7 +129,7 @@ export function ResponsiveTabs( {
81
129
  <Tabs
82
130
  tabs={ tabs }
83
131
  active={ active }
84
- onChange={ onChange }
132
+ onChange={ handleChange }
85
133
  className={ tabsClassName }
86
134
  classPrefix={ tabsClassPrefix }
87
135
  />
@@ -104,15 +152,15 @@ export function ResponsiveTabs( {
104
152
  className={ `${ classPrefix }__trigger${ isActive ? ' is-active' : '' }` }
105
153
  aria-expanded={ isActive }
106
154
  onClick={ () => {
107
- if ( isActive ) {
108
- setMobileActive( null );
109
- return;
110
- }
111
-
112
- setMobileActive( tab.id );
113
- onChange( tab.id );
114
- } }
115
- >
155
+ if ( isActive ) {
156
+ setMobileActive( null );
157
+ return;
158
+ }
159
+
160
+ setMobileActive( tab.id );
161
+ handleChange( tab.id );
162
+ } }
163
+ >
116
164
  <span>{ tab.label }</span>
117
165
  <span className={ `${ classPrefix }__arrow` } aria-hidden="true">
118
166
  { isActive ? '▲' : '▼' }
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@ export { SearchBox, type SearchBoxProps } from './SearchBox.tsx';
10
10
  export { Modal, type ModalProps } from './Modal.tsx';
11
11
  export { Tabs, type TabsProps, type TabItem } from './Tabs.tsx';
12
12
  export { ResponsiveTabs, type ResponsiveTabsProps } from './ResponsiveTabs.tsx';
13
+ export { initResponsiveTabsDom, type ResponsiveTabsDomOptions } from './initResponsiveTabsDom.ts';
13
14
  export { ShellTabs, type ShellTabsProps } from './ShellTabs.tsx';
14
15
  export { Panel, type PanelProps } from './Panel.tsx';
15
16
  export { Surface, type SurfaceProps } from './Surface.tsx';
@@ -0,0 +1,184 @@
1
+ export interface ResponsiveTabsDomOptions {
2
+ selector?: string;
3
+ mobileBreakpoint?: number;
4
+ hashPrefix?: string;
5
+ onPanelRender?: ( panel: HTMLElement, tabId: string, root: HTMLElement ) => void;
6
+ }
7
+
8
+ const DEFAULT_SELECTOR = '[data-ec-responsive-tabs]';
9
+
10
+ function createNodeFromTemplate( template: HTMLTemplateElement ): HTMLElement | null {
11
+ const fragment = template.content.cloneNode( true );
12
+ const wrapper = document.createElement( 'div' );
13
+ wrapper.appendChild( fragment );
14
+
15
+ return wrapper.firstElementChild instanceof HTMLElement ? wrapper.firstElementChild : wrapper;
16
+ }
17
+
18
+ function renderDesktopPanel(
19
+ root: HTMLElement,
20
+ tabId: string,
21
+ onPanelRender?: ResponsiveTabsDomOptions['onPanelRender']
22
+ ) {
23
+ const desktopPanel = root.querySelector<HTMLElement>( '.ec-responsive-tabs__desktop-panel' );
24
+ const template = root.querySelector<HTMLTemplateElement>( `template[data-tab-panel="${ tabId }"]` );
25
+
26
+ if ( ! desktopPanel || ! template ) {
27
+ return;
28
+ }
29
+
30
+ desktopPanel.innerHTML = '';
31
+ const content = createNodeFromTemplate( template );
32
+ if ( ! content ) {
33
+ return;
34
+ }
35
+
36
+ desktopPanel.appendChild( content );
37
+ onPanelRender?.( desktopPanel, tabId, root );
38
+ }
39
+
40
+ function renderAccordion(
41
+ root: HTMLElement,
42
+ activeTabId: string | null,
43
+ hashPrefix: string,
44
+ onPanelRender?: ResponsiveTabsDomOptions['onPanelRender']
45
+ ) {
46
+ const accordion = root.querySelector<HTMLElement>( '.ec-responsive-tabs__accordion' );
47
+ const tabs = Array.from( root.querySelectorAll<HTMLElement>( '.ec-tabs__tab[data-tab-id]' ) );
48
+
49
+ if ( ! accordion ) {
50
+ return;
51
+ }
52
+
53
+ accordion.innerHTML = '';
54
+
55
+ tabs.forEach( ( tab ) => {
56
+ const tabId = tab.dataset.tabId;
57
+ if ( ! tabId ) {
58
+ return;
59
+ }
60
+
61
+ const label = tab.textContent?.trim() || tabId;
62
+ const item = document.createElement( 'div' );
63
+ item.className = `ec-responsive-tabs__item${ tabId === activeTabId ? ' is-active' : '' }`;
64
+
65
+ const trigger = document.createElement( 'button' );
66
+ trigger.type = 'button';
67
+ trigger.className = `ec-responsive-tabs__trigger${ tabId === activeTabId ? ' is-active' : '' }`;
68
+ trigger.setAttribute( 'aria-expanded', tabId === activeTabId ? 'true' : 'false' );
69
+ trigger.dataset.tabId = tabId;
70
+ trigger.innerHTML = `<span>${ label }</span><span class="ec-responsive-tabs__arrow" aria-hidden="true">${ tabId === activeTabId ? '▲' : '▼' }</span>`;
71
+ item.appendChild( trigger );
72
+
73
+ if ( tabId === activeTabId ) {
74
+ const template = root.querySelector<HTMLTemplateElement>( `template[data-tab-panel="${ tabId }"]` );
75
+ if ( template ) {
76
+ const panel = document.createElement( 'div' );
77
+ panel.className = 'ec-responsive-tabs__panel';
78
+ const content = createNodeFromTemplate( template );
79
+ if ( content ) {
80
+ panel.appendChild( content );
81
+ item.appendChild( panel );
82
+ onPanelRender?.( panel, tabId, root );
83
+ }
84
+ }
85
+ }
86
+
87
+ trigger.addEventListener( 'click', () => {
88
+ const isActive = root.dataset.activeTab === tabId;
89
+ if ( isActive ) {
90
+ root.dataset.activeTab = '';
91
+ window.location.hash = '';
92
+ renderAccordion( root, null, hashPrefix, onPanelRender );
93
+ return;
94
+ }
95
+
96
+ root.dataset.activeTab = tabId;
97
+ window.location.hash = `${ hashPrefix }${ tabId }`;
98
+ renderDesktopPanel( root, tabId, onPanelRender );
99
+ renderAccordion( root, tabId, hashPrefix, onPanelRender );
100
+ setActiveDesktopTab( root, tabId );
101
+ } );
102
+
103
+ accordion.appendChild( item );
104
+ } );
105
+ }
106
+
107
+ function setActiveDesktopTab( root: HTMLElement, activeTabId: string ) {
108
+ root.querySelectorAll<HTMLElement>( '.ec-tabs__tab[data-tab-id]' ).forEach( ( tab ) => {
109
+ tab.classList.toggle( 'is-active', tab.dataset.tabId === activeTabId );
110
+ if ( tab.dataset.tabId === activeTabId ) {
111
+ tab.setAttribute( 'aria-selected', 'true' );
112
+ } else {
113
+ tab.setAttribute( 'aria-selected', 'false' );
114
+ }
115
+ } );
116
+ }
117
+
118
+ function resolveHashTabId( root: HTMLElement, hashPrefix: string ) {
119
+ const normalized = window.location.hash.replace( /^#/, '' );
120
+ if ( ! normalized.startsWith( hashPrefix ) ) {
121
+ return null;
122
+ }
123
+
124
+ const id = normalized.slice( hashPrefix.length );
125
+ return root.querySelector<HTMLElement>( `.ec-tabs__tab[data-tab-id="${ id }"]` ) ? id : null;
126
+ }
127
+
128
+ function initResponsiveTabsRoot( root: HTMLElement, options: ResponsiveTabsDomOptions ) {
129
+ if ( root.dataset.ecResponsiveTabsInitialized === '1' ) {
130
+ return;
131
+ }
132
+
133
+ root.dataset.ecResponsiveTabsInitialized = '1';
134
+
135
+ const hashPrefix = root.dataset.hashPrefix || options.hashPrefix || 'tab-';
136
+ const tabs = Array.from( root.querySelectorAll<HTMLElement>( '.ec-tabs__tab[data-tab-id]' ) );
137
+ const firstTabId = tabs[ 0 ]?.dataset.tabId || '';
138
+ const initialTabId = resolveHashTabId( root, hashPrefix ) || root.dataset.activeTab || firstTabId;
139
+
140
+ if ( ! initialTabId ) {
141
+ return;
142
+ }
143
+
144
+ root.dataset.activeTab = initialTabId;
145
+ setActiveDesktopTab( root, initialTabId );
146
+ renderDesktopPanel( root, initialTabId, options.onPanelRender );
147
+ renderAccordion( root, initialTabId, hashPrefix, options.onPanelRender );
148
+
149
+ tabs.forEach( ( tab ) => {
150
+ tab.addEventListener( 'click', () => {
151
+ const tabId = tab.dataset.tabId;
152
+ if ( ! tabId ) {
153
+ return;
154
+ }
155
+
156
+ root.dataset.activeTab = tabId;
157
+ window.location.hash = `${ hashPrefix }${ tabId }`;
158
+ setActiveDesktopTab( root, tabId );
159
+ renderDesktopPanel( root, tabId, options.onPanelRender );
160
+ renderAccordion( root, tabId, hashPrefix, options.onPanelRender );
161
+ } );
162
+ } );
163
+
164
+ window.addEventListener( 'hashchange', () => {
165
+ const tabId = resolveHashTabId( root, hashPrefix );
166
+ if ( ! tabId ) {
167
+ return;
168
+ }
169
+
170
+ root.dataset.activeTab = tabId;
171
+ setActiveDesktopTab( root, tabId );
172
+ renderDesktopPanel( root, tabId, options.onPanelRender );
173
+ renderAccordion( root, tabId, hashPrefix, options.onPanelRender );
174
+ } );
175
+ }
176
+
177
+ export function initResponsiveTabsDom( options: ResponsiveTabsDomOptions = {} ) {
178
+ if ( typeof document === 'undefined' ) {
179
+ return;
180
+ }
181
+
182
+ const selector = options.selector || DEFAULT_SELECTOR;
183
+ document.querySelectorAll<HTMLElement>( selector ).forEach( ( root ) => initResponsiveTabsRoot( root, options ) );
184
+ }