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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
53
|
+
// Wait for all tabs to register in the current cycle
|
|
104
54
|
queueMicrotask(() => {
|
|
105
|
-
|
|
106
|
-
if (isInitialized) return;
|
|
55
|
+
if (isInitialized || tabState.tabs.length === 0) return;
|
|
107
56
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
113
|
-
if (activeTab !== null
|
|
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
|
-
//
|
|
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
|
|
148
|
-
|
|
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
|
|
166
|
-
if (
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
button
|
|
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
|
|
332
|
+
// Set context - pass the reactive state object
|
|
226
333
|
setContext(tabContext, {
|
|
227
|
-
state
|
|
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={
|
|
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={
|
|
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 :
|
|
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 -
|
|
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
|
|
32
|
+
// Register this tab once on mount
|
|
34
33
|
onMount(() => {
|
|
35
34
|
ctx.register(_id, label, href, disabled);
|
|
36
35
|
});
|
|
37
36
|
|
|
38
|
-
//
|
|
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
|