@viniaraujo68/plinth 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/anchoring.d.ts +61 -0
- package/dist/anchoring.js +151 -0
- package/dist/async-action.svelte.d.ts +37 -0
- package/dist/async-action.svelte.js +64 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/tooltip.d.ts +37 -0
- package/dist/attachments/tooltip.js +166 -0
- package/dist/components/AsyncButton.svelte +49 -0
- package/dist/components/AsyncButton.svelte.d.ts +32 -0
- package/dist/components/Combobox.svelte +380 -0
- package/dist/components/Combobox.svelte.d.ts +74 -0
- package/dist/components/Copyable.svelte +116 -0
- package/dist/components/Copyable.svelte.d.ts +37 -0
- package/dist/components/DateRangePicker.svelte +266 -0
- package/dist/components/DateRangePicker.svelte.d.ts +66 -0
- package/dist/components/Dialog.svelte +102 -0
- package/dist/components/Dialog.svelte.d.ts +38 -0
- package/dist/components/Dropdown.svelte +204 -0
- package/dist/components/Dropdown.svelte.d.ts +49 -0
- package/dist/components/ErrorDisplay.svelte +59 -0
- package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
- package/dist/components/ErrorPage.svelte +119 -0
- package/dist/components/ErrorPage.svelte.d.ts +52 -0
- package/dist/components/JsonInput.svelte +106 -0
- package/dist/components/JsonInput.svelte.d.ts +40 -0
- package/dist/components/Listbox.svelte +257 -0
- package/dist/components/Listbox.svelte.d.ts +78 -0
- package/dist/components/LoadingButton.svelte +36 -0
- package/dist/components/LoadingButton.svelte.d.ts +22 -0
- package/dist/components/Logo.svelte +112 -0
- package/dist/components/Logo.svelte.d.ts +29 -0
- package/dist/components/Modal.svelte +187 -0
- package/dist/components/Modal.svelte.d.ts +62 -0
- package/dist/components/MultiSelect.svelte +256 -0
- package/dist/components/MultiSelect.svelte.d.ts +89 -0
- package/dist/components/OptionPanel.svelte +241 -0
- package/dist/components/OptionPanel.svelte.d.ts +51 -0
- package/dist/components/Paginator.svelte +205 -0
- package/dist/components/Paginator.svelte.d.ts +48 -0
- package/dist/components/RefreshButton.svelte +246 -0
- package/dist/components/RefreshButton.svelte.d.ts +46 -0
- package/dist/components/SegmentedControl.svelte +147 -0
- package/dist/components/SegmentedControl.svelte.d.ts +71 -0
- package/dist/components/Select.svelte +312 -0
- package/dist/components/Select.svelte.d.ts +68 -0
- package/dist/components/Skeleton.svelte +97 -0
- package/dist/components/Skeleton.svelte.d.ts +32 -0
- package/dist/components/Tooltip.svelte +60 -0
- package/dist/components/Tooltip.svelte.d.ts +58 -0
- package/dist/components/date-range.d.ts +101 -0
- package/dist/components/date-range.js +144 -0
- package/dist/components/index.d.ts +25 -0
- package/dist/components/index.js +37 -0
- package/dist/components/json-text.d.ts +22 -0
- package/dist/components/json-text.js +22 -0
- package/dist/components/pick.d.ts +18 -0
- package/dist/components/pick.js +18 -0
- package/dist/components/segmented.d.ts +7 -0
- package/dist/components/segmented.js +1 -0
- package/dist/components/select.d.ts +66 -0
- package/dist/components/select.js +90 -0
- package/dist/confirm/Confirmer.svelte +194 -0
- package/dist/confirm/Confirmer.svelte.d.ts +45 -0
- package/dist/confirm/challenge.d.ts +16 -0
- package/dist/confirm/challenge.js +16 -0
- package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
- package/dist/confirm/confirm-manager.svelte.js +149 -0
- package/dist/confirm/index.d.ts +3 -0
- package/dist/confirm/index.js +3 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.js +39 -0
- package/dist/formatters.d.ts +95 -0
- package/dist/formatters.js +146 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +138 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/routing.svelte.d.ts +169 -0
- package/dist/routing.svelte.js +208 -0
- package/dist/shell/AppShell.svelte +874 -0
- package/dist/shell/AppShell.svelte.d.ts +93 -0
- package/dist/shell/Breadcrumbs.svelte +78 -0
- package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
- package/dist/shell/crumbs.d.ts +23 -0
- package/dist/shell/crumbs.js +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +6 -0
- package/dist/table/ColumnMenu.svelte +249 -0
- package/dist/table/ColumnMenu.svelte.d.ts +47 -0
- package/dist/table/DataTable.svelte +643 -0
- package/dist/table/DataTable.svelte.d.ts +153 -0
- package/dist/table/columns.d.ts +55 -0
- package/dist/table/columns.js +153 -0
- package/dist/table/index.d.ts +4 -0
- package/dist/table/index.js +8 -0
- package/dist/table/sort.d.ts +48 -0
- package/dist/table/sort.js +139 -0
- package/dist/table/types.d.ts +93 -0
- package/dist/table/types.js +1 -0
- package/dist/theme/ThemeController.svelte +54 -0
- package/dist/theme/ThemeController.svelte.d.ts +27 -0
- package/dist/theme/ThemeToggle.svelte +119 -0
- package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
- package/dist/theme/css-color.d.ts +23 -0
- package/dist/theme/css-color.js +31 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/theme.css +410 -0
- package/dist/theme/theme.svelte.d.ts +54 -0
- package/dist/theme/theme.svelte.js +58 -0
- package/dist/toast/Toaster.svelte +257 -0
- package/dist/toast/Toaster.svelte.d.ts +35 -0
- package/dist/toast/index.d.ts +2 -0
- package/dist/toast/index.js +2 -0
- package/dist/toast/toast-manager.svelte.d.ts +99 -0
- package/dist/toast/toast-manager.svelte.js +176 -0
- package/dist/user/ScopedComponent.svelte +47 -0
- package/dist/user/ScopedComponent.svelte.d.ts +29 -0
- package/dist/user/anonymous.d.ts +21 -0
- package/dist/user/anonymous.js +26 -0
- package/dist/user/context.d.ts +55 -0
- package/dist/user/context.js +13 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.js +6 -0
- package/package.json +133 -4
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue, HTMLButtonAttributes } from "svelte/elements";
|
|
4
|
+
import OptionPanel from "./OptionPanel.svelte";
|
|
5
|
+
import {
|
|
6
|
+
isTypedKey,
|
|
7
|
+
nextSelectableIndex,
|
|
8
|
+
Typeahead,
|
|
9
|
+
typeaheadMatch,
|
|
10
|
+
type SelectOption,
|
|
11
|
+
} from "./select.js";
|
|
12
|
+
|
|
13
|
+
type Props = Omit<
|
|
14
|
+
HTMLButtonAttributes,
|
|
15
|
+
"children" | "class" | "disabled" | "name" | "onchange" | "value"
|
|
16
|
+
> & {
|
|
17
|
+
/** The rows, in the order they are shown. `value` is the key, so it has to be unique. */
|
|
18
|
+
options: readonly SelectOption[];
|
|
19
|
+
/** The selected `value`, or `null`. Bindable, and also reported through {@link Props.onchange}. */
|
|
20
|
+
value?: string | null;
|
|
21
|
+
/** Shown on the trigger while nothing is selected, and as the closed control's accessible text. */
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
/** Whether the selection can be taken back to `null`, through a ✕ on the trigger. */
|
|
24
|
+
clearable?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Custom rendering for a row. The default is the label. */
|
|
27
|
+
option?: Snippet<[SelectOption]>;
|
|
28
|
+
/** Accessible name of the clear button. A prop because the library ships no translations. */
|
|
29
|
+
clearLabel?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Shows a spinner while the options load, and `loadingLabel` in an empty list. A `value` the
|
|
32
|
+
* options do not hold yet shows as nothing rather than as itself.
|
|
33
|
+
*/
|
|
34
|
+
loading?: boolean;
|
|
35
|
+
loadingLabel?: string;
|
|
36
|
+
/** Classes for the control — width, margins. The trigger is where every other attribute goes. */
|
|
37
|
+
class?: ClassValue;
|
|
38
|
+
/** Submits the selection with a surrounding `<form>`. A form reset does not reach it. */
|
|
39
|
+
name?: string;
|
|
40
|
+
/** Holds the surrounding form back while nothing is selected, and focuses the trigger. */
|
|
41
|
+
required?: boolean;
|
|
42
|
+
onchange?: (value: string | null) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
options,
|
|
47
|
+
value = $bindable(null),
|
|
48
|
+
placeholder = "Select…",
|
|
49
|
+
clearable = false,
|
|
50
|
+
disabled = false,
|
|
51
|
+
option,
|
|
52
|
+
clearLabel = "Clear selection",
|
|
53
|
+
loading = false,
|
|
54
|
+
loadingLabel = "Loading…",
|
|
55
|
+
class: className,
|
|
56
|
+
name,
|
|
57
|
+
required = false,
|
|
58
|
+
onchange,
|
|
59
|
+
...rest
|
|
60
|
+
}: Props = $props();
|
|
61
|
+
|
|
62
|
+
// `popovertarget` and `aria-activedescendant` both need document-unique ids, and `$props.id()`
|
|
63
|
+
// is the only source of one that is stable across server render and hydration.
|
|
64
|
+
const uid = $props.id();
|
|
65
|
+
const panelId = `plinth-select-${uid}`;
|
|
66
|
+
const listboxId = `${panelId}-listbox`;
|
|
67
|
+
const anchorName = `--${panelId}`;
|
|
68
|
+
const optionId = (index: number) => `${panelId}-option-${index}`;
|
|
69
|
+
|
|
70
|
+
let isOpen = $state(false);
|
|
71
|
+
let highlightedValue = $state<string | null>(null);
|
|
72
|
+
let trigger = $state<HTMLButtonElement>();
|
|
73
|
+
let panel = $state<OptionPanel>();
|
|
74
|
+
|
|
75
|
+
// The listbox is named by the trigger, so whatever the consumer labelled the trigger with names
|
|
76
|
+
// both. That needs the trigger's real id, which is the consumer's when they gave it one.
|
|
77
|
+
const triggerId = $derived(rest.id ?? `${panelId}-trigger`);
|
|
78
|
+
|
|
79
|
+
const selected = $derived(options.find((candidate) => candidate.value === value) ?? null);
|
|
80
|
+
const unset = $derived(value === null || value === undefined || value === "");
|
|
81
|
+
const shown = $derived.by(() => {
|
|
82
|
+
if (selected) return selected.label;
|
|
83
|
+
if (unset) return placeholder;
|
|
84
|
+
return loading ? "" : value;
|
|
85
|
+
});
|
|
86
|
+
const showClear = $derived(clearable && !disabled && value !== null && value !== undefined);
|
|
87
|
+
|
|
88
|
+
// The highlight is held as a value rather than an index so that an option list that changes
|
|
89
|
+
// under it cannot silently move it onto a different row.
|
|
90
|
+
const highlighted = $derived.by(() => {
|
|
91
|
+
const index = options.findIndex((candidate) => candidate.value === highlightedValue);
|
|
92
|
+
|
|
93
|
+
return index >= 0 && !options[index]?.disabled ? index : nextSelectableIndex(options, 0, 1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const activeOptionId = $derived(isOpen && highlighted >= 0 ? optionId(highlighted) : undefined);
|
|
97
|
+
|
|
98
|
+
const commit = (next: string | null) => {
|
|
99
|
+
if (next === value) return;
|
|
100
|
+
|
|
101
|
+
value = next;
|
|
102
|
+
onchange?.(next);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const choose = (candidate: SelectOption) => {
|
|
106
|
+
if (candidate.disabled) return;
|
|
107
|
+
|
|
108
|
+
commit(candidate.value);
|
|
109
|
+
trigger?.focus();
|
|
110
|
+
panel?.hide();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const clear = () => {
|
|
114
|
+
commit(null);
|
|
115
|
+
trigger?.focus();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const typeahead = new Typeahead();
|
|
119
|
+
|
|
120
|
+
const jumpTo = (char: string) => {
|
|
121
|
+
if (isOpen) {
|
|
122
|
+
const match = typeaheadMatch(options, highlighted, typeahead.add(char));
|
|
123
|
+
if (match >= 0) highlightedValue = options[match].value;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const current = options.findIndex((candidate) => candidate.value === value);
|
|
127
|
+
const match = typeaheadMatch(options, current, typeahead.add(char));
|
|
128
|
+
if (match >= 0) commit(options[match].value);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const move = (delta: number) => {
|
|
132
|
+
const next = nextSelectableIndex(options, highlighted + delta, delta);
|
|
133
|
+
if (next !== -1) highlightedValue = options[next]?.value ?? null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
137
|
+
if (disabled) return;
|
|
138
|
+
|
|
139
|
+
// Enter and Space on the closed trigger are the button's own activation, which `popovertarget`
|
|
140
|
+
// already turns into an open. Only the arrows have to ask for it.
|
|
141
|
+
switch (event.key) {
|
|
142
|
+
case "ArrowDown":
|
|
143
|
+
case "ArrowUp":
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
if (!isOpen) trigger?.click();
|
|
146
|
+
else move(event.key === "ArrowDown" ? 1 : -1);
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case "Home":
|
|
150
|
+
case "End": {
|
|
151
|
+
if (!isOpen) return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
|
|
154
|
+
const forwards = event.key === "Home";
|
|
155
|
+
const edge = nextSelectableIndex(
|
|
156
|
+
options,
|
|
157
|
+
forwards ? 0 : options.length - 1,
|
|
158
|
+
forwards ? 1 : -1,
|
|
159
|
+
);
|
|
160
|
+
if (edge !== -1) highlightedValue = options[edge]?.value ?? null;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "Enter":
|
|
165
|
+
case " ": {
|
|
166
|
+
if (event.key === " " && typeahead.typing) {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
jumpTo(" ");
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (!isOpen) return;
|
|
172
|
+
// Ahead of the button's own activation, which would otherwise toggle the panel back open
|
|
173
|
+
// in the same keystroke that picked a row.
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
|
|
176
|
+
const candidate = options[highlighted];
|
|
177
|
+
if (candidate) choose(candidate);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case "Tab":
|
|
182
|
+
// The panel holds no focusable content, so the default Tab already continues from the
|
|
183
|
+
// trigger; all that is left is to take the list off the screen with it.
|
|
184
|
+
if (isOpen) panel?.hide();
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
default:
|
|
188
|
+
if (!isTypedKey(event)) return;
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
jumpTo(event.key);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const onOpenChange = (open: boolean) => {
|
|
195
|
+
isOpen = open;
|
|
196
|
+
if (open) highlightedValue = value;
|
|
197
|
+
};
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<!--
|
|
201
|
+
@component
|
|
202
|
+
A single-select listbox: a trigger showing the current selection, and a top-layer panel listing
|
|
203
|
+
the options. For a list long enough that scrolling it stops being reasonable, `Combobox` is the
|
|
204
|
+
one to reach for — it types into the control bar itself.
|
|
205
|
+
|
|
206
|
+
It is built on the native popover API, so re-clicking the trigger, clicking outside and pressing
|
|
207
|
+
Escape all dismiss it without a listener of our own, and the panel sits in the top layer — above a
|
|
208
|
+
`Modal`, and out of every overflow and stacking context on the page — with no z-index anywhere.
|
|
209
|
+
|
|
210
|
+
Clearing is a ✕ on the trigger rather than a blank row in the list, so an empty selection can
|
|
211
|
+
never be mistaken for a real option.
|
|
212
|
+
|
|
213
|
+
The trigger carries `role="combobox"` and every attribute passed in, which is where an
|
|
214
|
+
`aria-labelledby` pointing at the consumer's own `<label>` goes. Focus never leaves it: the rows
|
|
215
|
+
are pointed at by `aria-activedescendant` instead of being focused. `class` styles the control —
|
|
216
|
+
the width belongs to the wrapper, since the panel matches it.
|
|
217
|
+
|
|
218
|
+
Typing the first letters of a label works as in a native select: on the closed trigger it picks the
|
|
219
|
+
row, with the list open it moves the highlight, and the same letter again cycles through the rows
|
|
220
|
+
it starts. `required` holds a surrounding form back while nothing is selected, and `loading` shows a
|
|
221
|
+
spinner while the options are on their way.
|
|
222
|
+
|
|
223
|
+
```svelte
|
|
224
|
+
<span id="size-label">Size</span>
|
|
225
|
+
<Select
|
|
226
|
+
bind:value={size}
|
|
227
|
+
options={SIZES}
|
|
228
|
+
aria-labelledby="size-label"
|
|
229
|
+
placeholder="Any size"
|
|
230
|
+
clearable
|
|
231
|
+
class="max-w-xs"
|
|
232
|
+
/>
|
|
233
|
+
```
|
|
234
|
+
-->
|
|
235
|
+
|
|
236
|
+
<div class={["plinth-select relative flex", className]}>
|
|
237
|
+
<button
|
|
238
|
+
{...rest}
|
|
239
|
+
bind:this={trigger}
|
|
240
|
+
id={triggerId}
|
|
241
|
+
type="button"
|
|
242
|
+
class={[
|
|
243
|
+
"select min-h-11 w-full justify-start text-start",
|
|
244
|
+
showClear && "pe-16",
|
|
245
|
+
// One class and not two: daisyUI dims the text of a disabled `<select>` through a rule that
|
|
246
|
+
// only reaches a real `select` element, and two Tailwind opacities on the same property
|
|
247
|
+
// would be settled by their order in the stylesheet rather than by the order written here.
|
|
248
|
+
disabled ? "text-base-content/40" : unset && "text-base-content/50",
|
|
249
|
+
]}
|
|
250
|
+
role="combobox"
|
|
251
|
+
{disabled}
|
|
252
|
+
popovertarget={panelId}
|
|
253
|
+
aria-haspopup="listbox"
|
|
254
|
+
aria-expanded={isOpen}
|
|
255
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
256
|
+
aria-activedescendant={activeOptionId}
|
|
257
|
+
aria-busy={loading || undefined}
|
|
258
|
+
style:anchor-name={anchorName}
|
|
259
|
+
onkeydown={onKeydown}
|
|
260
|
+
>
|
|
261
|
+
<span class="truncate">{shown}</span>
|
|
262
|
+
{#if loading}
|
|
263
|
+
<span class="loading ms-auto loading-xs loading-spinner" aria-hidden="true"></span>
|
|
264
|
+
{/if}
|
|
265
|
+
</button>
|
|
266
|
+
|
|
267
|
+
{#if showClear}
|
|
268
|
+
<!-- Outside the trigger, because `.select` clips its overflow and a button may not nest in a
|
|
269
|
+
button. The end padding above is what keeps the label from running under it. -->
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
class="btn absolute inset-y-0 end-7 my-auto btn-circle size-8 btn-ghost text-xs"
|
|
273
|
+
aria-label={clearLabel}
|
|
274
|
+
onclick={clear}
|
|
275
|
+
>
|
|
276
|
+
✕
|
|
277
|
+
</button>
|
|
278
|
+
{/if}
|
|
279
|
+
|
|
280
|
+
{#if name || required}
|
|
281
|
+
<input
|
|
282
|
+
class="sr-only"
|
|
283
|
+
tabindex="-1"
|
|
284
|
+
aria-hidden="true"
|
|
285
|
+
autocomplete="off"
|
|
286
|
+
{name}
|
|
287
|
+
{disabled}
|
|
288
|
+
{required}
|
|
289
|
+
value={value ?? ""}
|
|
290
|
+
onfocus={() => trigger?.focus()}
|
|
291
|
+
/>
|
|
292
|
+
{/if}
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<OptionPanel
|
|
296
|
+
bind:this={panel}
|
|
297
|
+
id={panelId}
|
|
298
|
+
{anchorName}
|
|
299
|
+
{listboxId}
|
|
300
|
+
anchor={trigger}
|
|
301
|
+
{options}
|
|
302
|
+
{value}
|
|
303
|
+
{highlighted}
|
|
304
|
+
{optionId}
|
|
305
|
+
{option}
|
|
306
|
+
emptyLabel={loading ? loadingLabel : undefined}
|
|
307
|
+
labelledBy={triggerId}
|
|
308
|
+
class="plinth-select-panel"
|
|
309
|
+
onopenchange={onOpenChange}
|
|
310
|
+
onhighlight={(candidate) => (highlightedValue = candidate)}
|
|
311
|
+
onchoose={choose}
|
|
312
|
+
/>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ClassValue, HTMLButtonAttributes } from "svelte/elements";
|
|
3
|
+
import { type SelectOption } from "./select.js";
|
|
4
|
+
type Props = Omit<HTMLButtonAttributes, "children" | "class" | "disabled" | "name" | "onchange" | "value"> & {
|
|
5
|
+
/** The rows, in the order they are shown. `value` is the key, so it has to be unique. */
|
|
6
|
+
options: readonly SelectOption[];
|
|
7
|
+
/** The selected `value`, or `null`. Bindable, and also reported through {@link Props.onchange}. */
|
|
8
|
+
value?: string | null;
|
|
9
|
+
/** Shown on the trigger while nothing is selected, and as the closed control's accessible text. */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Whether the selection can be taken back to `null`, through a ✕ on the trigger. */
|
|
12
|
+
clearable?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** Custom rendering for a row. The default is the label. */
|
|
15
|
+
option?: Snippet<[SelectOption]>;
|
|
16
|
+
/** Accessible name of the clear button. A prop because the library ships no translations. */
|
|
17
|
+
clearLabel?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Shows a spinner while the options load, and `loadingLabel` in an empty list. A `value` the
|
|
20
|
+
* options do not hold yet shows as nothing rather than as itself.
|
|
21
|
+
*/
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
loadingLabel?: string;
|
|
24
|
+
/** Classes for the control — width, margins. The trigger is where every other attribute goes. */
|
|
25
|
+
class?: ClassValue;
|
|
26
|
+
/** Submits the selection with a surrounding `<form>`. A form reset does not reach it. */
|
|
27
|
+
name?: string;
|
|
28
|
+
/** Holds the surrounding form back while nothing is selected, and focuses the trigger. */
|
|
29
|
+
required?: boolean;
|
|
30
|
+
onchange?: (value: string | null) => void;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* A single-select listbox: a trigger showing the current selection, and a top-layer panel listing
|
|
34
|
+
* the options. For a list long enough that scrolling it stops being reasonable, `Combobox` is the
|
|
35
|
+
* one to reach for — it types into the control bar itself.
|
|
36
|
+
*
|
|
37
|
+
* It is built on the native popover API, so re-clicking the trigger, clicking outside and pressing
|
|
38
|
+
* Escape all dismiss it without a listener of our own, and the panel sits in the top layer — above a
|
|
39
|
+
* `Modal`, and out of every overflow and stacking context on the page — with no z-index anywhere.
|
|
40
|
+
*
|
|
41
|
+
* Clearing is a ✕ on the trigger rather than a blank row in the list, so an empty selection can
|
|
42
|
+
* never be mistaken for a real option.
|
|
43
|
+
*
|
|
44
|
+
* The trigger carries `role="combobox"` and every attribute passed in, which is where an
|
|
45
|
+
* `aria-labelledby` pointing at the consumer's own `<label>` goes. Focus never leaves it: the rows
|
|
46
|
+
* are pointed at by `aria-activedescendant` instead of being focused. `class` styles the control —
|
|
47
|
+
* the width belongs to the wrapper, since the panel matches it.
|
|
48
|
+
*
|
|
49
|
+
* Typing the first letters of a label works as in a native select: on the closed trigger it picks the
|
|
50
|
+
* row, with the list open it moves the highlight, and the same letter again cycles through the rows
|
|
51
|
+
* it starts. `required` holds a surrounding form back while nothing is selected, and `loading` shows a
|
|
52
|
+
* spinner while the options are on their way.
|
|
53
|
+
*
|
|
54
|
+
* ```svelte
|
|
55
|
+
* <span id="size-label">Size</span>
|
|
56
|
+
* <Select
|
|
57
|
+
* bind:value={size}
|
|
58
|
+
* options={SIZES}
|
|
59
|
+
* aria-labelledby="size-label"
|
|
60
|
+
* placeholder="Any size"
|
|
61
|
+
* clearable
|
|
62
|
+
* class="max-w-xs"
|
|
63
|
+
* />
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
declare const Select: import("svelte").Component<Props, {}, "value">;
|
|
67
|
+
type Select = ReturnType<typeof Select>;
|
|
68
|
+
export default Select;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
|
|
4
|
+
type Props = HTMLAttributes<HTMLDivElement> & {
|
|
5
|
+
/**
|
|
6
|
+
* Corner shape. A prop and not a class because an unlayered component rule beats a Tailwind
|
|
7
|
+
* utility whatever the source order, so `rounded-full` from the outside could never win.
|
|
8
|
+
* `"none"` is for a caller clipping the block to a silhouette of its own.
|
|
9
|
+
*/
|
|
10
|
+
rounded?: "field" | "full" | "none";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let { class: className, rounded = "field", ...rest }: Props = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<!--
|
|
17
|
+
@component
|
|
18
|
+
The block that stands in for content that has not arrived: a resting wash with a band of the brand
|
|
19
|
+
colour sweeping across it, left to right, on the same calm 1.7s linear cycle the `Logo` orbits on.
|
|
20
|
+
|
|
21
|
+
It sets no width and no height, so **give it a size** — `class="h-4 w-40"` — and it takes any shape
|
|
22
|
+
you clip it to, because the sweep is painted on the element itself rather than on a pseudo-element.
|
|
23
|
+
Corner shape is the one piece of geometry it does own, as the `rounded` prop; everything else is
|
|
24
|
+
yours.
|
|
25
|
+
|
|
26
|
+
Like the logo it renders no text and carries no role, which keeps it silent to assistive
|
|
27
|
+
technology: the container that knows what is loading is the one that should say so.
|
|
28
|
+
|
|
29
|
+
```svelte
|
|
30
|
+
<div role="status" aria-label="Loading releases" class="flex flex-col gap-2">
|
|
31
|
+
<Skeleton class="h-4 w-48" />
|
|
32
|
+
<Skeleton class="h-4 w-64" />
|
|
33
|
+
<Skeleton class="size-10" rounded="full" />
|
|
34
|
+
</div>
|
|
35
|
+
```
|
|
36
|
+
-->
|
|
37
|
+
|
|
38
|
+
<div {...rest} class={["plinth-skeleton", className]} data-rounded={rounded}></div>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
@keyframes sweep {
|
|
42
|
+
/* Past both edges at either end: at 200% background-size the lit band is half a box wide, and
|
|
43
|
+
these two stops are what carry it fully off screen instead of parking it at the rim. */
|
|
44
|
+
from {
|
|
45
|
+
background-position: 150% 0;
|
|
46
|
+
}
|
|
47
|
+
to {
|
|
48
|
+
background-position: -50% 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.plinth-skeleton {
|
|
53
|
+
/* Tinted, not grey: the sweep is the brand's, mixed into the same ink wash the library uses
|
|
54
|
+
for a resting surface everywhere else. Both stay transparent mixes so the block reads on
|
|
55
|
+
whatever surface it is dropped onto, in either theme. */
|
|
56
|
+
--skeleton-base: color-mix(in oklch, var(--color-base-content) 11%, transparent);
|
|
57
|
+
--skeleton-sheen: color-mix(in oklch, var(--color-primary) 26%, transparent);
|
|
58
|
+
|
|
59
|
+
display: block;
|
|
60
|
+
background-color: var(--skeleton-base);
|
|
61
|
+
|
|
62
|
+
/* On the element rather than on an `::after`: a pseudo-element would need its own overflow
|
|
63
|
+
clip and would ignore a `clip-path` the caller hands the block, which is exactly how the
|
|
64
|
+
pick-shaped placeholder in the showcase is built. */
|
|
65
|
+
background-image: linear-gradient(
|
|
66
|
+
90deg,
|
|
67
|
+
transparent 20%,
|
|
68
|
+
var(--skeleton-sheen) 50%,
|
|
69
|
+
transparent 80%
|
|
70
|
+
);
|
|
71
|
+
background-repeat: no-repeat;
|
|
72
|
+
background-size: 200% 100%;
|
|
73
|
+
|
|
74
|
+
/* The logo's tempo, deliberately: two loading states on one screen beating at different
|
|
75
|
+
speeds is what makes a page look nervous. */
|
|
76
|
+
animation: sweep 1.7s linear infinite;
|
|
77
|
+
|
|
78
|
+
&[data-rounded="field"] {
|
|
79
|
+
border-radius: var(--radius-field, 0.5rem);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
&[data-rounded="full"] {
|
|
83
|
+
border-radius: 9999px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@media (prefers-reduced-motion: reduce) {
|
|
87
|
+
/* Where `Logo` degrades its orbit to a pulse rather than to nothing — an outline that stops
|
|
88
|
+
moving has stopped saying anything at all — this one stops outright. A skeleton's
|
|
89
|
+
affordance is that it holds the space its content will take, and that survives standing
|
|
90
|
+
still, so all the motion is doing here is decorating. It settles on the tone the sweep
|
|
91
|
+
averages to, which is what it looks like mid-pass. */
|
|
92
|
+
background-image: none;
|
|
93
|
+
background-color: color-mix(in oklch, var(--skeleton-sheen) 45%, var(--skeleton-base));
|
|
94
|
+
animation: none;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
2
|
+
type Props = HTMLAttributes<HTMLDivElement> & {
|
|
3
|
+
/**
|
|
4
|
+
* Corner shape. A prop and not a class because an unlayered component rule beats a Tailwind
|
|
5
|
+
* utility whatever the source order, so `rounded-full` from the outside could never win.
|
|
6
|
+
* `"none"` is for a caller clipping the block to a silhouette of its own.
|
|
7
|
+
*/
|
|
8
|
+
rounded?: "field" | "full" | "none";
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The block that stands in for content that has not arrived: a resting wash with a band of the brand
|
|
12
|
+
* colour sweeping across it, left to right, on the same calm 1.7s linear cycle the `Logo` orbits on.
|
|
13
|
+
*
|
|
14
|
+
* It sets no width and no height, so **give it a size** — `class="h-4 w-40"` — and it takes any shape
|
|
15
|
+
* you clip it to, because the sweep is painted on the element itself rather than on a pseudo-element.
|
|
16
|
+
* Corner shape is the one piece of geometry it does own, as the `rounded` prop; everything else is
|
|
17
|
+
* yours.
|
|
18
|
+
*
|
|
19
|
+
* Like the logo it renders no text and carries no role, which keeps it silent to assistive
|
|
20
|
+
* technology: the container that knows what is loading is the one that should say so.
|
|
21
|
+
*
|
|
22
|
+
* ```svelte
|
|
23
|
+
* <div role="status" aria-label="Loading releases" class="flex flex-col gap-2">
|
|
24
|
+
* <Skeleton class="h-4 w-48" />
|
|
25
|
+
* <Skeleton class="h-4 w-64" />
|
|
26
|
+
* <Skeleton class="size-10" rounded="full" />
|
|
27
|
+
* </div>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
declare const Skeleton: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type Skeleton = ReturnType<typeof Skeleton>;
|
|
32
|
+
export default Skeleton;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends keyof SvelteHTMLElements = 'span'">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { SvelteHTMLElements } from "svelte/elements";
|
|
4
|
+
import { tooltipPanel } from "../attachments/tooltip.js";
|
|
5
|
+
|
|
6
|
+
type Props = SvelteHTMLElements[T] & {
|
|
7
|
+
/** Tag rendered as the host. It is this component's root, so classes, event handlers and
|
|
8
|
+
aria attributes land on the real element rather than on a wrapper. */
|
|
9
|
+
as?: T;
|
|
10
|
+
children: Snippet;
|
|
11
|
+
tooltip: Snippet;
|
|
12
|
+
/** Suppresses the panel while keeping the host and the wiring identical. */
|
|
13
|
+
tooltipDisabled?: boolean | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Deliberately not defaulted here: `"span"` is not assignable to `T`, since a caller may
|
|
17
|
+
// instantiate `T` as something narrower without ever passing `as`. The fallback lives on
|
|
18
|
+
// `svelte:element`, whose `this` takes any tag name.
|
|
19
|
+
let { as, children, tooltip, tooltipDisabled, ...rest }: Props = $props();
|
|
20
|
+
|
|
21
|
+
let panel = $state<HTMLDivElement>();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<!--
|
|
25
|
+
@component
|
|
26
|
+
Renders the host element itself — `as`, a span by default — carrying a tooltip whose content is a
|
|
27
|
+
snippet. Everything else you pass lands on the host, so it stays a normal element in its parent's
|
|
28
|
+
layout. The panel is written declaratively so the snippet keeps its component context and any
|
|
29
|
+
scoped styles the caller relies on.
|
|
30
|
+
|
|
31
|
+
The host is focusable by default (`tabindex={0}`) because the show/hide wiring listens for
|
|
32
|
+
focusin/focusout, and a bare span never receives either — the tooltip would silently be
|
|
33
|
+
mouse-only. Pass your own `tabindex` to override that.
|
|
34
|
+
|
|
35
|
+
For plain text on an element you already render, the `tooltip` attachment is lighter: it creates
|
|
36
|
+
its panel on first hover, while this component's panel is in the DOM from the start (hidden by
|
|
37
|
+
`popover`, so it neither lays out nor paints). At table scale that is a fine trade; for hundreds
|
|
38
|
+
of instances or expensive content, prefer the attachment or a `Dropdown`, which mounts on demand.
|
|
39
|
+
|
|
40
|
+
```svelte
|
|
41
|
+
<Tooltip as="button" class="btn" onclick={save}>
|
|
42
|
+
{#snippet tooltip()}
|
|
43
|
+
Saves <strong>every</strong> pending change
|
|
44
|
+
{/snippet}
|
|
45
|
+
Save
|
|
46
|
+
</Tooltip>
|
|
47
|
+
```
|
|
48
|
+
-->
|
|
49
|
+
|
|
50
|
+
<svelte:element
|
|
51
|
+
this={as ?? "span"}
|
|
52
|
+
tabindex={0}
|
|
53
|
+
{...rest}
|
|
54
|
+
{@attach tooltipPanel(panel, { disabled: tooltipDisabled ?? false })}
|
|
55
|
+
>
|
|
56
|
+
{@render children()}
|
|
57
|
+
</svelte:element>
|
|
58
|
+
<div bind:this={panel} popover="manual" role="tooltip">
|
|
59
|
+
{@render tooltip()}
|
|
60
|
+
</div>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { SvelteHTMLElements } from "svelte/elements";
|
|
3
|
+
declare function $$render<T extends keyof SvelteHTMLElements = 'span'>(): {
|
|
4
|
+
props: SvelteHTMLElements[T] & {
|
|
5
|
+
/** Tag rendered as the host. It is this component's root, so classes, event handlers and
|
|
6
|
+
aria attributes land on the real element rather than on a wrapper. */
|
|
7
|
+
as?: T;
|
|
8
|
+
children: Snippet;
|
|
9
|
+
tooltip: Snippet;
|
|
10
|
+
/** Suppresses the panel while keeping the host and the wiring identical. */
|
|
11
|
+
tooltipDisabled?: boolean | null;
|
|
12
|
+
};
|
|
13
|
+
exports: {};
|
|
14
|
+
bindings: "";
|
|
15
|
+
slots: {};
|
|
16
|
+
events: {};
|
|
17
|
+
};
|
|
18
|
+
declare class __sveltets_Render<T extends keyof SvelteHTMLElements = 'span'> {
|
|
19
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
20
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
21
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
22
|
+
bindings(): "";
|
|
23
|
+
exports(): {};
|
|
24
|
+
}
|
|
25
|
+
interface $$IsomorphicComponent {
|
|
26
|
+
new <T extends keyof SvelteHTMLElements = 'span'>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
27
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
28
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
29
|
+
<T extends keyof SvelteHTMLElements = 'span'>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
30
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Renders the host element itself — `as`, a span by default — carrying a tooltip whose content is a
|
|
34
|
+
* snippet. Everything else you pass lands on the host, so it stays a normal element in its parent's
|
|
35
|
+
* layout. The panel is written declaratively so the snippet keeps its component context and any
|
|
36
|
+
* scoped styles the caller relies on.
|
|
37
|
+
*
|
|
38
|
+
* The host is focusable by default (`tabindex={0}`) because the show/hide wiring listens for
|
|
39
|
+
* focusin/focusout, and a bare span never receives either — the tooltip would silently be
|
|
40
|
+
* mouse-only. Pass your own `tabindex` to override that.
|
|
41
|
+
*
|
|
42
|
+
* For plain text on an element you already render, the `tooltip` attachment is lighter: it creates
|
|
43
|
+
* its panel on first hover, while this component's panel is in the DOM from the start (hidden by
|
|
44
|
+
* `popover`, so it neither lays out nor paints). At table scale that is a fine trade; for hundreds
|
|
45
|
+
* of instances or expensive content, prefer the attachment or a `Dropdown`, which mounts on demand.
|
|
46
|
+
*
|
|
47
|
+
* ```svelte
|
|
48
|
+
* <Tooltip as="button" class="btn" onclick={save}>
|
|
49
|
+
* {#snippet tooltip()}
|
|
50
|
+
* Saves <strong>every</strong> pending change
|
|
51
|
+
* {/snippet}
|
|
52
|
+
* Save
|
|
53
|
+
* </Tooltip>
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
declare const Tooltip: $$IsomorphicComponent;
|
|
57
|
+
type Tooltip<T extends keyof SvelteHTMLElements = 'span'> = InstanceType<typeof Tooltip<T>>;
|
|
58
|
+
export default Tooltip;
|