@classic-homes/theme-svelte 1.28.0 → 1.30.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.
- package/dist/lib/components/Combobox.svelte +106 -39
- package/dist/lib/components/Combobox.svelte.d.ts +7 -2
- package/dist/lib/components/Dialog.svelte +41 -7
- package/dist/lib/components/Dialog.svelte.d.ts +9 -0
- package/dist/lib/components/Select.svelte +53 -23
- package/dist/lib/components/Select.svelte.d.ts +5 -0
- package/dist/lib/components/Sheet.svelte +46 -5
- package/dist/lib/components/Sheet.svelte.d.ts +9 -0
- package/dist/lib/components/internal/DiscardPrompt.svelte +47 -0
- package/dist/lib/components/internal/DiscardPrompt.svelte.d.ts +8 -0
- package/dist/lib/components/internal/discard-guard.svelte.d.ts +26 -0
- package/dist/lib/components/internal/discard-guard.svelte.js +69 -0
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Combobox as ComboboxPrimitive } from 'bits-ui';
|
|
3
|
+
import { DEV } from 'esm-env';
|
|
3
4
|
import { cn } from '../utils.js';
|
|
5
|
+
import type { SelectGroup } from '../types/components.js';
|
|
4
6
|
import { computeDescribedBy } from '../utils/form.js';
|
|
5
7
|
import Spinner from './Spinner.svelte';
|
|
6
8
|
import Label from './Label.svelte';
|
|
@@ -17,8 +19,8 @@
|
|
|
17
19
|
value?: string;
|
|
18
20
|
/** Callback when value changes */
|
|
19
21
|
onValueChange?: (value: string) => void;
|
|
20
|
-
/** Array of
|
|
21
|
-
options: ComboboxOption[];
|
|
22
|
+
/** Array of options or grouped options */
|
|
23
|
+
options: (ComboboxOption | SelectGroup)[];
|
|
22
24
|
/** Placeholder text for the input */
|
|
23
25
|
placeholder?: string;
|
|
24
26
|
/** Message to display when no results found */
|
|
@@ -45,6 +47,10 @@
|
|
|
45
47
|
debounceMs?: number;
|
|
46
48
|
/** Additional class for the trigger */
|
|
47
49
|
class?: string;
|
|
50
|
+
/** Accessible name for the input, when there is no visible label */
|
|
51
|
+
'aria-label'?: string;
|
|
52
|
+
/** ID of an element that labels the input */
|
|
53
|
+
'aria-labelledby'?: string;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
let {
|
|
@@ -64,6 +70,8 @@
|
|
|
64
70
|
onSearch,
|
|
65
71
|
debounceMs = 300,
|
|
66
72
|
class: className,
|
|
73
|
+
'aria-label': ariaLabelProp,
|
|
74
|
+
'aria-labelledby': ariaLabelledby,
|
|
67
75
|
}: Props = $props();
|
|
68
76
|
|
|
69
77
|
// Generate unique IDs for accessibility ($props.id() is SSR-stable)
|
|
@@ -79,16 +87,53 @@
|
|
|
79
87
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
80
88
|
let open = $state(false);
|
|
81
89
|
|
|
82
|
-
//
|
|
83
|
-
|
|
90
|
+
// The placeholder is only a last-resort name: an aria-label would override
|
|
91
|
+
// an external <label for={id}> or aria-labelledby.
|
|
92
|
+
const fallbackAriaLabel = $derived(
|
|
93
|
+
ariaLabelProp ?? (!label && !id && !ariaLabelledby ? placeholder : undefined)
|
|
94
|
+
);
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
if (DEV) {
|
|
97
|
+
$effect(() => {
|
|
98
|
+
if (!label && !id && !ariaLabelProp && !ariaLabelledby) {
|
|
99
|
+
console.warn(
|
|
100
|
+
'[Combobox] Missing accessible label. Provide a label prop or an id with a matching <label for="...">.'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isGroup(item: ComboboxOption | SelectGroup): item is SelectGroup {
|
|
107
|
+
return 'options' in item;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Flat list for the label lookup and for bits-ui's items
|
|
111
|
+
const flatItems = $derived(options.flatMap((item) => (isGroup(item) ? item.options : [item])));
|
|
112
|
+
|
|
113
|
+
const selectedLabel = $derived(flatItems.find((opt) => opt.value === value)?.label);
|
|
114
|
+
|
|
115
|
+
// Always filter options locally based on search query; groups keep only
|
|
116
|
+
// their matching options and drop out when none match.
|
|
117
|
+
const filteredOptions = $derived.by((): (ComboboxOption | SelectGroup)[] => {
|
|
87
118
|
if (!searchQuery) return options;
|
|
88
119
|
const query = searchQuery.toLowerCase();
|
|
89
|
-
|
|
120
|
+
const matches = (opt: ComboboxOption) => opt.label.toLowerCase().includes(query);
|
|
121
|
+
return options.flatMap((item): (ComboboxOption | SelectGroup)[] => {
|
|
122
|
+
if (!isGroup(item)) return matches(item) ? [item] : [];
|
|
123
|
+
const groupOptions = item.options.filter(matches);
|
|
124
|
+
return groupOptions.length > 0 ? [{ ...item, options: groupOptions }] : [];
|
|
125
|
+
});
|
|
90
126
|
});
|
|
91
127
|
|
|
128
|
+
const filteredCount = $derived(
|
|
129
|
+
filteredOptions.reduce((n: number, item) => n + (isGroup(item) ? item.options.length : 1), 0)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Closed, the input shows the selected label; open, it holds what the user
|
|
133
|
+
// types, with the selected label as the placeholder so the full list shows.
|
|
134
|
+
const inputValue = $derived(open ? searchQuery : (selectedLabel ?? ''));
|
|
135
|
+
const inputPlaceholder = $derived(open ? selectedLabel || placeholder : placeholder);
|
|
136
|
+
|
|
92
137
|
function handleSearchInput(e: Event) {
|
|
93
138
|
const target = e.target as HTMLInputElement;
|
|
94
139
|
searchQuery = target.value;
|
|
@@ -127,12 +172,41 @@
|
|
|
127
172
|
const resultsAnnouncement = $derived.by(() => {
|
|
128
173
|
if (loading) return 'Loading options...';
|
|
129
174
|
if (!open) return '';
|
|
130
|
-
if (
|
|
131
|
-
const count =
|
|
175
|
+
if (filteredCount === 0) return emptyMessage;
|
|
176
|
+
const count = filteredCount;
|
|
132
177
|
return `${count} ${count === 1 ? 'option' : 'options'} available`;
|
|
133
178
|
});
|
|
134
179
|
</script>
|
|
135
180
|
|
|
181
|
+
{#snippet comboboxItem(option: ComboboxOption)}
|
|
182
|
+
<ComboboxPrimitive.Item
|
|
183
|
+
value={option.value}
|
|
184
|
+
label={option.label}
|
|
185
|
+
disabled={option.disabled}
|
|
186
|
+
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
|
|
187
|
+
>
|
|
188
|
+
{#if option.value === value}
|
|
189
|
+
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
190
|
+
<svg
|
|
191
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
192
|
+
width="16"
|
|
193
|
+
height="16"
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="currentColor"
|
|
197
|
+
stroke-width="2"
|
|
198
|
+
stroke-linecap="round"
|
|
199
|
+
stroke-linejoin="round"
|
|
200
|
+
class="h-4 w-4"
|
|
201
|
+
>
|
|
202
|
+
<polyline points="20 6 9 17 4 12" />
|
|
203
|
+
</svg>
|
|
204
|
+
</span>
|
|
205
|
+
{/if}
|
|
206
|
+
{option.label}
|
|
207
|
+
</ComboboxPrimitive.Item>
|
|
208
|
+
{/snippet}
|
|
209
|
+
|
|
136
210
|
<!-- One structure whether or not there is a label, hint or error, so the
|
|
137
211
|
focused control is never remounted when an error appears. -->
|
|
138
212
|
<div class="space-y-2">
|
|
@@ -155,11 +229,12 @@
|
|
|
155
229
|
onOpenChange={handleOpenChange}
|
|
156
230
|
onValueChange={handleValueChange}
|
|
157
231
|
{value}
|
|
232
|
+
items={flatItems}
|
|
158
233
|
>
|
|
159
234
|
<div class="relative">
|
|
160
235
|
<ComboboxPrimitive.Input
|
|
161
236
|
id={componentId}
|
|
162
|
-
placeholder={
|
|
237
|
+
placeholder={inputPlaceholder}
|
|
163
238
|
oninput={handleSearchInput}
|
|
164
239
|
onfocus={() => (open = true)}
|
|
165
240
|
onclick={() => (open = true)}
|
|
@@ -170,9 +245,15 @@
|
|
|
170
245
|
)}
|
|
171
246
|
role="combobox"
|
|
172
247
|
aria-expanded={open}
|
|
248
|
+
aria-label={fallbackAriaLabel}
|
|
249
|
+
aria-labelledby={ariaLabelledby}
|
|
173
250
|
aria-invalid={error ? 'true' : undefined}
|
|
174
251
|
aria-describedby={describedBy}
|
|
175
|
-
|
|
252
|
+
>
|
|
253
|
+
{#snippet child({ props })}
|
|
254
|
+
<input {...props} value={inputValue} />
|
|
255
|
+
{/snippet}
|
|
256
|
+
</ComboboxPrimitive.Input>
|
|
176
257
|
</div>
|
|
177
258
|
|
|
178
259
|
<!-- Portal to <body>: escapes transformed/overflow-hidden ancestors
|
|
@@ -190,38 +271,24 @@
|
|
|
190
271
|
<Spinner size="sm" />
|
|
191
272
|
<span class="ml-2 text-sm text-muted-foreground">Loading...</span>
|
|
192
273
|
</div>
|
|
193
|
-
{:else if
|
|
274
|
+
{:else if filteredCount === 0}
|
|
194
275
|
<div class="py-6 text-center text-sm text-muted-foreground">
|
|
195
276
|
{emptyMessage}
|
|
196
277
|
</div>
|
|
197
278
|
{:else}
|
|
198
|
-
{#each filteredOptions as
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
viewBox="0 0 24 24"
|
|
212
|
-
fill="none"
|
|
213
|
-
stroke="currentColor"
|
|
214
|
-
stroke-width="2"
|
|
215
|
-
stroke-linecap="round"
|
|
216
|
-
stroke-linejoin="round"
|
|
217
|
-
class="h-4 w-4"
|
|
218
|
-
>
|
|
219
|
-
<polyline points="20 6 9 17 4 12" />
|
|
220
|
-
</svg>
|
|
221
|
-
</span>
|
|
222
|
-
{/if}
|
|
223
|
-
{option.label}
|
|
224
|
-
</ComboboxPrimitive.Item>
|
|
279
|
+
{#each filteredOptions as item}
|
|
280
|
+
{#if isGroup(item)}
|
|
281
|
+
<ComboboxPrimitive.Group>
|
|
282
|
+
<ComboboxPrimitive.GroupHeading class="py-1.5 pl-8 pr-2 text-sm font-semibold">
|
|
283
|
+
{item.label}
|
|
284
|
+
</ComboboxPrimitive.GroupHeading>
|
|
285
|
+
{#each item.options as option}
|
|
286
|
+
{@render comboboxItem(option)}
|
|
287
|
+
{/each}
|
|
288
|
+
</ComboboxPrimitive.Group>
|
|
289
|
+
{:else}
|
|
290
|
+
{@render comboboxItem(item)}
|
|
291
|
+
{/if}
|
|
225
292
|
{/each}
|
|
226
293
|
{/if}
|
|
227
294
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SelectGroup } from '../types/components.js';
|
|
1
2
|
export interface ComboboxOption {
|
|
2
3
|
value: string;
|
|
3
4
|
label: string;
|
|
@@ -8,8 +9,8 @@ interface Props {
|
|
|
8
9
|
value?: string;
|
|
9
10
|
/** Callback when value changes */
|
|
10
11
|
onValueChange?: (value: string) => void;
|
|
11
|
-
/** Array of
|
|
12
|
-
options: ComboboxOption[];
|
|
12
|
+
/** Array of options or grouped options */
|
|
13
|
+
options: (ComboboxOption | SelectGroup)[];
|
|
13
14
|
/** Placeholder text for the input */
|
|
14
15
|
placeholder?: string;
|
|
15
16
|
/** Message to display when no results found */
|
|
@@ -36,6 +37,10 @@ interface Props {
|
|
|
36
37
|
debounceMs?: number;
|
|
37
38
|
/** Additional class for the trigger */
|
|
38
39
|
class?: string;
|
|
40
|
+
/** Accessible name for the input, when there is no visible label */
|
|
41
|
+
'aria-label'?: string;
|
|
42
|
+
/** ID of an element that labels the input */
|
|
43
|
+
'aria-labelledby'?: string;
|
|
39
44
|
}
|
|
40
45
|
declare const Combobox: import("svelte").Component<Props, {}, "value">;
|
|
41
46
|
type Combobox = ReturnType<typeof Combobox>;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
5
|
import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
|
|
6
|
+
import { createDiscardGuard } from './internal/discard-guard.svelte.js';
|
|
7
|
+
import DiscardPrompt from './internal/DiscardPrompt.svelte';
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
/** Whether the dialog is open */
|
|
@@ -39,6 +41,15 @@
|
|
|
39
41
|
* keeps focus where the caller put it (e.g. a field it just revealed).
|
|
40
42
|
*/
|
|
41
43
|
onCloseAutoFocus?: (event: Event) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Unsaved work inside. When true, an overlay click, Escape or the close
|
|
46
|
+
* button asks "Discard unsaved changes?" instead of closing. Left unset,
|
|
47
|
+
* the dialog counts as dirty once anything inside is edited (typing, or a
|
|
48
|
+
* checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
|
|
49
|
+
* don't count). Pass `false` to turn the prompt off. Closing in code is
|
|
50
|
+
* never blocked.
|
|
51
|
+
*/
|
|
52
|
+
dirty?: boolean;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
// Every size is clamped to the viewport minus a 1rem gutter on phones; the
|
|
@@ -66,6 +77,7 @@
|
|
|
66
77
|
headerActions,
|
|
67
78
|
showClose = true,
|
|
68
79
|
onCloseAutoFocus,
|
|
80
|
+
dirty,
|
|
69
81
|
}: Props = $props();
|
|
70
82
|
|
|
71
83
|
const uid = $props.id();
|
|
@@ -77,9 +89,21 @@
|
|
|
77
89
|
onOpenChange?.(newOpen);
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
const guard = createDiscardGuard(() => dirty);
|
|
93
|
+
let contentEl: HTMLElement | null = $state(null);
|
|
94
|
+
|
|
95
|
+
$effect(() => {
|
|
96
|
+
if (open) guard.reset();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function keepEditing() {
|
|
100
|
+
guard.keep();
|
|
101
|
+
contentEl?.focus();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function discard() {
|
|
105
|
+
guard.reset();
|
|
106
|
+
handleOpenChange(false);
|
|
83
107
|
}
|
|
84
108
|
</script>
|
|
85
109
|
|
|
@@ -97,7 +121,13 @@
|
|
|
97
121
|
class="fixed inset-0 z-50 bg-foreground/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
98
122
|
/>
|
|
99
123
|
<DialogPrimitive.Content
|
|
124
|
+
bind:ref={contentEl}
|
|
100
125
|
{onCloseAutoFocus}
|
|
126
|
+
onInteractOutside={guard.dismiss}
|
|
127
|
+
onEscapeKeydown={guard.escape}
|
|
128
|
+
oninput={guard.track}
|
|
129
|
+
onchange={guard.track}
|
|
130
|
+
onclick={guard.track}
|
|
101
131
|
class={cn(
|
|
102
132
|
'fixed left-[50%] top-[50%] z-50 flex max-h-[calc(100dvh-2rem)] w-full translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border border-border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
|
|
103
133
|
sizeClasses[size],
|
|
@@ -133,7 +163,9 @@
|
|
|
133
163
|
{@render headerActions()}
|
|
134
164
|
{/if}
|
|
135
165
|
{#if showClose}
|
|
136
|
-
<
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onclick={() => guard.dismiss() && handleOpenChange(false)}
|
|
137
169
|
class="flex h-9 w-9 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none relative after:absolute after:-inset-1 after:content-['']"
|
|
138
170
|
aria-label="Close dialog"
|
|
139
171
|
>
|
|
@@ -153,7 +185,7 @@
|
|
|
153
185
|
<path d="M18 6 6 18" />
|
|
154
186
|
<path d="m6 6 12 12" />
|
|
155
187
|
</svg>
|
|
156
|
-
</
|
|
188
|
+
</button>
|
|
157
189
|
{/if}
|
|
158
190
|
</div>
|
|
159
191
|
</div>
|
|
@@ -164,8 +196,10 @@
|
|
|
164
196
|
{@render children()}
|
|
165
197
|
</div>
|
|
166
198
|
|
|
167
|
-
<!-- Footer section -->
|
|
168
|
-
{#if
|
|
199
|
+
<!-- Footer section; the discard prompt takes its place while it asks -->
|
|
200
|
+
{#if guard.confirming}
|
|
201
|
+
<DiscardPrompt class="px-6 py-4" onKeep={keepEditing} onDiscard={discard} />
|
|
202
|
+
{:else if footer}
|
|
169
203
|
<div
|
|
170
204
|
class="flex flex-col-reverse border-t border-border bg-muted/30 px-6 py-4 sm:flex-row sm:justify-end sm:space-x-2"
|
|
171
205
|
>
|
|
@@ -35,6 +35,15 @@ interface Props {
|
|
|
35
35
|
* keeps focus where the caller put it (e.g. a field it just revealed).
|
|
36
36
|
*/
|
|
37
37
|
onCloseAutoFocus?: (event: Event) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Unsaved work inside. When true, an overlay click, Escape or the close
|
|
40
|
+
* button asks "Discard unsaved changes?" instead of closing. Left unset,
|
|
41
|
+
* the dialog counts as dirty once anything inside is edited (typing, or a
|
|
42
|
+
* checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
|
|
43
|
+
* don't count). Pass `false` to turn the prompt off. Closing in code is
|
|
44
|
+
* never blocked.
|
|
45
|
+
*/
|
|
46
|
+
dirty?: boolean;
|
|
38
47
|
}
|
|
39
48
|
declare const Dialog: import("svelte").Component<Props, {}, "open">;
|
|
40
49
|
type Dialog = ReturnType<typeof Dialog>;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { validateNonEmptyArray } from '../validation.js';
|
|
5
5
|
import { DEV } from 'esm-env';
|
|
6
6
|
import Label from './Label.svelte';
|
|
7
|
+
import Combobox from './Combobox.svelte';
|
|
7
8
|
|
|
8
9
|
export interface SelectOption {
|
|
9
10
|
value: string;
|
|
@@ -45,6 +46,11 @@
|
|
|
45
46
|
'aria-label'?: string;
|
|
46
47
|
/** ID of an element that labels the trigger */
|
|
47
48
|
'aria-labelledby'?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Type to narrow the options: `true` always renders a Combobox; a number
|
|
51
|
+
* renders one once there are at least that many options.
|
|
52
|
+
*/
|
|
53
|
+
searchable?: boolean | number;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
let {
|
|
@@ -62,6 +68,7 @@
|
|
|
62
68
|
hint,
|
|
63
69
|
'aria-label': ariaLabelProp,
|
|
64
70
|
'aria-labelledby': ariaLabelledby,
|
|
71
|
+
searchable = false,
|
|
65
72
|
}: Props = $props();
|
|
66
73
|
|
|
67
74
|
// Generate unique IDs for accessibility using sequential counter
|
|
@@ -132,6 +139,10 @@
|
|
|
132
139
|
return items;
|
|
133
140
|
});
|
|
134
141
|
|
|
142
|
+
const useCombobox = $derived(
|
|
143
|
+
typeof searchable === 'number' ? flatItems.length >= searchable : searchable
|
|
144
|
+
);
|
|
145
|
+
|
|
135
146
|
function handleValueChange(newValue: string) {
|
|
136
147
|
value = newValue;
|
|
137
148
|
onValueChange?.(newValue);
|
|
@@ -267,30 +278,49 @@
|
|
|
267
278
|
</SelectPrimitive.Root>
|
|
268
279
|
{/snippet}
|
|
269
280
|
|
|
270
|
-
|
|
281
|
+
{#if useCombobox}
|
|
282
|
+
<Combobox
|
|
283
|
+
bind:value
|
|
284
|
+
{onValueChange}
|
|
285
|
+
{options}
|
|
286
|
+
{placeholder}
|
|
287
|
+
{disabled}
|
|
288
|
+
{name}
|
|
289
|
+
{required}
|
|
290
|
+
{label}
|
|
291
|
+
{id}
|
|
292
|
+
{error}
|
|
293
|
+
{hint}
|
|
294
|
+
aria-label={ariaLabelProp}
|
|
295
|
+
aria-labelledby={ariaLabelledby}
|
|
296
|
+
class={className}
|
|
297
|
+
/>
|
|
298
|
+
{:else}
|
|
299
|
+
<!-- One structure whether or not there is a label, hint or error, so the
|
|
271
300
|
focused control is never remounted when an error appears. -->
|
|
272
|
-
<div class="space-y-2">
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
301
|
+
<div class="space-y-2">
|
|
302
|
+
{#if label}
|
|
303
|
+
<Label for={componentId} {disabled}>
|
|
304
|
+
{label}
|
|
305
|
+
{#if required}
|
|
306
|
+
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
|
307
|
+
<span class="sr-only">(required)</span>
|
|
308
|
+
{/if}
|
|
309
|
+
</Label>
|
|
310
|
+
{/if}
|
|
282
311
|
|
|
283
|
-
|
|
312
|
+
{@render selectRoot(fallbackAriaLabel)}
|
|
284
313
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
314
|
+
{#if hint && !error}
|
|
315
|
+
<p id={hintId} class="text-sm text-muted-foreground">
|
|
316
|
+
{hint}
|
|
317
|
+
</p>
|
|
318
|
+
{/if}
|
|
290
319
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
</div>
|
|
320
|
+
{#if error}
|
|
321
|
+
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
322
|
+
{error}
|
|
323
|
+
</p>
|
|
324
|
+
{/if}
|
|
325
|
+
</div>
|
|
326
|
+
{/if}
|
|
@@ -36,6 +36,11 @@ interface Props {
|
|
|
36
36
|
'aria-label'?: string;
|
|
37
37
|
/** ID of an element that labels the trigger */
|
|
38
38
|
'aria-labelledby'?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Type to narrow the options: `true` always renders a Combobox; a number
|
|
41
|
+
* renders one once there are at least that many options.
|
|
42
|
+
*/
|
|
43
|
+
searchable?: boolean | number;
|
|
39
44
|
}
|
|
40
45
|
declare const Select: import("svelte").Component<Props, {}, "value">;
|
|
41
46
|
type Select = ReturnType<typeof Select>;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
5
|
import { cn } from '../utils.js';
|
|
6
6
|
import Icon from './Icon.svelte';
|
|
7
|
+
import { createDiscardGuard } from './internal/discard-guard.svelte.js';
|
|
8
|
+
import DiscardPrompt from './internal/DiscardPrompt.svelte';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Sheet - a modal panel that slides in from an edge of the screen.
|
|
@@ -66,6 +68,15 @@
|
|
|
66
68
|
footer?: Snippet;
|
|
67
69
|
children: Snippet;
|
|
68
70
|
class?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Unsaved work inside. When true, a scrim click, Escape or the close
|
|
73
|
+
* button asks "Discard unsaved changes?" instead of closing. Left unset,
|
|
74
|
+
* the sheet counts as dirty once anything inside is edited (typing, or a
|
|
75
|
+
* checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
|
|
76
|
+
* don't count). Pass `false` to turn the prompt off. Closing in code is
|
|
77
|
+
* never blocked.
|
|
78
|
+
*/
|
|
79
|
+
dirty?: boolean;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
let {
|
|
@@ -80,6 +91,7 @@
|
|
|
80
91
|
footer,
|
|
81
92
|
children,
|
|
82
93
|
class: className,
|
|
94
|
+
dirty,
|
|
83
95
|
}: Props = $props();
|
|
84
96
|
|
|
85
97
|
const styles = $derived(sheet({ side, size }));
|
|
@@ -88,16 +100,35 @@
|
|
|
88
100
|
open = next;
|
|
89
101
|
onOpenChange?.(next);
|
|
90
102
|
}
|
|
103
|
+
|
|
104
|
+
const guard = createDiscardGuard(() => dirty);
|
|
105
|
+
let contentEl: HTMLElement | null = $state(null);
|
|
106
|
+
|
|
107
|
+
$effect(() => {
|
|
108
|
+
if (open) guard.reset();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function keepEditing() {
|
|
112
|
+
guard.keep();
|
|
113
|
+
contentEl?.focus();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function discard() {
|
|
117
|
+
guard.reset();
|
|
118
|
+
handleOpenChange(false);
|
|
119
|
+
}
|
|
91
120
|
</script>
|
|
92
121
|
|
|
93
|
-
<!--
|
|
122
|
+
<!-- A plain button, not Close, so the discard guard can intercept it -->
|
|
94
123
|
{#snippet closeButton()}
|
|
95
|
-
<
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onclick={() => guard.dismiss() && handleOpenChange(false)}
|
|
96
127
|
class="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
97
128
|
aria-label="Close"
|
|
98
129
|
>
|
|
99
130
|
<Icon name="close" size={18} aria-hidden="true" />
|
|
100
|
-
</
|
|
131
|
+
</button>
|
|
101
132
|
{/snippet}
|
|
102
133
|
|
|
103
134
|
<DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
|
|
@@ -105,7 +136,15 @@
|
|
|
105
136
|
<DialogPrimitive.Overlay
|
|
106
137
|
class="fixed inset-0 z-50 bg-foreground/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
107
138
|
/>
|
|
108
|
-
<DialogPrimitive.Content
|
|
139
|
+
<DialogPrimitive.Content
|
|
140
|
+
bind:ref={contentEl}
|
|
141
|
+
class={cn(styles.content(), className)}
|
|
142
|
+
onInteractOutside={guard.dismiss}
|
|
143
|
+
onEscapeKeydown={guard.escape}
|
|
144
|
+
oninput={guard.track}
|
|
145
|
+
onchange={guard.track}
|
|
146
|
+
onclick={guard.track}
|
|
147
|
+
>
|
|
109
148
|
{#if side === 'bottom'}
|
|
110
149
|
<!-- Grab bar row; carries the close button when the title is hidden -->
|
|
111
150
|
<div class="relative flex h-6 shrink-0 items-center justify-center">
|
|
@@ -145,7 +184,9 @@
|
|
|
145
184
|
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
146
185
|
{@render children()}
|
|
147
186
|
</div>
|
|
148
|
-
{#if
|
|
187
|
+
{#if guard.confirming}
|
|
188
|
+
<DiscardPrompt class="px-4 py-3" onKeep={keepEditing} onDiscard={discard} />
|
|
189
|
+
{:else if footer}
|
|
149
190
|
<div class="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
|
150
191
|
{@render footer()}
|
|
151
192
|
</div>
|
|
@@ -22,6 +22,15 @@ interface Props {
|
|
|
22
22
|
footer?: Snippet;
|
|
23
23
|
children: Snippet;
|
|
24
24
|
class?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Unsaved work inside. When true, a scrim click, Escape or the close
|
|
27
|
+
* button asks "Discard unsaved changes?" instead of closing. Left unset,
|
|
28
|
+
* the sheet counts as dirty once anything inside is edited (typing, or a
|
|
29
|
+
* checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
|
|
30
|
+
* don't count). Pass `false` to turn the prompt off. Closing in code is
|
|
31
|
+
* never blocked.
|
|
32
|
+
*/
|
|
33
|
+
dirty?: boolean;
|
|
25
34
|
}
|
|
26
35
|
declare const Sheet: import("svelte").Component<Props, {}, "open">;
|
|
27
36
|
type Sheet = ReturnType<typeof Sheet>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
import { buttonVariants } from '../button-variants.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
onKeep: () => void;
|
|
8
|
+
onDiscard: () => void;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { onKeep, onDiscard, class: className }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const id = $props.id();
|
|
15
|
+
let keepEl: HTMLButtonElement | undefined = $state();
|
|
16
|
+
|
|
17
|
+
// Safe default under the pointer/keyboard: a stray Enter keeps the work
|
|
18
|
+
onMount(() => keepEl?.focus());
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
role="group"
|
|
23
|
+
aria-labelledby="{id}-text"
|
|
24
|
+
class={cn(
|
|
25
|
+
'flex shrink-0 flex-col gap-3 border-t border-border bg-muted/30 sm:flex-row sm:items-center sm:justify-between',
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
<p id="{id}-text" class="text-sm font-medium">Discard unsaved changes?</p>
|
|
30
|
+
<div class="flex flex-col-reverse gap-2 sm:flex-row">
|
|
31
|
+
<button
|
|
32
|
+
bind:this={keepEl}
|
|
33
|
+
type="button"
|
|
34
|
+
class={buttonVariants({ variant: 'outline', size: 'sm' })}
|
|
35
|
+
onclick={onKeep}
|
|
36
|
+
>
|
|
37
|
+
Keep editing
|
|
38
|
+
</button>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
class={buttonVariants({ variant: 'destructive', size: 'sm' })}
|
|
42
|
+
onclick={onDiscard}
|
|
43
|
+
>
|
|
44
|
+
Discard
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared by Dialog and Sheet: once someone has typed into the content, an
|
|
3
|
+
* overlay click, Escape or the close button asks before throwing that work
|
|
4
|
+
* away. Closing in code (`open = false`, a Cancel or Save button) is never
|
|
5
|
+
* blocked.
|
|
6
|
+
*
|
|
7
|
+
* Dirtiness is the caller's `dirty` prop when given, otherwise "an input or
|
|
8
|
+
* change event, or a checkbox/switch/radio click, happened inside since it
|
|
9
|
+
* opened". Search boxes and anything under `[data-dirty-ignore]` don't
|
|
10
|
+
* count, so pickers and filters stay one-click.
|
|
11
|
+
*
|
|
12
|
+
* A factory rather than a class: the package build lowers class fields,
|
|
13
|
+
* which `$state` doesn't survive.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createDiscardGuard(dirty: () => boolean | undefined): {
|
|
16
|
+
readonly confirming: boolean;
|
|
17
|
+
/** Forget earlier edits; call whenever the surface opens or is discarded */
|
|
18
|
+
reset(): void;
|
|
19
|
+
/** Hide the prompt and carry on editing */
|
|
20
|
+
keep(): void;
|
|
21
|
+
/** Delegated `oninput` / `onchange` / `onclick` for the content element */
|
|
22
|
+
track(event: Event): void;
|
|
23
|
+
dismiss: (event?: Event) => boolean;
|
|
24
|
+
/** Escape backs out of the prompt first, then behaves like `dismiss` */
|
|
25
|
+
escape(event: KeyboardEvent): void;
|
|
26
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Bits UI checkboxes, switches and radios are buttons: they click, not input
|
|
2
|
+
const TOGGLES = '[role="checkbox"], [role="switch"], [role="radio"]';
|
|
3
|
+
/**
|
|
4
|
+
* Shared by Dialog and Sheet: once someone has typed into the content, an
|
|
5
|
+
* overlay click, Escape or the close button asks before throwing that work
|
|
6
|
+
* away. Closing in code (`open = false`, a Cancel or Save button) is never
|
|
7
|
+
* blocked.
|
|
8
|
+
*
|
|
9
|
+
* Dirtiness is the caller's `dirty` prop when given, otherwise "an input or
|
|
10
|
+
* change event, or a checkbox/switch/radio click, happened inside since it
|
|
11
|
+
* opened". Search boxes and anything under `[data-dirty-ignore]` don't
|
|
12
|
+
* count, so pickers and filters stay one-click.
|
|
13
|
+
*
|
|
14
|
+
* A factory rather than a class: the package build lowers class fields,
|
|
15
|
+
* which `$state` doesn't survive.
|
|
16
|
+
*/
|
|
17
|
+
export function createDiscardGuard(dirty) {
|
|
18
|
+
let confirming = $state(false);
|
|
19
|
+
let touched = $state(false);
|
|
20
|
+
const isDirty = () => dirty() ?? touched;
|
|
21
|
+
/**
|
|
22
|
+
* An overlay click or the close button. Returns true when it may close;
|
|
23
|
+
* otherwise cancels `event` (if any) and shows the discard prompt.
|
|
24
|
+
*/
|
|
25
|
+
function dismiss(event) {
|
|
26
|
+
if (!isDirty())
|
|
27
|
+
return true;
|
|
28
|
+
event?.preventDefault();
|
|
29
|
+
confirming = true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
get confirming() {
|
|
34
|
+
return confirming;
|
|
35
|
+
},
|
|
36
|
+
/** Forget earlier edits; call whenever the surface opens or is discarded */
|
|
37
|
+
reset() {
|
|
38
|
+
touched = false;
|
|
39
|
+
confirming = false;
|
|
40
|
+
},
|
|
41
|
+
/** Hide the prompt and carry on editing */
|
|
42
|
+
keep() {
|
|
43
|
+
confirming = false;
|
|
44
|
+
},
|
|
45
|
+
/** Delegated `oninput` / `onchange` / `onclick` for the content element */
|
|
46
|
+
track(event) {
|
|
47
|
+
const target = event.target;
|
|
48
|
+
if (!(target instanceof Element))
|
|
49
|
+
return;
|
|
50
|
+
if (event.type === 'click' && !target.closest(TOGGLES))
|
|
51
|
+
return;
|
|
52
|
+
if (target instanceof HTMLInputElement && target.type === 'search')
|
|
53
|
+
return;
|
|
54
|
+
if (target.closest('[data-dirty-ignore]'))
|
|
55
|
+
return;
|
|
56
|
+
touched = true;
|
|
57
|
+
},
|
|
58
|
+
dismiss,
|
|
59
|
+
/** Escape backs out of the prompt first, then behaves like `dismiss` */
|
|
60
|
+
escape(event) {
|
|
61
|
+
if (confirming) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
confirming = false;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
dismiss(event);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|