sveltacular 1.0.11 → 1.0.13
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/dist/forms/dimension-box/dimension-box.svelte +201 -0
- package/dist/forms/dimension-box/dimension-box.svelte.d.ts +18 -0
- package/dist/forms/index.d.ts +1 -0
- package/dist/forms/index.js +1 -0
- package/dist/forms/number-range-box/number-range-box.svelte +0 -1
- package/dist/forms/text-area/text-area.svelte +60 -17
- package/dist/forms/text-area/text-area.svelte.d.ts +1 -0
- package/dist/navigation/tabs/tab-context.d.ts +1 -2
- package/dist/navigation/tabs/tab-group.svelte +114 -32
- package/dist/navigation/tabs/tab-group.svelte.d.ts +2 -1
- package/dist/navigation/tabs/tab.svelte +3 -3
- package/dist/navigation/tabs/tab.svelte.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { roundToDecimals } from '../../helpers/round-to-decimals.js';
|
|
3
|
+
import { uniqueId } from '../../helpers/unique-id.js';
|
|
4
|
+
import FormField from '../form-field/form-field.svelte';
|
|
5
|
+
import type { FormFieldSizeOptions } from '../../types/form.js';
|
|
6
|
+
|
|
7
|
+
const baseId = uniqueId();
|
|
8
|
+
|
|
9
|
+
// Generate unique IDs for each dimension input
|
|
10
|
+
function getDimensionId(index: number) {
|
|
11
|
+
return `${baseId}-dimension-${index}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
dimensions = ['Width', 'Depth', 'Height'] as string[],
|
|
16
|
+
value = $bindable([] as (number | null)[]),
|
|
17
|
+
minAllowed = 0,
|
|
18
|
+
maxAllowed = 99999,
|
|
19
|
+
step = 1,
|
|
20
|
+
decimals = 0,
|
|
21
|
+
prefix = null as string | null,
|
|
22
|
+
suffix = null as string | null,
|
|
23
|
+
required = false,
|
|
24
|
+
size = 'full' as FormFieldSizeOptions,
|
|
25
|
+
label = undefined as string | undefined,
|
|
26
|
+
onChange = undefined as ((value: (number | null)[]) => void) | undefined
|
|
27
|
+
}: {
|
|
28
|
+
dimensions?: string[];
|
|
29
|
+
value?: (number | null)[];
|
|
30
|
+
minAllowed?: number;
|
|
31
|
+
maxAllowed?: number;
|
|
32
|
+
step?: number;
|
|
33
|
+
decimals?: number;
|
|
34
|
+
prefix?: string | null;
|
|
35
|
+
suffix?: string | null;
|
|
36
|
+
required?: boolean;
|
|
37
|
+
size?: FormFieldSizeOptions;
|
|
38
|
+
label?: string;
|
|
39
|
+
onChange?: ((value: (number | null)[]) => void) | undefined;
|
|
40
|
+
} = $props();
|
|
41
|
+
|
|
42
|
+
// Ensure value array matches dimensions array length
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (dimensions.length > 0 && value.length !== dimensions.length) {
|
|
45
|
+
const newValue = Array(dimensions.length)
|
|
46
|
+
.fill(null)
|
|
47
|
+
.map((_, i) => (i < value.length && value[i] !== undefined ? value[i] : null));
|
|
48
|
+
value = newValue;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const handleChange = (index: number) => {
|
|
53
|
+
const currentValue = value[index];
|
|
54
|
+
|
|
55
|
+
if (currentValue === null || currentValue === undefined) {
|
|
56
|
+
onChange?.(value);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Round to decimals
|
|
61
|
+
value[index] = roundToDecimals(currentValue, decimals);
|
|
62
|
+
|
|
63
|
+
// Ensure value is within allowed range
|
|
64
|
+
if (value[index]! < minAllowed) {
|
|
65
|
+
value[index] = minAllowed;
|
|
66
|
+
}
|
|
67
|
+
if (value[index]! > maxAllowed) {
|
|
68
|
+
value[index] = maxAllowed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onChange?.(value);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const onInput = (e: Event, index: number) => {
|
|
75
|
+
const input = e.target as HTMLInputElement;
|
|
76
|
+
const newValue = parseFloat(input.value);
|
|
77
|
+
if (isNaN(newValue)) {
|
|
78
|
+
value[index] = null;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
value[index] = newValue;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Don't allow certain characters to be typed into the input
|
|
85
|
+
const onKeyPress = (e: KeyboardEvent) => {
|
|
86
|
+
const isNumber = !isNaN(Number(e.key));
|
|
87
|
+
const isDecimal = e.key === '.';
|
|
88
|
+
const allowDecimals = decimals > 0;
|
|
89
|
+
const isAllowed =
|
|
90
|
+
isNumber ||
|
|
91
|
+
isDecimal ||
|
|
92
|
+
['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', '-'].includes(e.key);
|
|
93
|
+
if (!isAllowed) return e.preventDefault();
|
|
94
|
+
if (isDecimal && !allowDecimals) return e.preventDefault();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const getPlaceholder = (dimension: string) => {
|
|
98
|
+
return dimension;
|
|
99
|
+
};
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<FormField {size} {label} id={getDimensionId(0)} {required}>
|
|
103
|
+
<div class="dimension-inputs">
|
|
104
|
+
{#each dimensions as dimension, index}
|
|
105
|
+
<div class="input-group">
|
|
106
|
+
<div class="input">
|
|
107
|
+
{#if prefix}
|
|
108
|
+
<span class="prefix">{prefix}</span>
|
|
109
|
+
{/if}
|
|
110
|
+
<input
|
|
111
|
+
id={getDimensionId(index)}
|
|
112
|
+
type="number"
|
|
113
|
+
placeholder={getPlaceholder(dimension)}
|
|
114
|
+
min={minAllowed}
|
|
115
|
+
max={maxAllowed}
|
|
116
|
+
{step}
|
|
117
|
+
bind:value={value[index]}
|
|
118
|
+
onchange={() => handleChange(index)}
|
|
119
|
+
oninput={(e) => onInput(e, index)}
|
|
120
|
+
onkeypress={onKeyPress}
|
|
121
|
+
{required}
|
|
122
|
+
/>
|
|
123
|
+
{#if suffix}
|
|
124
|
+
<span class="suffix">{suffix}</span>
|
|
125
|
+
{/if}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
{#if index < dimensions.length - 1}
|
|
129
|
+
<span class="separator">×</span>
|
|
130
|
+
{/if}
|
|
131
|
+
{/each}
|
|
132
|
+
</div>
|
|
133
|
+
</FormField>
|
|
134
|
+
|
|
135
|
+
<style>.dimension-inputs {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: var(--spacing-md);
|
|
139
|
+
width: 100%;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.input-group {
|
|
143
|
+
flex: 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.separator {
|
|
147
|
+
font-size: var(--font-lg);
|
|
148
|
+
font-weight: 500;
|
|
149
|
+
color: var(--form-input-fg);
|
|
150
|
+
user-select: none;
|
|
151
|
+
flex-shrink: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.input {
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
justify-content: flex-start;
|
|
158
|
+
position: relative;
|
|
159
|
+
width: 100%;
|
|
160
|
+
height: 100%;
|
|
161
|
+
border-radius: var(--radius-md);
|
|
162
|
+
border: var(--border-thin) solid var(--form-input-border);
|
|
163
|
+
background-color: var(--form-input-bg);
|
|
164
|
+
color: var(--form-input-fg);
|
|
165
|
+
font-size: var(--font-md);
|
|
166
|
+
font-weight: 500;
|
|
167
|
+
line-height: 2rem;
|
|
168
|
+
transition: background-color var(--transition-base) var(--ease-in-out), border-color var(--transition-base) var(--ease-in-out), color var(--transition-base) var(--ease-in-out), fill var(--transition-base) var(--ease-in-out), stroke var(--transition-base) var(--ease-in-out);
|
|
169
|
+
user-select: none;
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
}
|
|
172
|
+
.input input {
|
|
173
|
+
background-color: transparent;
|
|
174
|
+
border: none;
|
|
175
|
+
line-height: 2rem;
|
|
176
|
+
font-size: var(--font-md);
|
|
177
|
+
width: 100%;
|
|
178
|
+
flex-grow: 1;
|
|
179
|
+
padding-left: var(--spacing-base);
|
|
180
|
+
}
|
|
181
|
+
.input input:focus {
|
|
182
|
+
outline: none;
|
|
183
|
+
}
|
|
184
|
+
.input input::placeholder {
|
|
185
|
+
color: var(--form-input-placeholder);
|
|
186
|
+
}
|
|
187
|
+
.input .prefix,
|
|
188
|
+
.input .suffix {
|
|
189
|
+
font-size: var(--font-md);
|
|
190
|
+
line-height: 2rem;
|
|
191
|
+
padding-left: var(--spacing-base);
|
|
192
|
+
padding-right: var(--spacing-base);
|
|
193
|
+
background-color: var(--form-input-accent-bg);
|
|
194
|
+
color: var(--form-input-accent-fg);
|
|
195
|
+
}
|
|
196
|
+
.input .prefix {
|
|
197
|
+
border-right: var(--border-thin) solid var(--form-input-border);
|
|
198
|
+
}
|
|
199
|
+
.input .suffix {
|
|
200
|
+
border-left: var(--border-thin) solid var(--form-input-border);
|
|
201
|
+
}</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FormFieldSizeOptions } from '../../types/form.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
dimensions?: string[];
|
|
4
|
+
value?: (number | null)[];
|
|
5
|
+
minAllowed?: number;
|
|
6
|
+
maxAllowed?: number;
|
|
7
|
+
step?: number;
|
|
8
|
+
decimals?: number;
|
|
9
|
+
prefix?: string | null;
|
|
10
|
+
suffix?: string | null;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
size?: FormFieldSizeOptions;
|
|
13
|
+
label?: string;
|
|
14
|
+
onChange?: ((value: (number | null)[]) => void) | undefined;
|
|
15
|
+
};
|
|
16
|
+
declare const DimensionBox: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
17
|
+
type DimensionBox = ReturnType<typeof DimensionBox>;
|
|
18
|
+
export default DimensionBox;
|
package/dist/forms/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as Button } from './button/button.svelte';
|
|
2
2
|
export { default as BoolBox } from './bool-box/bool-box.svelte';
|
|
3
3
|
export { default as DateBox } from './date-box/date-box.svelte';
|
|
4
|
+
export { default as DimensionBox } from './dimension-box/dimension-box.svelte';
|
|
4
5
|
export { default as FileArea } from './file-area/file-area.svelte';
|
|
5
6
|
export { default as FileBox } from './file-box/file-box.svelte';
|
|
6
7
|
export { default as InfoBox } from './info-box/info-box.svelte';
|
package/dist/forms/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { default as Button } from './button/button.svelte';
|
|
3
3
|
export { default as BoolBox } from './bool-box/bool-box.svelte';
|
|
4
4
|
export { default as DateBox } from './date-box/date-box.svelte';
|
|
5
|
+
export { default as DimensionBox } from './dimension-box/dimension-box.svelte';
|
|
5
6
|
export { default as FileArea } from './file-area/file-area.svelte';
|
|
6
7
|
export { default as FileBox } from './file-box/file-box.svelte';
|
|
7
8
|
export { default as InfoBox } from './info-box/info-box.svelte';
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
helperText = undefined,
|
|
22
22
|
feedback = undefined,
|
|
23
23
|
isLoading = false,
|
|
24
|
+
showCharacterCount = false,
|
|
24
25
|
maxlength = undefined,
|
|
25
26
|
minlength = undefined,
|
|
26
27
|
pattern = undefined
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
helperText?: string;
|
|
40
41
|
feedback?: FormFieldFeedback;
|
|
41
42
|
isLoading?: boolean;
|
|
43
|
+
showCharacterCount?: boolean;
|
|
42
44
|
maxlength?: number | undefined;
|
|
43
45
|
minlength?: number | undefined;
|
|
44
46
|
pattern?: string | undefined;
|
|
@@ -46,6 +48,16 @@
|
|
|
46
48
|
|
|
47
49
|
let textareaElement: HTMLTextAreaElement | null = $state(null);
|
|
48
50
|
|
|
51
|
+
// Character count functionality
|
|
52
|
+
let characterCount = $derived((value || '').length);
|
|
53
|
+
let characterLimitClass = $derived(
|
|
54
|
+
maxlength && characterCount > maxlength * 0.9
|
|
55
|
+
? characterCount >= maxlength
|
|
56
|
+
? 'at-limit'
|
|
57
|
+
: 'near-limit'
|
|
58
|
+
: ''
|
|
59
|
+
);
|
|
60
|
+
|
|
49
61
|
// Auto-resize functionality
|
|
50
62
|
const handleAutoResize = () => {
|
|
51
63
|
if (!autoResize || !textareaElement) return;
|
|
@@ -86,25 +98,37 @@
|
|
|
86
98
|
</script>
|
|
87
99
|
|
|
88
100
|
<FormField {size} {label} {id} {required} {disabled} {helperText} {feedback}>
|
|
89
|
-
<textarea
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
<div class="textarea-container">
|
|
102
|
+
<textarea
|
|
103
|
+
wrap="soft"
|
|
104
|
+
{id}
|
|
105
|
+
{placeholder}
|
|
106
|
+
rows={autoResize ? minRows : rows}
|
|
107
|
+
bind:value
|
|
108
|
+
bind:this={textareaElement}
|
|
109
|
+
{required}
|
|
110
|
+
{disabled}
|
|
111
|
+
{readonly}
|
|
112
|
+
{maxlength}
|
|
113
|
+
{minlength}
|
|
114
|
+
aria-busy={isLoading}
|
|
115
|
+
data-auto-resize={autoResize}
|
|
116
|
+
oninput={handleAutoResize}
|
|
117
|
+
></textarea>
|
|
118
|
+
{#if showCharacterCount && maxlength}
|
|
119
|
+
<div class="character-count {characterLimitClass}">
|
|
120
|
+
{characterCount} / {maxlength}
|
|
121
|
+
</div>
|
|
122
|
+
{/if}
|
|
123
|
+
</div>
|
|
105
124
|
</FormField>
|
|
106
125
|
|
|
107
|
-
<style
|
|
126
|
+
<style>.textarea-container {
|
|
127
|
+
position: relative;
|
|
128
|
+
width: 100%;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
textarea {
|
|
108
132
|
width: 100%;
|
|
109
133
|
height: auto;
|
|
110
134
|
padding: 0.5rem 1rem;
|
|
@@ -128,4 +152,23 @@ textarea::placeholder {
|
|
|
128
152
|
textarea[data-auto-resize=true] {
|
|
129
153
|
resize: none;
|
|
130
154
|
overflow-y: hidden;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.character-count {
|
|
158
|
+
font-size: var(--font-sm);
|
|
159
|
+
line-height: 1.25rem;
|
|
160
|
+
padding: var(--spacing-xs);
|
|
161
|
+
text-align: right;
|
|
162
|
+
color: var(--body-fg);
|
|
163
|
+
position: absolute;
|
|
164
|
+
right: 0;
|
|
165
|
+
bottom: 0;
|
|
166
|
+
}
|
|
167
|
+
.character-count.near-limit {
|
|
168
|
+
color: var(--warning, #ffc107);
|
|
169
|
+
font-weight: 500;
|
|
170
|
+
}
|
|
171
|
+
.character-count.at-limit {
|
|
172
|
+
color: var(--danger, #dc3545);
|
|
173
|
+
font-weight: 600;
|
|
131
174
|
}</style>
|
|
@@ -2,7 +2,6 @@ export type TabVariant = 'traditional' | 'underline' | 'outline' | 'overline' |
|
|
|
2
2
|
export type TabDefinition = {
|
|
3
3
|
id: string;
|
|
4
4
|
label: string;
|
|
5
|
-
defaultActive: boolean;
|
|
6
5
|
index: number;
|
|
7
6
|
disabled?: boolean;
|
|
8
7
|
href?: string;
|
|
@@ -14,6 +13,6 @@ export interface TabContext {
|
|
|
14
13
|
};
|
|
15
14
|
variant: TabVariant;
|
|
16
15
|
groupId: string;
|
|
17
|
-
register: (id: string, label: string,
|
|
16
|
+
register: (id: string, label: string, href?: string, disabled?: boolean) => void;
|
|
18
17
|
}
|
|
19
18
|
export declare const tabContext = "tabContext";
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
let {
|
|
8
8
|
variant = 'traditional' as TabVariant,
|
|
9
9
|
onChange = undefined,
|
|
10
|
+
activeTab = $bindable(null as string | null),
|
|
10
11
|
children
|
|
11
12
|
}: {
|
|
12
13
|
variant?: TabVariant;
|
|
13
14
|
onChange?: ((id: string | null) => void) | undefined;
|
|
15
|
+
activeTab?: string | null;
|
|
14
16
|
children: Snippet;
|
|
15
17
|
} = $props();
|
|
16
18
|
|
|
@@ -26,11 +28,73 @@
|
|
|
26
28
|
let registrationIndex = 0;
|
|
27
29
|
let isInitialized = $state(false);
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
// Cache for tab lookups to improve performance
|
|
32
|
+
const tabCache = new Map<string, TabDefinition>();
|
|
33
|
+
|
|
34
|
+
const register = (id: string, label: string, href?: string, disabled = false) => {
|
|
35
|
+
// Prevent duplicate registrations
|
|
36
|
+
if (tabCache.has(id)) {
|
|
37
|
+
console.warn(`Tab with id "${id}" is already registered. Ignoring duplicate.`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
const index = registrationIndex++;
|
|
31
|
-
|
|
42
|
+
const tab: TabDefinition = { id, label, index, href, disabled };
|
|
43
|
+
tabState.tabs.push(tab);
|
|
44
|
+
tabCache.set(id, tab);
|
|
32
45
|
};
|
|
33
46
|
|
|
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
|
+
|
|
34
98
|
// Initialize active tab - only run once when tabs are available
|
|
35
99
|
$effect(() => {
|
|
36
100
|
// Skip if already initialized or no tabs yet
|
|
@@ -45,6 +109,16 @@
|
|
|
45
109
|
const currentTabs = tabState.tabs;
|
|
46
110
|
if (currentTabs.length === 0) return;
|
|
47
111
|
|
|
112
|
+
// If activeTab is already set by parent, use it (after verifying it's valid and not disabled)
|
|
113
|
+
if (activeTab !== null && activeTab !== undefined) {
|
|
114
|
+
const tab = tabCache.get(activeTab);
|
|
115
|
+
if (tab && !tab.disabled) {
|
|
116
|
+
tabState.active = activeTab;
|
|
117
|
+
isInitialized = true;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
48
122
|
// Check for anchor in URL first (but only if it matches a tab in THIS group)
|
|
49
123
|
const anchor = getAnchor();
|
|
50
124
|
if (anchor) {
|
|
@@ -52,58 +126,60 @@
|
|
|
52
126
|
const groupPrefix = `${groupId}-`;
|
|
53
127
|
if (anchor.startsWith(groupPrefix)) {
|
|
54
128
|
const tabId = anchor.slice(groupPrefix.length);
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
129
|
+
const tab = tabCache.get(tabId);
|
|
130
|
+
if (tab && !tab.disabled) {
|
|
57
131
|
tabState.active = tabId;
|
|
132
|
+
activeTab = tabId;
|
|
58
133
|
isInitialized = true;
|
|
59
134
|
return;
|
|
60
135
|
}
|
|
61
136
|
}
|
|
62
137
|
// Also check for direct tab ID match (for backward compatibility)
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
138
|
+
const tab = tabCache.get(anchor);
|
|
139
|
+
if (tab && !tab.disabled) {
|
|
65
140
|
tabState.active = anchor;
|
|
141
|
+
activeTab = anchor;
|
|
66
142
|
isInitialized = true;
|
|
67
143
|
return;
|
|
68
144
|
}
|
|
69
145
|
}
|
|
70
146
|
|
|
71
|
-
//
|
|
72
|
-
const defaultActiveTab = currentTabs.find((tab) => tab.defaultActive);
|
|
73
|
-
if (defaultActiveTab) {
|
|
74
|
-
tabState.active = defaultActiveTab.id;
|
|
75
|
-
isInitialized = true;
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Default to first tab (by registration index, which should be the first in DOM order)
|
|
147
|
+
// Default to first enabled tab (by registration index, which should be the first in DOM order)
|
|
80
148
|
// Sort by index to get the first registered tab (lowest index = first)
|
|
81
|
-
const sortedTabs = [...currentTabs]
|
|
149
|
+
const sortedTabs = [...currentTabs]
|
|
150
|
+
.filter((tab) => !tab.disabled)
|
|
151
|
+
.sort((a, b) => a.index - b.index);
|
|
82
152
|
const firstTab = sortedTabs[0];
|
|
83
153
|
if (firstTab) {
|
|
84
154
|
tabState.active = firstTab.id;
|
|
155
|
+
activeTab = firstTab.id;
|
|
85
156
|
isInitialized = true;
|
|
86
157
|
}
|
|
87
158
|
});
|
|
88
159
|
});
|
|
89
160
|
const selectTab = (id: string) => {
|
|
90
|
-
|
|
91
|
-
|
|
161
|
+
// Use cache for faster lookup
|
|
162
|
+
const tab = tabCache.get(id);
|
|
163
|
+
if (!tab || tab.disabled) return;
|
|
92
164
|
|
|
93
|
-
|
|
165
|
+
// Only update if different to avoid unnecessary re-renders
|
|
166
|
+
if (tabState.active !== id) {
|
|
167
|
+
tabState.active = id;
|
|
168
|
+
activeTab = id;
|
|
94
169
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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}`);
|
|
174
|
+
}
|
|
100
175
|
|
|
101
|
-
|
|
176
|
+
onChange?.(id);
|
|
102
177
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
178
|
+
// Focus the selected tab (scoped to this group)
|
|
179
|
+
const button = document.getElementById(`tab-${groupId}-${id}`);
|
|
180
|
+
if (button) {
|
|
181
|
+
button.focus();
|
|
182
|
+
}
|
|
107
183
|
}
|
|
108
184
|
};
|
|
109
185
|
|
|
@@ -161,15 +237,17 @@
|
|
|
161
237
|
<div class="tab-head">
|
|
162
238
|
<div role="tablist">
|
|
163
239
|
{#each tabState.tabs as tab}
|
|
164
|
-
<li class={tabState.active == tab.id ? 'active' : 'inactive'}>
|
|
240
|
+
<li class={tabState.active == tab.id ? 'active' : 'inactive'} class:disabled={tab.disabled}>
|
|
165
241
|
<button
|
|
166
242
|
id="tab-{groupId}-{tab.id}"
|
|
167
243
|
role="tab"
|
|
168
244
|
aria-selected={tabState.active === tab.id}
|
|
169
245
|
aria-controls="tabpanel-{groupId}-{tab.id}"
|
|
170
|
-
|
|
246
|
+
aria-disabled={tab.disabled || false}
|
|
247
|
+
tabindex={tab.disabled ? -1 : tabState.active === tab.id ? 0 : -1}
|
|
248
|
+
disabled={tab.disabled || false}
|
|
171
249
|
onclick={() => selectTab(tab.id)}
|
|
172
|
-
onkeydown={(e) => handleKeydown(e, tab.id)}
|
|
250
|
+
onkeydown={(e) => !tab.disabled && handleKeydown(e, tab.id)}
|
|
173
251
|
>
|
|
174
252
|
{tab.label}
|
|
175
253
|
</button>
|
|
@@ -206,6 +284,10 @@
|
|
|
206
284
|
font-weight: 700;
|
|
207
285
|
cursor: default;
|
|
208
286
|
}
|
|
287
|
+
.tab-head li.disabled button {
|
|
288
|
+
opacity: 0.5;
|
|
289
|
+
cursor: not-allowed;
|
|
290
|
+
}
|
|
209
291
|
.tab-head button {
|
|
210
292
|
appearance: none;
|
|
211
293
|
border: none 0;
|
|
@@ -3,8 +3,9 @@ import { type TabVariant } from './tab-context.js';
|
|
|
3
3
|
type $$ComponentProps = {
|
|
4
4
|
variant?: TabVariant;
|
|
5
5
|
onChange?: ((id: string | null) => void) | undefined;
|
|
6
|
+
activeTab?: string | null;
|
|
6
7
|
children: Snippet;
|
|
7
8
|
};
|
|
8
|
-
declare const TabGroup: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
declare const TabGroup: import("svelte").Component<$$ComponentProps, {}, "activeTab">;
|
|
9
10
|
type TabGroup = ReturnType<typeof TabGroup>;
|
|
10
11
|
export default TabGroup;
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
let {
|
|
9
9
|
label,
|
|
10
10
|
href,
|
|
11
|
-
defaultActive = false,
|
|
12
11
|
id = undefined,
|
|
12
|
+
disabled = false,
|
|
13
13
|
onActivate = undefined,
|
|
14
14
|
children = undefined
|
|
15
15
|
}: {
|
|
16
16
|
label: string;
|
|
17
17
|
href?: string | undefined;
|
|
18
|
-
defaultActive?: boolean;
|
|
19
18
|
id?: string | undefined;
|
|
19
|
+
disabled?: boolean;
|
|
20
20
|
onActivate?: ((id: string) => void) | undefined;
|
|
21
21
|
children?: Snippet;
|
|
22
22
|
} = $props();
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
// Register this tab once on mount (like wizard does)
|
|
34
34
|
onMount(() => {
|
|
35
|
-
ctx.register(_id, label,
|
|
35
|
+
ctx.register(_id, label, href, disabled);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
// Access the $state object's properties directly - THIS creates reactive dependencies!
|
|
@@ -2,8 +2,8 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
label: string;
|
|
4
4
|
href?: string | undefined;
|
|
5
|
-
defaultActive?: boolean;
|
|
6
5
|
id?: string | undefined;
|
|
6
|
+
disabled?: boolean;
|
|
7
7
|
onActivate?: ((id: string) => void) | undefined;
|
|
8
8
|
children?: Snippet;
|
|
9
9
|
};
|