sveltacular 1.0.13 → 1.0.14

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.
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { setContext } from 'svelte';
3
+ import { setContext, onMount, onDestroy } from 'svelte';
4
4
  import { tabContext, type TabDefinition, type TabVariant } from './tab-context.js';
5
5
  import { getAnchor, navigateToAnchor, uniqueId } from '../../index.js';
6
+ import { browser } from '$app/environment';
6
7
 
7
8
  let {
8
9
  variant = 'traditional' as TabVariant,
@@ -19,14 +20,14 @@
19
20
  // Generate unique group ID to scope tab IDs and prevent conflicts between multiple tab groups
20
21
  const groupId = uniqueId();
21
22
 
22
- // Use a SINGLE $state object - this is key for reactivity!
23
+ // Tab registry - stores all registered tabs
23
24
  const tabState = $state({
24
- tabs: [] as TabDefinition[],
25
- active: null as string | null
25
+ tabs: [] as TabDefinition[]
26
26
  });
27
27
 
28
28
  let registrationIndex = 0;
29
- let isInitialized = $state(false);
29
+ let isInitialized = false;
30
+ let lastHashSet = $state<string | null>(null); // Track the last hash we set to prevent loops
30
31
 
31
32
  // Cache for tab lookups to improve performance
32
33
  const tabCache = new Map<string, TabDefinition>();
@@ -44,82 +45,34 @@
44
45
  tabCache.set(id, tab);
45
46
  };
46
47
 
