@extrachill/components 0.4.23 → 0.4.25

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.25] - 2026-03-26
4
+
5
+ ### Changed
6
+ - move shared tab layout into components
7
+
8
+ ## [0.4.23] - 2026-03-25
9
+
10
+ ### Changed
11
+ - Refine responsive accordion spacing and interaction styling
12
+
13
+ ## [0.4.22] - 2026-03-25
14
+
15
+ ### Changed
16
+ - Preserve local accordion collapse state on mobile
17
+
18
+ ## [0.4.21] - 2026-03-25
19
+
20
+ ### Changed
21
+ - Allow responsive accordion items to collapse on second tap
22
+
23
+ ## [0.4.20] - 2026-03-25
24
+
25
+ ### Changed
26
+ - Keep responsive tabs root structural only
27
+
28
+ ## [0.4.19] - 2026-03-25
29
+
30
+ ### Changed
31
+ - Limit responsive tabs accordion mode to phone breakpoint
32
+
33
+ ## [0.4.18] - 2026-03-25
34
+
35
+ ### Changed
36
+ - Remove mobile color overrides from shared surfaces
37
+
38
+ ## [0.4.17] - 2026-03-25
39
+
40
+ ### Changed
41
+ - Fix responsive tabs mobile interaction and shared hover styling
42
+
3
43
  ## [0.4.16] - 2026-03-25
4
44
 
5
45
  ### Changed
@@ -7,11 +7,15 @@ export interface ResponsiveTabsProps {
7
7
  renderPanel: (id: string) => ReactNode;
8
8
  className?: string;
9
9
  classPrefix?: string;
10
+ innerClassName?: string;
11
+ innerMaxWidth?: 'none' | 'narrow' | 'wide';
10
12
  tabsClassName?: string;
11
13
  tabsClassPrefix?: string;
12
14
  mobileBreakpoint?: number;
13
15
  accordionClassName?: string;
14
16
  showDesktopTabs?: boolean;
17
+ syncWithHash?: boolean;
18
+ hashPrefix?: string;
15
19
  }
16
- export declare function ResponsiveTabs({ tabs, active, onChange, renderPanel, className, classPrefix, tabsClassName, tabsClassPrefix, mobileBreakpoint, accordionClassName, showDesktopTabs, }: ResponsiveTabsProps): import("react/jsx-runtime").JSX.Element | null;
20
+ export declare function ResponsiveTabs({ tabs, active, onChange, renderPanel, className, classPrefix, innerClassName, innerMaxWidth, tabsClassName, tabsClassPrefix, mobileBreakpoint, accordionClassName, showDesktopTabs, syncWithHash, hashPrefix, }: ResponsiveTabsProps): import("react/jsx-runtime").JSX.Element | null;
17
21
  //# 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,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC3C,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,cAAmB,EACnB,aAAsB,EACtB,aAAkB,EAClB,eAA2B,EAC3B,gBAAsB,EACtB,kBAAuB,EACvB,eAAsB,EACtB,YAAoB,EACpB,UAAmB,GACnB,EAAE,mBAAmB,kDAkJrB"}
@@ -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', innerClassName = '', innerMaxWidth = 'none', 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,9 +43,26 @@ 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`,
65
+ innerMaxWidth !== 'none' ? `${classPrefix}--inner-${innerMaxWidth}` : '',
31
66
  className,
32
67
  ]
33
68
  .filter(Boolean)
@@ -36,17 +71,17 @@ export function ResponsiveTabs({ tabs, active, onChange, renderPanel, className
36
71
  return null;
37
72
  }
38
73
  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) })] }));
74
+ return (_jsx("div", { className: rootClass, children: _jsxs("div", { className: [`${classPrefix}__inner`, innerClassName].filter(Boolean).join(' '), children: [showDesktopTabs && (_jsx(Tabs, { tabs: tabs, active: active, onChange: handleChange, className: tabsClassName, classPrefix: tabsClassPrefix })), _jsx("div", { className: `${classPrefix}__desktop-panel`, children: renderPanel(active) })] }) }));
40
75
  }
