@classic-homes/theme-svelte 1.22.4 → 1.24.0

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.
@@ -3,6 +3,7 @@
3
3
  import { setContext } from 'svelte';
4
4
  import { cn } from '../utils.js';
5
5
  import type { Tab } from '../types/components.js';
6
+ import Select from './Select.svelte';
6
7
 
7
8
  interface Props {
8
9
  /** Array of tab definitions */
@@ -32,6 +33,12 @@
32
33
  children?: Snippet;
33
34
  /** Callback when active tab changes */
34
35
  onValueChange?: (value: string) => void;
36
+ /**
37
+ * What to do when the tabs are wider than their container.
38
+ * - `auto` (default): show a full-width Select of the tabs instead
39
+ * - `never`: keep the tab row and wrap it onto more lines
40
+ */
41
+ collapse?: 'auto' | 'never';
35
42
  }
36
43
 
37
44
  const uid = $props.id();
@@ -45,40 +52,41 @@
45
52
  class: className,
46
53
  children,
47
54
  onValueChange,
55
+ collapse = 'auto',
48
56
  }: Props = $props();
49
57
 
50
58
  // Store references to tab button elements for direct focus management
51
59
  let tabRefs: Record<string, HTMLButtonElement | undefined> = $state({});
52
60
 
53
- // Horizontal-scroll handling for tab lists wider than their container
61
+ // Collapse to a Select when the tab row is wider than the container. The
62
+ // row stays rendered (hidden) while collapsed, so it can still be measured
63
+ // and so TabPanel's aria-labelledby keeps pointing at a real tab element.
64
+ let root: HTMLDivElement | undefined = $state();
54
65
  let tabList: HTMLDivElement | undefined = $state();
55
- let canScrollLeft = $state(false);
56
- let canScrollRight = $state(false);
57
-
58
- function updateScrollShadows() {
59
- if (!tabList) return;
60
- canScrollLeft = tabList.scrollLeft > 1;
61
- canScrollRight = tabList.scrollLeft + tabList.clientWidth < tabList.scrollWidth - 1;
62
- }
63
-
64
- // Scroll only the tab list (never the page) so deep-linked tabs are visible
65
- function scrollTabIntoView(el: HTMLButtonElement) {
66
- if (!tabList) return;
67
- const listRect = tabList.getBoundingClientRect();
68
- const rect = el.getBoundingClientRect();
69
- if (rect.left < listRect.left) {
70
- tabList.scrollLeft += rect.left - listRect.left - 8;
71
- } else if (rect.right > listRect.right) {
72
- tabList.scrollLeft += rect.right - listRect.right + 8;
73
- }
74
- }
66
+ let collapsed = $state(false);
75
67
 
76
68
  $effect(() => {
77
- const el = tabRefs[value];
78
- if (el) scrollTabIntoView(el);
79
- updateScrollShadows();
69
+ if (collapse !== 'auto' || !root || !tabList) {
70
+ collapsed = false;
71
+ return;
72
+ }
73
+ const rootEl = root;
74
+ const listEl = tabList;
75
+ const measure = () => {
76
+ collapsed = listEl.scrollWidth > rootEl.clientWidth;
77
+ };
78
+ measure();
79
+ // Fires on container resize and when tab labels change the row's width
80
+ const observer = new ResizeObserver(measure);
81
+ observer.observe(rootEl);
82
+ observer.observe(listEl);
83
+ return () => observer.disconnect();
80
84
  });
81
85
 
86
+ const selectOptions = $derived(
87
+ tabs.map((t) => ({ value: t.id, label: t.label, disabled: t.disabled }))
88
+ );
89
+
82
90
  // Context for TabPanel children. Getters read the props directly, so the
83
91
  // active panel is right on the first render and during SSR (an $effect
84
92
  // would only fill it in after hydration).
@@ -133,27 +141,25 @@
133
141
  }
134
142
  </script>
135
143
 
136
- <div class={cn('w-full', className)}>
137
- <!-- Tab List -->
138
- <div class="relative inline-flex max-w-full">
139
- {#if canScrollLeft}
140
- <div
141
- class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 rounded-l-md bg-gradient-to-r from-muted to-transparent"
142
- aria-hidden="true"
143
- ></div>
144
- {/if}
145
- {#if canScrollRight}
146
- <div
147
- class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 rounded-r-md bg-gradient-to-l from-muted to-transparent"
148
- aria-hidden="true"
149
- ></div>
150
- {/if}
144
+ <div bind:this={root} class={cn('w-full min-w-0', className)}>
145
+ {#if collapsed}
146
+ <Select {value} options={selectOptions} onValueChange={selectTab} aria-label={label} />
147
+ {/if}
148
+
149
+ <!-- Tab List (kept for measuring while collapsed; zero height and inert) -->
150
+ <div
151
+ class={cn(collapsed && 'invisible h-0 overflow-hidden')}
152
+ inert={collapsed}
153
+ aria-hidden={collapsed ? 'true' : undefined}
154
+ >
151
155
  <div
152
156
  bind:this={tabList}
153
- onscroll={updateScrollShadows}
154
157
  role="tablist"
155
158
  aria-label={label}
156
- class="inline-flex h-10 max-w-full items-center justify-center overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
159
+ class={cn(
160
+ 'inline-flex items-center rounded-md bg-muted p-1 text-muted-foreground',
161
+ collapse === 'never' ? 'max-w-full flex-wrap' : 'h-10 w-max'
162
+ )}
157
163
  >
158
164
  {#each tabs as tab, index (tab.id)}
159
165
  <button
@@ -28,6 +28,12 @@ interface Props {
28
28
  children?: Snippet;
29
29
  /** Callback when active tab changes */
30
30
  onValueChange?: (value: string) => void;
31
+ /**
32
+ * What to do when the tabs are wider than their container.
33
+ * - `auto` (default): show a full-width Select of the tabs instead
34
+ * - `never`: keep the tab row and wrap it onto more lines
35
+ */
36
+ collapse?: 'auto' | 'never';
31
37
  }
32
38
  declare const Tabs: import("svelte").Component<Props, {}, "value">;
33
39
  type Tabs = ReturnType<typeof Tabs>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.22.4",
3
+ "version": "1.24.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",