47
- // Sync from activeTab (parent) to tabState.active (internal)
48
- // This allows parent to control which tab is active
49
- // Using untrack to prevent reactive dependencies that could cause loops
50
- $effect(() => {
51
- const currentActiveTab = activeTab;
52
- const currentActiveState = tabState.active;
53
-
54
- // Check if current active tab becomes disabled - clear it if so
55
- if (currentActiveState !== null) {
56
- const activeTabDef = tabCache.get(currentActiveState);
57
- if (activeTabDef?.disabled) {
58
- // Active tab was disabled, find first enabled tab or clear
59
- const enabledTabs = tabState.tabs.filter((tab) => !tab.disabled);
60
- if (enabledTabs.length > 0) {
61
- const firstEnabled = enabledTabs.sort((a, b) => a.index - b.index)[0];
62
- tabState.active = firstEnabled.id;
63
- activeTab = firstEnabled.id;
64
- } else {
65
- tabState.active = null;
66
- activeTab = null;
67
- }
68
- return;
69
- }
70
- }
71
-
72
- // Skip if values are already in sync
73
- if (currentActiveTab === currentActiveState) return;
74
-
75
- // If activeTab is explicitly set (including null), sync it to internal state
76
- if (currentActiveTab !== undefined) {
77
- if (currentActiveTab === null) {
78
- // Parent wants to clear active tab
79
- if (currentActiveState !== null) {
80
- tabState.active = null;
81
- }
82
- } else {
83
- // Only update if the tab exists and is not disabled
84
- const tab = tabCache.get(currentActiveTab);
85
- if (tab) {
86
- if (!tab.disabled && currentActiveState !== currentActiveTab) {
87
- tabState.active = currentActiveTab;
88
- } else if (tab.disabled) {
89
- // If parent tries to activate a disabled tab, revert activeTab
90
- activeTab = currentActiveState;
91
- }
92
- }
93
- // If tabs haven't registered yet, initialization will handle it
94
- }
95
- }
96
- });
97
-
98
- // Initialize active tab - only run once when tabs are available
48
+ // Initialize active tab once when tabs are available
99
49
  $effect(() => {
100
50
  // Skip if already initialized or no tabs yet
101
51
  if (isInitialized || tabState.tabs.length === 0) return;
102
52
 
103
- // Use microtask to ensure all synchronous tab registrations in this cycle complete
53
+ // Wait for all tabs to register in the current cycle
104
54
  queueMicrotask(() => {
105
- // Double-check we haven't initialized in the meantime
106
- if (isInitialized) return;
55
+ if (isInitialized || tabState.tabs.length === 0) return;
107
56
 
108
- // Get current tabs state at initialization time
109
- const currentTabs = tabState.tabs;
110
- if (currentTabs.length === 0) return;
57
+ const enabledTabs = tabState.tabs
58
+ .filter((tab) => !tab.disabled)
59
+ .sort((a, b) => a.index - b.index);
60
+
61
+ if (enabledTabs.length === 0) {
62
+ isInitialized = true;
63
+ return;
64
+ }
111
65
 
112
- // If activeTab is already set by parent, use it (after verifying it's valid and not disabled)
113
- if (activeTab !== null && activeTab !== undefined) {
66
+ // If activeTab is already set and valid, use it
67
+ if (activeTab !== null) {
114
68
  const tab = tabCache.get(activeTab);
115
69
  if (tab && !tab.disabled) {
116
- tabState.active = activeTab;
117
70
  isInitialized = true;
118
71
  return;
119
72
  }
120
73
  }
121
74
 
122
- // Check for anchor in URL first (but only if it matches a tab in THIS group)
75
+ // Priority 1: Check URL anchor
123
76
  const anchor = getAnchor();
124
77
  if (anchor) {
125
78
  // Check if anchor starts with our group ID prefix
@@ -128,7 +81,6 @@
128
81
  const tabId = anchor.slice(groupPrefix.length);
129
82
  const tab = tabCache.get(tabId);
130
83
  if (tab && !tab.disabled) {
131
- tabState.active = tabId;
132
84
  activeTab = tabId;
133
85
  isInitialized = true;
134
86
  return;
@@ -137,52 +89,207 @@
137
89
  // Also check for direct tab ID match (for backward compatibility)
138
90
  const tab = tabCache.get(anchor);
139
91
  if (tab && !tab.disabled) {
140
- tabState.active = anchor;
141
92
  activeTab = anchor;
142
93
  isInitialized = true;
143
94
  return;
144
95
  }
145
96
  }
146
97
 
147
- // Default to first enabled tab (by registration index, which should be the first in DOM order)
148
- // Sort by index to get the first registered tab (lowest index = first)
149
- const sortedTabs = [...currentTabs]
150
- .filter((tab) => !tab.disabled)
151
- .sort((a, b) => a.index - b.index);
152
- const firstTab = sortedTabs[0];
98
+ // Priority 2: Default to first enabled tab
99
+ const firstTab = enabledTabs[0];
153
100
  if (firstTab) {
154
- tabState.active = firstTab.id;
155
101
  activeTab = firstTab.id;
156
- isInitialized = true;
157
102
  }
103
+
104
+ isInitialized = true;
158
105
  });
159
106
  });
107
+
108
+ // Validate activeTab when it changes externally or when tabs change
109
+ $effect(() => {
110
+ if (!isInitialized) return;
111
+
112
+ // If activeTab is null, that's valid (no tab selected)
113
+ if (activeTab === null) return;
114
+
115
+ const tab = tabCache.get(activeTab);
116
+
117
+ // If tab doesn't exist or is disabled, find a replacement
118
+ if (!tab || tab.disabled) {
119
+ const enabledTabs = tabState.tabs
120
+ .filter((t) => !t.disabled)
121
+ .sort((a, b) => a.index - b.index);
122
+
123
+ if (enabledTabs.length > 0) {
124
+ activeTab = enabledTabs[0].id;
125
+ } else {
126
+ activeTab = null;
127
+ }
128
+ }
129
+ });
130
+
131
+ // Watch for disabled state changes - if current active tab becomes disabled, switch
132
+ $effect(() => {
133
+ if (!isInitialized || activeTab === null) return;
134
+
135
+ const tab = tabCache.get(activeTab);
136
+ if (tab?.disabled) {
137
+ const enabledTabs = tabState.tabs
138
+ .filter((t) => !t.disabled)
139
+ .sort((a, b) => a.index - b.index);
140
+
141
+ if (enabledTabs.length > 0) {
142
+ activeTab = enabledTabs[0].id;
143
+ } else {
144
+ activeTab = null;
145
+ }
146
+ }
147
+ });
148
+
160
149
  const selectTab = (id: string) => {
161
- // Use cache for faster lookup
162
150
  const tab = tabCache.get(id);
163
151
  if (!tab || tab.disabled) return;
164
152
 
165
- // Only update if different to avoid unnecessary re-renders
166
- if (tabState.active !== id) {
167
- tabState.active = id;
153
+ // Only update if different
154
+ if (activeTab !== id) {
168
155
  activeTab = id;
169
156
 
170
- // Only use anchor navigation if tab doesn't have href (href navigation is handled by Tab component)
171
- // Scope anchor to this group to avoid conflicts with multiple tab groups
172
- if (!tab.href) {
173
- navigateToAnchor(`${groupId}-${id}`);
157
+ // Only use anchor navigation if tab doesn't have href
158
+ if (!tab.href && browser) {
159
+ const targetWindow = window.top || window;
160
+ const hash = `#${groupId}-${id}`;
161
+ // Only update hash if it's different to avoid unnecessary updates
162
+ if (targetWindow.location.hash !== hash) {
163
+ lastHashSet = hash;
164
+ targetWindow.location.hash = hash;
165
+ // Clear the tracking after a brief delay to allow hashchange to fire
166
+ setTimeout(() => {
167
+ if (lastHashSet === hash) {
168
+ lastHashSet = null;
169
+ }
170
+ }, 100);
171
+ }
174
172
  }
175
173
 
176
174
  onChange?.(id);
177
175
 
178
- // Focus the selected tab (scoped to this group)
179
- const button = document.getElementById(`tab-${groupId}-${id}`);
180
- if (button) {
181
- button.focus();
176
+ // Focus the selected tab
177
+ queueMicrotask(() => {
178
+ const button = document.getElementById(`tab-${groupId}-${id}`);
179
+ if (button) {
180
+ button.focus();
181
+ }
182
+ });
183
+ }
184
+ };
185
+
186
+ // Sync activeTab with URL hash changes (including browser back/forward)
187
+ // Use empty string for no hash to ensure reactivity works correctly
188
+ let currentUrlHash = $state<string>('');
189
+
190
+ // Function to sync hash to activeTab
191
+ const syncHashToActiveTab = () => {
192
+ if (!browser || !isInitialized || tabState.tabs.length === 0) return;
193
+
194
+ const targetWindow = window.top || window;
195
+ const hash = targetWindow.location.hash;
196
+
197
+ // Update our reactive state (use empty string for no hash, not null)
198
+ currentUrlHash = hash || '';
199
+
200
+ // If this hash change was caused by us (selectTab), ignore it
201
+ if (lastHashSet !== null && hash === lastHashSet) {
202
+ return;
203
+ }
204
+
205
+ const anchor = hash && hash.length > 1 ? hash.slice(1) : null;
206
+
207
+ if (!anchor) {
208
+ // Hash was cleared - default to first enabled tab
209
+ const enabledTabs = tabState.tabs
210
+ .filter((t) => !t.disabled)
211
+ .sort((a, b) => a.index - b.index);
212
+
213
+ if (enabledTabs.length > 0) {
214
+ const firstTab = enabledTabs[0];
215
+ if (activeTab !== firstTab.id) {
216
+ activeTab = firstTab.id;
217
+ onChange?.(firstTab.id);
218
+ }
219
+ } else {
220
+ // No enabled tabs - clear active tab
221
+ if (activeTab !== null) {
222
+ activeTab = null;
223
+ onChange?.(null);
224
+ }
225
+ }
226
+ return;
227
+ }
228
+
229
+ // Check if anchor matches a tab in this group
230
+ const groupPrefix = `${groupId}-`;
231
+ let tabId: string | null = null;
232
+
233
+ if (anchor.startsWith(groupPrefix)) {
234
+ // Anchor is scoped to this group
235
+ tabId = anchor.slice(groupPrefix.length);
236
+ } else {
237
+ // Check for direct tab ID match (backward compatibility)
238
+ const tab = tabCache.get(anchor);
239
+ if (tab) {
240
+ tabId = anchor;
241
+ }
242
+ }
243
+
244
+ // If we found a matching tab and it's not already active, update it
245
+ if (tabId) {
246
+ const tab = tabCache.get(tabId);
247
+ if (tab && !tab.disabled && activeTab !== tabId) {
248
+ activeTab = tabId;
249
+ onChange?.(tabId);
182
250
  }
183
251
  }
184
252
  };
185
253
 
254
+ // Watch the reactive hash state
255
+ $effect(() => {
256
+ // This effect runs when currentUrlHash changes
257
+ // Access currentUrlHash to create reactive dependency
258
+ const _ = currentUrlHash;
259
+ if (isInitialized) {
260
+ syncHashToActiveTab();
261
+ }
262
+ });
263
+
264
+ // Set up hashchange listener and polling
265
+ onMount(() => {
266
+ if (!browser) return;
267
+
268
+ // Initialize current hash
269
+ const targetWindow = window.top || window;
270
+ currentUrlHash = targetWindow.location.hash;
271
+
272
+ // Function to check and update hash
273
+ const checkHash = () => {
274
+ const targetWindow = window.top || window;
275
+ const hash = targetWindow.location.hash || '';
276
+ if (hash !== currentUrlHash) {
277
+ currentUrlHash = hash;
278
+ }
279
+ };
280
+
281
+ // Listen for hashchange events
282
+ window.addEventListener('hashchange', checkHash);
283
+
284
+ // Also poll periodically as a fallback (in case hashchange doesn't fire)
285
+ const intervalId = setInterval(checkHash, 150);
286
+
287
+ onDestroy(() => {
288
+ clearInterval(intervalId);
289
+ window.removeEventListener('hashchange', checkHash);
290
+ });
291
+ });
292
+
186
293
  // Keyboard navigation handler
187
294
  const handleKeydown = (e: KeyboardEvent, currentId: string) => {
188
295
  const tabList = tabState.tabs.filter((tab) => !tab.disabled);
@@ -222,9 +329,14 @@
222
329
  }
223
330
  };
224
331
 
225
- // Set context - pass the reactive state object directly!
332
+ // Set context - pass the reactive state object
226
333
  setContext(tabContext, {
227
- state: tabState,
334
+ get state() {
335
+ return {
336
+ tabs: tabState.tabs,
337
+ active: activeTab
338
+ };
339
+ },
228
340
  get variant() {
229
341
  return variant;
230
342
  },
@@ -237,14 +349,14 @@
237
349
  <div class="tab-head">
238
350
  <div role="tablist">
239
351
  {#each tabState.tabs as tab}
240
- <li class={tabState.active == tab.id ? 'active' : 'inactive'} class:disabled={tab.disabled}>
352
+ <li class={activeTab == tab.id ? 'active' : 'inactive'} class:disabled={tab.disabled}>
241
353
  <button
242
354
  id="tab-{groupId}-{tab.id}"
243
355
  role="tab"
244
- aria-selected={tabState.active === tab.id}
356
+ aria-selected={activeTab === tab.id}
245
357
  aria-controls="tabpanel-{groupId}-{tab.id}"
246
358
  aria-disabled={tab.disabled || false}
247
- tabindex={tab.disabled ? -1 : tabState.active === tab.id ? 0 : -1}
359
+ tabindex={tab.disabled ? -1 : activeTab === tab.id ? 0 : -1}
248
360
  disabled={tab.disabled || false}
249
361
  onclick={() => selectTab(tab.id)}
250
362
  onkeydown={(e) => !tab.disabled && handleKeydown(e, tab.id)}
@@ -24,18 +24,17 @@
24
24
  const ctx = getContext<TabContext>(tabContext);
25
25
  const tabStyle = ctx.variant || 'traditional';
26
26
 
27
- // Generate ID once - explicitly capture initial prop values for stable tab identity
28
- // Using untrack() to indicate we intentionally want non-reactive initial values
27
+ // Generate ID once - capture initial prop values for stable tab identity
29
28
  const _id = untrack(
30
29
  () => id || label.trim().toLocaleLowerCase().replaceAll(' ', '_') || uniqueId()
31
30
  );
32
31
 
33
- // Register this tab once on mount (like wizard does)
32
+ // Register this tab once on mount
34
33
  onMount(() => {
35
34
  ctx.register(_id, label, href, disabled);
36
35
  });
37
36
 
38
- // Access the $state object's properties directly - THIS creates reactive dependencies!
37
+ // Reactively check if this tab is active
39
38
  const isActive = $derived(ctx.state.active === _id);
40
39
 
41
40
  // Handle activation side effects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltacular",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "A Svelte component library",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",