41
- return (_jsx("div", { className: rootClass, children: _jsx("div", { className: [`${classPrefix}__accordion`, accordionClassName].filter(Boolean).join(' '), children: tabs.map((tab) => {
42
- const isActive = tab.id === mobileActive;
43
- return (_jsxs("div", { className: `${classPrefix}__item${isActive ? ' is-active' : ''}`, children: [_jsxs("button", { type: "button", className: `${classPrefix}__trigger${isActive ? ' is-active' : ''}`, "aria-expanded": isActive, onClick: () => {
44
- if (isActive) {
45
- setMobileActive(null);
46
- return;
47
- }
48
- setMobileActive(tab.id);
49
- onChange(tab.id);
50
- }, 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
- }) }) }));
76
+ return (_jsx("div", { className: rootClass, children: _jsx("div", { className: [`${classPrefix}__inner`, innerClassName].filter(Boolean).join(' '), children: _jsx("div", { className: [`${classPrefix}__accordion`, accordionClassName].filter(Boolean).join(' '), children: tabs.map((tab) => {
77
+ const isActive = tab.id === mobileActive;
78
+ return (_jsxs("div", { className: `${classPrefix}__item${isActive ? ' is-active' : ''}`, children: [_jsxs("button", { type: "button", className: `${classPrefix}__trigger${isActive ? ' is-active' : ''}`, "aria-expanded": isActive, onClick: () => {
79
+ if (isActive) {
80
+ setMobileActive(null);
81
+ return;
82
+ }
83
+ setMobileActive(tab.id);
84
+ handleChange(tab.id);
85
+ }, 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));
86
+ }) }) }) }));
52
87
  }
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.25",
4
4
  "description": "Shared React components for the Extra Chill Platform.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,11 +9,15 @@ export interface ResponsiveTabsProps {
9
9
  renderPanel: ( id: string ) => ReactNode;
10
10
  className?: string;
11
11
  classPrefix?: string;
12
+ innerClassName?: string;
13
+ innerMaxWidth?: 'none' | 'narrow' | 'wide';
12
14
  tabsClassName?: string;
13
15
  tabsClassPrefix?: string;
14
16
  mobileBreakpoint?: number;
15
17
  accordionClassName?: string;
16
18
  showDesktopTabs?: boolean;
19
+ syncWithHash?: boolean;
20
+ hashPrefix?: string;
17
21
  }
18
22
 
19
23
  export function ResponsiveTabs( {
@@ -23,11 +27,15 @@ export function ResponsiveTabs( {
23
27
  renderPanel,
24
28
  className = '',
25
29
  classPrefix = 'ec-responsive-tabs',
30
+ innerClassName = '',
31
+ innerMaxWidth = 'none',
26
32
  tabsClassName = '',
27
33
  tabsClassPrefix = 'ec-tabs',
28
34
  mobileBreakpoint = 480,
29
35
  accordionClassName = '',
30
36
  showDesktopTabs = true,
37
+ syncWithHash = false,
38
+ hashPrefix = 'tab-',
31
39
  }: ResponsiveTabsProps ) {
32
40
  const [ isMobile, setIsMobile ] = useState( () => {
33
41
  if ( typeof window === 'undefined' ) {
@@ -38,6 +46,29 @@ export function ResponsiveTabs( {
38
46
  } );
39
47
  const [ mobileActive, setMobileActive ] = useState<string | null>( active );
40
48
 
49
+ const resolveTabIdFromHash = ( hash: string ) => {
50
+ const normalized = hash.replace( /^#/, '' );
51
+ if ( ! normalized.startsWith( hashPrefix ) ) {
52
+ return null;
53
+ }
54
+
55
+ const id = normalized.slice( hashPrefix.length );
56
+ return tabs.some( ( tab ) => tab.id === id ) ? id : null;
57
+ };
58
+
59
+ const updateHash = ( id: string ) => {
60
+ if ( ! syncWithHash || typeof window === 'undefined' ) {
61
+ return;
62
+ }
63
+
64
+ window.location.hash = `${ hashPrefix }${ id }`;
65
+ };
66
+
67
+ const handleChange = ( id: string ) => {
68
+ onChange( id );
69
+ updateHash( id );
70
+ };
71
+
41
72
  useEffect( () => {
42
73
  if ( typeof window === 'undefined' ) {
43
74
  return undefined;
@@ -59,10 +90,32 @@ export function ResponsiveTabs( {
59
90
  }
60
91
  }, [ active, isMobile ] );
61
92
 
93
+ useEffect( () => {
94
+ if ( ! syncWithHash || typeof window === 'undefined' || tabs.length === 0 ) {
95
+ return undefined;
96
+ }
97
+
98
+ const syncFromHash = () => {
99
+ const id = resolveTabIdFromHash( window.location.hash );
100
+ if ( ! id ) {
101
+ return;
102
+ }
103
+
104
+ onChange( id );
105
+ setMobileActive( id );
106
+ };
107
+
108
+ syncFromHash();
109
+ window.addEventListener( 'hashchange', syncFromHash );
110
+
111
+ return () => window.removeEventListener( 'hashchange', syncFromHash );
112
+ }, [ hashPrefix, onChange, syncWithHash, tabs ] );
113
+
62
114
  const rootClass = useMemo(
63
115
  () => [
64
116
  classPrefix,
65
117
  isMobile ? `${ classPrefix }--mobile` : `${ classPrefix }--desktop`,
118
+ innerMaxWidth !== 'none' ? `${ classPrefix }--inner-${ innerMaxWidth }` : '',
66
119
  className,
67
120
  ]
68
121
  .filter( Boolean )
@@ -77,23 +130,26 @@ export function ResponsiveTabs( {
77
130
  if ( ! isMobile ) {
78
131
  return (
79
132
  <div className={ rootClass }>
80
- { showDesktopTabs && (
81
- <Tabs
82
- tabs={ tabs }
83
- active={ active }
84
- onChange={ onChange }
85
- className={ tabsClassName }
86
- classPrefix={ tabsClassPrefix }
87
- />
88
- ) }
89
- <div className={ `${ classPrefix }__desktop-panel` }>{ renderPanel( active ) }</div>
133
+ <div className={ [ `${ classPrefix }__inner`, innerClassName ].filter( Boolean ).join( ' ' ) }>
134
+ { showDesktopTabs && (
135
+ <Tabs
136
+ tabs={ tabs }
137
+ active={ active }
138
+ onChange={ handleChange }
139
+ className={ tabsClassName }
140
+ classPrefix={ tabsClassPrefix }
141
+ />
142
+ ) }
143
+ <div className={ `${ classPrefix }__desktop-panel` }>{ renderPanel( active ) }</div>
144
+ </div>
90
145
  </div>
91
146
  );
92
147
  }
93
148
 
94
149
  return (
95
150
  <div className={ rootClass }>
96
- <div className={ [ `${ classPrefix }__accordion`, accordionClassName ].filter( Boolean ).join( ' ' ) }>
151
+ <div className={ [ `${ classPrefix }__inner`, innerClassName ].filter( Boolean ).join( ' ' ) }>
152
+ <div className={ [ `${ classPrefix }__accordion`, accordionClassName ].filter( Boolean ).join( ' ' ) }>
97
153
  { tabs.map( ( tab ) => {
98
154
  const isActive = tab.id === mobileActive;
99
155
 
@@ -104,15 +160,15 @@ export function ResponsiveTabs( {
104
160
  className={ `${ classPrefix }__trigger${ isActive ? ' is-active' : '' }` }
105
161
  aria-expanded={ isActive }
106
162
  onClick={ () => {
107
- if ( isActive ) {
108
- setMobileActive( null );
109
- return;
110
- }
111
-
112
- setMobileActive( tab.id );
113
- onChange( tab.id );
114
- } }
115
- >
163
+ if ( isActive ) {
164
+ setMobileActive( null );
165
+ return;
166
+ }
167
+
168
+ setMobileActive( tab.id );
169
+ handleChange( tab.id );
170
+ } }
171
+ >
116
172
  <span>{ tab.label }</span>
117
173
  <span className={ `${ classPrefix }__arrow` } aria-hidden="true">
118
174
  { isActive ? '▲' : '▼' }
@@ -122,6 +178,7 @@ export function ResponsiveTabs( {
122
178
  </div>
123
179
  );
124
180
  } ) }
181
+ </div>
125
182
  </div>
126
183
  </div>
127
184
  );
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
+ }
@@ -149,6 +149,25 @@
149
149
  gap: var(--spacing-md, 1rem);
150
150
  }
151
151
 
152
+ .ec-responsive-tabs__inner {
153
+ width: 100%;
154
+ min-width: 0;
155
+ }
156
+
157
+ .ec-responsive-tabs--inner-narrow .ec-responsive-tabs__inner {
158
+ max-width: 700px;
159
+ }
160
+
161
+ .ec-responsive-tabs--inner-wide .ec-responsive-tabs__inner {
162
+ max-width: 1080px;
163
+ }
164
+
165
+ .ec-responsive-tabs .ec-tabs__tabs {
166
+ padding-bottom: calc(var(--spacing-md, 1rem) + var(--spacing-sm, 0.5rem));
167
+ margin-bottom: var(--spacing-sm, 0.5rem);
168
+ border-bottom: 1px solid var(--border-color, #ddd);
169
+ }
170
+
152
171
  .ec-responsive-tabs__desktop-panel {
153
172
  display: grid;
154
173
  background: var(--background-color, #fff);
@@ -230,6 +249,12 @@
230
249
  }
231
250
 
232
251
  @media (max-width: 480px) {
252
+ .ec-responsive-tabs__inner {
253
+ max-width: none;
254
+ padding-left: var(--spacing-md, 1rem);
255
+ padding-right: var(--spacing-md, 1rem);
256
+ }
257
+
233
258
  .ec-responsive-tabs .ec-tabs__tabs,
234
259
  .ec-responsive-tabs__desktop-panel {
235
260
  display: none;