@extrachill/components 0.4.22 → 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.22",
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
+ }
@@ -160,11 +160,6 @@
160
160
  box-sizing: border-box;
161
161
  }
162
162
 
163
- .ec-responsive-tabs__accordion {
164
- display: grid;
165
- gap: var(--spacing-md, 1rem);
166
- }
167
-
168
163
  .ec-responsive-tabs__item {
169
164
  border: 1px solid var(--border-color, #ddd);
170
165
  border-radius: var(--border-radius-md, 8px);
@@ -186,13 +181,11 @@
186
181
  gap: var(--spacing-sm, 0.5rem);
187
182
  padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
188
183
  border: 0;
189
- border-bottom: 1px solid var(--border-color, #ddd);
190
184
  background: var(--card-background, #f8f8f8);
191
185
  color: var(--text-color, #111);
192
186
  font-family: var(--font-family-body, inherit);
193
187
  font-size: var(--font-size-body, 1.125rem);
194
188
  font-weight: 600;
195
- line-height: 1.4;
196
189
  cursor: pointer;
197
190
  text-align: left;
198
191
  transition: background 0.15s ease, color 0.15s ease;
@@ -207,11 +200,6 @@
207
200
  color: var(--link-color, #0b5394);
208
201
  }
209
202
 
210
- .ec-responsive-tabs__trigger:hover {
211
- background: var(--background-color, #fff);
212
- color: var(--link-color-hover, var(--link-color, #0b5394));
213
- }
214
-
215
203
  .ec-responsive-tabs__trigger:focus-visible {
216
204
  outline: 2px solid var(--focus-border-color, #53940b);
217
205
  outline-offset: -2px;
@@ -219,7 +207,7 @@
219
207
  }
220
208
 
221
209
  .ec-responsive-tabs__panel {
222
- padding: var(--spacing-sm, 0.5rem);
210
+ padding: 0 var(--spacing-md, 1rem);
223
211
  background: var(--background-color, #fff);
224
212
  margin-bottom: 0;
225
213
  }