@viniaraujo68/plinth 0.0.0-stage → 0.3.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 +2 -0
- package/dist/attachments/index.js +2 -0
- package/dist/attachments/sortable.d.ts +6 -0
- package/dist/attachments/sortable.js +227 -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/DragHandle.svelte +26 -0
- package/dist/components/DragHandle.svelte.d.ts +7 -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 +26 -0
- package/dist/components/index.js +38 -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,380 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack, type Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue, HTMLInputAttributes } from "svelte/elements";
|
|
4
|
+
import OptionPanel from "./OptionPanel.svelte";
|
|
5
|
+
import { matchesSelectQuery, nextSelectableIndex, type SelectOption } from "./select.js";
|
|
6
|
+
|
|
7
|
+
type Props = Omit<
|
|
8
|
+
HTMLInputAttributes,
|
|
9
|
+
| "children"
|
|
10
|
+
| "class"
|
|
11
|
+
| "disabled"
|
|
12
|
+
| "name"
|
|
13
|
+
| "onblur"
|
|
14
|
+
| "onchange"
|
|
15
|
+
| "onclick"
|
|
16
|
+
| "onfocus"
|
|
17
|
+
| "oninput"
|
|
18
|
+
| "onkeydown"
|
|
19
|
+
| "onpointerdown"
|
|
20
|
+
| "type"
|
|
21
|
+
| "value"
|
|
22
|
+
> & {
|
|
23
|
+
/** The rows, in the order they are shown. `value` is the key, so it has to be unique. */
|
|
24
|
+
options: readonly SelectOption[];
|
|
25
|
+
/** The selected `value`, or `null`. Bindable, and also reported through {@link Props.onchange}. */
|
|
26
|
+
value?: string | null;
|
|
27
|
+
/** Shown in the field while nothing is selected. */
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Whether the selection can be taken back to `null`, through a ✕ in the field. */
|
|
30
|
+
clearable?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Matching for the typed query. The default looks for every word of the query in the label,
|
|
34
|
+
* in any order, case- AND accent-insensitive, so `rocha otavio` finds `Otávio Rocha`.
|
|
35
|
+
*
|
|
36
|
+
* Only consulted once something has been typed: an untouched field always shows every option,
|
|
37
|
+
* which is one less case a custom matcher has to handle.
|
|
38
|
+
*/
|
|
39
|
+
filter?: (option: SelectOption, query: string) => boolean;
|
|
40
|
+
/** Custom rendering for a row. The default is the label. */
|
|
41
|
+
option?: Snippet<[SelectOption]>;
|
|
42
|
+
/** Shown in place of the list when the query matches nothing. */
|
|
43
|
+
emptyLabel?: string;
|
|
44
|
+
/** Accessible name of the clear button. A prop because the library ships no translations. */
|
|
45
|
+
clearLabel?: string;
|
|
46
|
+
/** Classes for the control — width, margins. The field is where every other attribute goes. */
|
|
47
|
+
class?: ClassValue;
|
|
48
|
+
/**
|
|
49
|
+
* Submits the selection with a surrounding `<form>`, through a hidden input carrying the
|
|
50
|
+
* selected `value` — never the text on screen, which is a label and, mid-query, not even that.
|
|
51
|
+
*
|
|
52
|
+
* It buys form participation and nothing else: no constraint validation, no `required`, and
|
|
53
|
+
* no reset. A form that needs those wants a real `<select>`.
|
|
54
|
+
*/
|
|
55
|
+
name?: string;
|
|
56
|
+
onchange?: (value: string | null) => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let {
|
|
60
|
+
options,
|
|
61
|
+
value = $bindable(null),
|
|
62
|
+
placeholder = "Search…",
|
|
63
|
+
clearable = false,
|
|
64
|
+
disabled = false,
|
|
65
|
+
filter = matchesSelectQuery,
|
|
66
|
+
option,
|
|
67
|
+
emptyLabel = "No matches",
|
|
68
|
+
clearLabel = "Clear selection",
|
|
69
|
+
class: className,
|
|
70
|
+
name,
|
|
71
|
+
onchange,
|
|
72
|
+
...rest
|
|
73
|
+
}: Props = $props();
|
|
74
|
+
|
|
75
|
+
// `aria-controls` and `aria-activedescendant` both need document-unique ids, and `$props.id()`
|
|
76
|
+
// is the only source of one that is stable across server render and hydration.
|
|
77
|
+
const uid = $props.id();
|
|
78
|
+
const panelId = `plinth-combobox-${uid}`;
|
|
79
|
+
const listboxId = `${panelId}-listbox`;
|
|
80
|
+
const anchorName = `--${panelId}`;
|
|
81
|
+
const optionId = (index: number) => `${panelId}-option-${index}`;
|
|
82
|
+
|
|
83
|
+
const labelOf = (candidate: string | null) =>
|
|
84
|
+
options.find((row) => row.value === candidate)?.label ?? "";
|
|
85
|
+
|
|
86
|
+
let isOpen = $state(false);
|
|
87
|
+
let highlightedValue = $state<string | null>(null);
|
|
88
|
+
// Initialised rather than left to an effect: an effect never runs on the server, so a control
|
|
89
|
+
// rendered with a selection would ship blank HTML and only fill in after hydration.
|
|
90
|
+
let text = $state(labelOf(value));
|
|
91
|
+
// Whether the text on screen is a query someone typed rather than the selected label put there
|
|
92
|
+
// by the control. Until it is, the whole list is offered -- opening on a selection should not
|
|
93
|
+
// filter everything else away just because one label happens to be in the field.
|
|
94
|
+
let isQuery = $state(false);
|
|
95
|
+
let input = $state<HTMLInputElement>();
|
|
96
|
+
let panel = $state<OptionPanel>();
|
|
97
|
+
// Focus opens the list -- unless a pointer is what brought the focus, in which case the click
|
|
98
|
+
// does it instead. Opening on the focus a mousedown causes would put the panel up just in time
|
|
99
|
+
// for the pointerup that follows to light-dismiss it again.
|
|
100
|
+
let focusedByPointer = false;
|
|
101
|
+
|
|
102
|
+
const inputId = $derived(rest.id ?? `${panelId}-input`);
|
|
103
|
+
|
|
104
|
+
const selected = $derived(options.find((candidate) => candidate.value === value) ?? null);
|
|
105
|
+
const showClear = $derived(clearable && !disabled && value !== null && value !== undefined);
|
|
106
|
+
|
|
107
|
+
const query = $derived(isQuery ? text : "");
|
|
108
|
+
const visible = $derived(
|
|
109
|
+
query.trim() === "" ? options : options.filter((candidate) => filter(candidate, query)),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// The highlight is held as a value rather than an index so that filtering cannot silently move
|
|
113
|
+
// it onto a different option: a row that stops matching takes the highlight back to the top of
|
|
114
|
+
// what is left, which is what someone still typing expects, and no effect has to watch the query
|
|
115
|
+
// to make it happen.
|
|
116
|
+
const highlighted = $derived.by(() => {
|
|
117
|
+
const index = visible.findIndex((candidate) => candidate.value === highlightedValue);
|
|
118
|
+
|
|
119
|
+
return index >= 0 && !visible[index]?.disabled ? index : nextSelectableIndex(visible, 0, 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const activeOptionId = $derived(isOpen && highlighted >= 0 ? optionId(highlighted) : undefined);
|
|
123
|
+
|
|
124
|
+
// Puts the selected label in the field and marks it as a label rather than a query. Whenever the
|
|
125
|
+
// field still has the keyboard the label is also selected, which is the whole of what makes the
|
|
126
|
+
// next keystroke a new query instead of an append: a caret parked after `Itaipava` turns `sao`
|
|
127
|
+
// into `Itaipavasao`, which matches nothing. It is the same state a click on the bar leaves
|
|
128
|
+
// behind, so every way back into a settled field behaves alike.
|
|
129
|
+
//
|
|
130
|
+
// Written straight to the element as well as to the state: `bind:value` lands a tick later and
|
|
131
|
+
// puts the caret at the end when it does, which would undo the selection made here. The binding
|
|
132
|
+
// then finds the element already holding the value and leaves it -- and the selection -- alone.
|
|
133
|
+
const showSelectedLabel = () => {
|
|
134
|
+
isQuery = false;
|
|
135
|
+
text = selected?.label ?? "";
|
|
136
|
+
|
|
137
|
+
if (input && document.activeElement === input) {
|
|
138
|
+
input.value = text;
|
|
139
|
+
input.select();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// A selection made from outside -- a reset button, a value arriving with the data -- has to
|
|
144
|
+
// reach the field too. Only while the list is closed: an open control's text belongs to whoever
|
|
145
|
+
// is typing into it, and the close puts the label back anyway.
|
|
146
|
+
$effect(() => {
|
|
147
|
+
const label = selected?.label ?? "";
|
|
148
|
+
|
|
149
|
+
untrack(() => {
|
|
150
|
+
if (isOpen || label === text) return;
|
|
151
|
+
showSelectedLabel();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const commit = (next: string | null) => {
|
|
156
|
+
if (next === value) return;
|
|
157
|
+
|
|
158
|
+
value = next;
|
|
159
|
+
onchange?.(next);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const open = () => {
|
|
163
|
+
if (!disabled) panel?.show();
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const choose = (candidate: SelectOption) => {
|
|
167
|
+
if (candidate.disabled) return;
|
|
168
|
+
|
|
169
|
+
commit(candidate.value);
|
|
170
|
+
input?.focus();
|
|
171
|
+
// The close is what writes the chosen label into the field: `commit` has already moved the
|
|
172
|
+
// value, so the restore below reads the new one.
|
|
173
|
+
panel?.hide();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const clear = () => {
|
|
177
|
+
commit(null);
|
|
178
|
+
text = "";
|
|
179
|
+
isQuery = false;
|
|
180
|
+
input?.focus();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const move = (delta: number) => {
|
|
184
|
+
const next = nextSelectableIndex(visible, highlighted + delta, delta);
|
|
185
|
+
if (next !== -1) highlightedValue = visible[next]?.value ?? null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
189
|
+
if (disabled) return;
|
|
190
|
+
|
|
191
|
+
// Escape is left alone on purpose: the panel is a popover, so the browser's close watcher
|
|
192
|
+
// dismisses it -- and dismisses only it, leaving a surrounding modal open.
|
|
193
|
+
switch (event.key) {
|
|
194
|
+
case "ArrowDown":
|
|
195
|
+
case "ArrowUp":
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
if (!isOpen) open();
|
|
198
|
+
else move(event.key === "ArrowDown" ? 1 : -1);
|
|
199
|
+
break;
|
|
200
|
+
|
|
201
|
+
case "Home":
|
|
202
|
+
case "End": {
|
|
203
|
+
// In a text field these move the caret, which is what they should keep doing while there
|
|
204
|
+
// is no list to walk.
|
|
205
|
+
if (!isOpen) return;
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
|
|
208
|
+
const forwards = event.key === "Home";
|
|
209
|
+
const edge = nextSelectableIndex(
|
|
210
|
+
visible,
|
|
211
|
+
forwards ? 0 : visible.length - 1,
|
|
212
|
+
forwards ? 1 : -1,
|
|
213
|
+
);
|
|
214
|
+
if (edge !== -1) highlightedValue = visible[edge]?.value ?? null;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "Enter": {
|
|
219
|
+
if (!isOpen) return;
|
|
220
|
+
// A pick is not a submit: without this the Enter that chooses a row would also send the
|
|
221
|
+
// form the control sits in.
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
|
|
224
|
+
const candidate = visible[highlighted];
|
|
225
|
+
if (candidate) choose(candidate);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case "Tab":
|
|
230
|
+
if (isOpen) panel?.hide();
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const onInput = () => {
|
|
236
|
+
isQuery = true;
|
|
237
|
+
if (!isOpen) open();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const onFocus = () => {
|
|
241
|
+
if (focusedByPointer) return;
|
|
242
|
+
|
|
243
|
+
input?.select();
|
|
244
|
+
open();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const onClick = () => {
|
|
248
|
+
focusedByPointer = false;
|
|
249
|
+
// Not a toggle: a click on the bar always leaves it open with the selected label in it and
|
|
250
|
+
// that label selected, so the next keystroke replaces it. The panel light-dismisses itself on
|
|
251
|
+
// the pointerup that precedes this click, which is why re-opening is the whole job here.
|
|
252
|
+
open();
|
|
253
|
+
input?.select();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const onBlur = () => {
|
|
257
|
+
focusedByPointer = false;
|
|
258
|
+
// Everything that closes the list restores the text, so a half-typed query never survives as
|
|
259
|
+
// the label of a value the control does not hold.
|
|
260
|
+
panel?.hide();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Restoring the label belongs to the CLOSE and to nothing else. An open is just as often asked
|
|
264
|
+
// for by the keystroke that starts a query -- typing into a field Escape had closed -- and
|
|
265
|
+
// restoring there would overwrite that very character with the label and leave the caret after
|
|
266
|
+
// it, so the second keystroke would append. Nothing needs restoring on the way in anyway: every
|
|
267
|
+
// close already did it, and the effect above keeps a closed field in step.
|
|
268
|
+
const onOpenChange = (nowOpen: boolean) => {
|
|
269
|
+
isOpen = nowOpen;
|
|
270
|
+
highlightedValue = nowOpen ? value : null;
|
|
271
|
+
if (!nowOpen) showSelectedLabel();
|
|
272
|
+
};
|
|
273
|
+
</script>
|
|
274
|
+
|
|
275
|
+
<!--
|
|
276
|
+
@component
|
|
277
|
+
A single-select combobox whose control bar IS the search field: click it and type, with no second
|
|
278
|
+
field appearing anywhere. For a short list that is quicker to point at than to type at, `Select`
|
|
279
|
+
is the one to reach for.
|
|
280
|
+
|
|
281
|
+
Searching is accent-insensitive — typing `otavio` finds `Otávio` — and matches anywhere in the
|
|
282
|
+
label, which is the pair of things native type-ahead cannot do. `filter` replaces the matcher.
|
|
283
|
+
|
|
284
|
+
The text in the field is a view of the selection, never a value of its own: Escape, Tab, a click
|
|
285
|
+
outside, a blur and a pick all put the selected label back, and every label written into a field
|
|
286
|
+
that still has the keyboard is selected — as focusing and clicking select it — so typing straight
|
|
287
|
+
on starts a new query instead of appending to a label. Emptying the field and leaving therefore
|
|
288
|
+
reverts rather than clears — clearing is the ✕, and only the ✕. `name` submits the selected `value`
|
|
289
|
+
through a hidden input, so the text on screen is never what a form receives.
|
|
290
|
+
|
|
291
|
+
The panel is a native popover: it sits in the top layer, above a `Modal` and out of every overflow
|
|
292
|
+
and stacking context, with no z-index anywhere. The input carries `role="combobox"` with
|
|
293
|
+
`aria-autocomplete="list"` and `aria-activedescendant`, so focus stays in the field while the
|
|
294
|
+
arrows walk the list.
|
|
295
|
+
|
|
296
|
+
```svelte
|
|
297
|
+
<span id="airport-label">Airport</span>
|
|
298
|
+
<Combobox
|
|
299
|
+
bind:value={airport}
|
|
300
|
+
options={AIRPORTS}
|
|
301
|
+
aria-labelledby="airport-label"
|
|
302
|
+
placeholder="Search airports"
|
|
303
|
+
clearable
|
|
304
|
+
class="max-w-sm"
|
|
305
|
+
/>
|
|
306
|
+
```
|
|
307
|
+
-->
|
|
308
|
+
|
|
309
|
+
<div class={["plinth-combobox relative flex", className]}>
|
|
310
|
+
<input
|
|
311
|
+
{...rest}
|
|
312
|
+
bind:this={input}
|
|
313
|
+
bind:value={text}
|
|
314
|
+
id={inputId}
|
|
315
|
+
type="text"
|
|
316
|
+
class={[
|
|
317
|
+
"select min-h-11 w-full",
|
|
318
|
+
showClear && "pe-16",
|
|
319
|
+
// daisyUI dims the text of a disabled `<select>` through a rule that only reaches a real
|
|
320
|
+
// `select` element, so a disabled field would otherwise keep full-contrast text on the
|
|
321
|
+
// greyed-out surface it already gets.
|
|
322
|
+
disabled && "text-base-content/40",
|
|
323
|
+
]}
|
|
324
|
+
role="combobox"
|
|
325
|
+
{disabled}
|
|
326
|
+
{placeholder}
|
|
327
|
+
autocomplete="off"
|
|
328
|
+
autocorrect="off"
|
|
329
|
+
spellcheck="false"
|
|
330
|
+
aria-haspopup="listbox"
|
|
331
|
+
aria-autocomplete="list"
|
|
332
|
+
aria-expanded={isOpen}
|
|
333
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
334
|
+
aria-activedescendant={activeOptionId}
|
|
335
|
+
style:anchor-name={anchorName}
|
|
336
|
+
onpointerdown={() => (focusedByPointer = true)}
|
|
337
|
+
onfocus={onFocus}
|
|
338
|
+
onclick={onClick}
|
|
339
|
+
onblur={onBlur}
|
|
340
|
+
oninput={onInput}
|
|
341
|
+
onkeydown={onKeydown}
|
|
342
|
+
/>
|
|
343
|
+
|
|
344
|
+
{#if showClear}
|
|
345
|
+
<!-- The end padding above is what keeps the text from running under it, and the mousedown is
|
|
346
|
+
swallowed so that clearing never costs the field its focus. -->
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
class="btn absolute inset-y-0 end-7 my-auto btn-circle size-8 btn-ghost text-xs"
|
|
350
|
+
aria-label={clearLabel}
|
|
351
|
+
onmousedown={(event) => event.preventDefault()}
|
|
352
|
+
onclick={clear}
|
|
353
|
+
>
|
|
354
|
+
✕
|
|
355
|
+
</button>
|
|
356
|
+
{/if}
|
|
357
|
+
|
|
358
|
+
{#if name}
|
|
359
|
+
<input type="hidden" {name} value={value ?? ""} />
|
|
360
|
+
{/if}
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<OptionPanel
|
|
364
|
+
bind:this={panel}
|
|
365
|
+
id={panelId}
|
|
366
|
+
{anchorName}
|
|
367
|
+
{listboxId}
|
|
368
|
+
anchor={input}
|
|
369
|
+
{value}
|
|
370
|
+
{highlighted}
|
|
371
|
+
{optionId}
|
|
372
|
+
{option}
|
|
373
|
+
{emptyLabel}
|
|
374
|
+
options={visible}
|
|
375
|
+
labelledBy={inputId}
|
|
376
|
+
class="plinth-combobox-panel"
|
|
377
|
+
onopenchange={onOpenChange}
|
|
378
|
+
onhighlight={(candidate) => (highlightedValue = candidate)}
|
|
379
|
+
onchoose={choose}
|
|
380
|
+
/>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Snippet } from "svelte";
|
|
2
|
+
import type { ClassValue, HTMLInputAttributes } from "svelte/elements";
|
|
3
|
+
import { type SelectOption } from "./select.js";
|
|
4
|
+
type Props = Omit<HTMLInputAttributes, "children" | "class" | "disabled" | "name" | "onblur" | "onchange" | "onclick" | "onfocus" | "oninput" | "onkeydown" | "onpointerdown" | "type" | "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 in the field while nothing is selected. */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Whether the selection can be taken back to `null`, through a ✕ in the field. */
|
|
12
|
+
clearable?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Matching for the typed query. The default looks for every word of the query in the label,
|
|
16
|
+
* in any order, case- AND accent-insensitive, so `rocha otavio` finds `Otávio Rocha`.
|
|
17
|
+
*
|
|
18
|
+
* Only consulted once something has been typed: an untouched field always shows every option,
|
|
19
|
+
* which is one less case a custom matcher has to handle.
|
|
20
|
+
*/
|
|
21
|
+
filter?: (option: SelectOption, query: string) => boolean;
|
|
22
|
+
/** Custom rendering for a row. The default is the label. */
|
|
23
|
+
option?: Snippet<[SelectOption]>;
|
|
24
|
+
/** Shown in place of the list when the query matches nothing. */
|
|
25
|
+
emptyLabel?: string;
|
|
26
|
+
/** Accessible name of the clear button. A prop because the library ships no translations. */
|
|
27
|
+
clearLabel?: string;
|
|
28
|
+
/** Classes for the control — width, margins. The field is where every other attribute goes. */
|
|
29
|
+
class?: ClassValue;
|
|
30
|
+
/**
|
|
31
|
+
* Submits the selection with a surrounding `<form>`, through a hidden input carrying the
|
|
32
|
+
* selected `value` — never the text on screen, which is a label and, mid-query, not even that.
|
|
33
|
+
*
|
|
34
|
+
* It buys form participation and nothing else: no constraint validation, no `required`, and
|
|
35
|
+
* no reset. A form that needs those wants a real `<select>`.
|
|
36
|
+
*/
|
|
37
|
+
name?: string;
|
|
38
|
+
onchange?: (value: string | null) => void;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A single-select combobox whose control bar IS the search field: click it and type, with no second
|
|
42
|
+
* field appearing anywhere. For a short list that is quicker to point at than to type at, `Select`
|
|
43
|
+
* is the one to reach for.
|
|
44
|
+
*
|
|
45
|
+
* Searching is accent-insensitive — typing `otavio` finds `Otávio` — and matches anywhere in the
|
|
46
|
+
* label, which is the pair of things native type-ahead cannot do. `filter` replaces the matcher.
|
|
47
|
+
*
|
|
48
|
+
* The text in the field is a view of the selection, never a value of its own: Escape, Tab, a click
|
|
49
|
+
* outside, a blur and a pick all put the selected label back, and every label written into a field
|
|
50
|
+
* that still has the keyboard is selected — as focusing and clicking select it — so typing straight
|
|
51
|
+
* on starts a new query instead of appending to a label. Emptying the field and leaving therefore
|
|
52
|
+
* reverts rather than clears — clearing is the ✕, and only the ✕. `name` submits the selected `value`
|
|
53
|
+
* through a hidden input, so the text on screen is never what a form receives.
|
|
54
|
+
*
|
|
55
|
+
* The panel is a native popover: it sits in the top layer, above a `Modal` and out of every overflow
|
|
56
|
+
* and stacking context, with no z-index anywhere. The input carries `role="combobox"` with
|
|
57
|
+
* `aria-autocomplete="list"` and `aria-activedescendant`, so focus stays in the field while the
|
|
58
|
+
* arrows walk the list.
|
|
59
|
+
*
|
|
60
|
+
* ```svelte
|
|
61
|
+
* <span id="airport-label">Airport</span>
|
|
62
|
+
* <Combobox
|
|
63
|
+
* bind:value={airport}
|
|
64
|
+
* options={AIRPORTS}
|
|
65
|
+
* aria-labelledby="airport-label"
|
|
66
|
+
* placeholder="Search airports"
|
|
67
|
+
* clearable
|
|
68
|
+
* class="max-w-sm"
|
|
69
|
+
* />
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
declare const Combobox: import("svelte").Component<Props, {}, "value">;
|
|
73
|
+
type Combobox = ReturnType<typeof Combobox>;
|
|
74
|
+
export default Combobox;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, type Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
|
|
5
|
+
type Props = HTMLAttributes<HTMLSpanElement> & {
|
|
6
|
+
children: Snippet;
|
|
7
|
+
/** Text put on the clipboard. Defaults to the rendered text, which is what you want whenever
|
|
8
|
+
the content already *is* the value; pass it when the two differ — a truncated label, a
|
|
9
|
+
formatted number, a link whose text is shorter than its id. */
|
|
10
|
+
copyableText?: string | null;
|
|
11
|
+
/** How long the confirmation stays up, in milliseconds. */
|
|
12
|
+
confirmationMs?: number;
|
|
13
|
+
/** Accessible name of the button while it is waiting to be pressed. */
|
|
14
|
+
copyLabel?: string;
|
|
15
|
+
/** Accessible name of the button while the confirmation is up. */
|
|
16
|
+
copiedLabel?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
class: className,
|
|
21
|
+
children,
|
|
22
|
+
copyableText,
|
|
23
|
+
confirmationMs = 1200,
|
|
24
|
+
copyLabel = "Copy",
|
|
25
|
+
copiedLabel = "Copied",
|
|
26
|
+
...rest
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
let content = $state<HTMLElement>();
|
|
30
|
+
let copied = $state(false);
|
|
31
|
+
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
|
|
33
|
+
const copy = async () => {
|
|
34
|
+
try {
|
|
35
|
+
// `innerText` rather than `textContent`: it reads what is laid out, so a hidden helper span
|
|
36
|
+
// inside the content does not end up on the clipboard.
|
|
37
|
+
await navigator.clipboard.writeText(copyableText ?? content!.innerText);
|
|
38
|
+
} catch {
|
|
39
|
+
// A rejected write means the browser refused the clipboard — an insecure origin, or a
|
|
40
|
+
// denied permission. Nothing was copied, so the confirmation must not appear; there is no
|
|
41
|
+
// sensible recovery at this level, and the caller's own error channel is the wrong place
|
|
42
|
+
// for a failure the user can see for themselves.
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
copied = true;
|
|
46
|
+
clearTimeout(resetTimer);
|
|
47
|
+
resetTimer = setTimeout(() => (copied = false), confirmationMs);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
onDestroy(() => clearTimeout(resetTimer));
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<!--
|
|
54
|
+
@component
|
|
55
|
+
Renders content beside a copy button. The content is an ordinary slot — text, a link, any markup —
|
|
56
|
+
and only the button copies, so whatever is in there keeps its own behavior: a link stays
|
|
57
|
+
navigable, a value stays selectable.
|
|
58
|
+
|
|
59
|
+
The button confirms with a check for a moment after a successful write, and stays silent when the
|
|
60
|
+
browser refuses the clipboard, so the confirmation never claims a copy that did not happen. Its
|
|
61
|
+
accessible name carries that state, since the icon alone is invisible to a screen reader.
|
|
62
|
+
|
|
63
|
+
Both of those names are props with an English default — `copyLabel` and `copiedLabel` — because the
|
|
64
|
+
library ships no translations. They are the whole of the copy: the button has no visible text, and
|
|
65
|
+
a refused clipboard stays silent rather than saying so.
|
|
66
|
+
|
|
67
|
+
```svelte
|
|
68
|
+
<Copyable class="font-mono text-xs" copyableText={id}>
|
|
69
|
+
<a class="link" href={href}>{id}</a>
|
|
70
|
+
</Copyable>
|
|
71
|
+
```
|
|
72
|
+
-->
|
|
73
|
+
|
|
74
|
+
<span {...rest} class={["inline-flex items-center gap-1", className]}>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="copy-button inline-flex shrink-0 items-center"
|
|
78
|
+
aria-label={copied ? copiedLabel : copyLabel}
|
|
79
|
+
onclick={copy}
|
|
80
|
+
>
|
|
81
|
+
<!-- Inline paths rather than an icon-font class: the library must render correctly in a
|
|
82
|
+
consumer that has no icon plugin in its Tailwind chain. Sized in `em` so it tracks the
|
|
83
|
+
surrounding text. -->
|
|
84
|
+
<svg
|
|
85
|
+
class="size-[0.9em]"
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
stroke-width="2"
|
|
90
|
+
stroke-linecap="round"
|
|
91
|
+
stroke-linejoin="round"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
{#if copied}
|
|
95
|
+
<path d="m4 12.5 5 5L20 6.5" />
|
|
96
|
+
{:else}
|
|
97
|
+
<rect x="9" y="9" width="11" height="11" rx="2" />
|
|
98
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
99
|
+
{/if}
|
|
100
|
+
</svg>
|
|
101
|
+
</button>
|
|
102
|
+
<span bind:this={content} class="min-w-0 truncate">{@render children()}</span>
|
|
103
|
+
</span>
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
.copy-button {
|
|
107
|
+
color: color-mix(in oklch, currentColor 45%, transparent);
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
}
|
|
110
|
+
.copy-button:hover {
|
|
111
|
+
color: currentColor;
|
|
112
|
+
}
|
|
113
|
+
.copy-button:active {
|
|
114
|
+
transform: translateY(1px);
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Snippet } from "svelte";
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
type Props = HTMLAttributes<HTMLSpanElement> & {
|
|
4
|
+
children: Snippet;
|
|
5
|
+
/** Text put on the clipboard. Defaults to the rendered text, which is what you want whenever
|
|
6
|
+
the content already *is* the value; pass it when the two differ — a truncated label, a
|
|
7
|
+
formatted number, a link whose text is shorter than its id. */
|
|
8
|
+
copyableText?: string | null;
|
|
9
|
+
/** How long the confirmation stays up, in milliseconds. */
|
|
10
|
+
confirmationMs?: number;
|
|
11
|
+
/** Accessible name of the button while it is waiting to be pressed. */
|
|
12
|
+
copyLabel?: string;
|
|
13
|
+
/** Accessible name of the button while the confirmation is up. */
|
|
14
|
+
copiedLabel?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Renders content beside a copy button. The content is an ordinary slot — text, a link, any markup —
|
|
18
|
+
* and only the button copies, so whatever is in there keeps its own behavior: a link stays
|
|
19
|
+
* navigable, a value stays selectable.
|
|
20
|
+
*
|
|
21
|
+
* The button confirms with a check for a moment after a successful write, and stays silent when the
|
|
22
|
+
* browser refuses the clipboard, so the confirmation never claims a copy that did not happen. Its
|
|
23
|
+
* accessible name carries that state, since the icon alone is invisible to a screen reader.
|
|
24
|
+
*
|
|
25
|
+
* Both of those names are props with an English default — `copyLabel` and `copiedLabel` — because the
|
|
26
|
+
* library ships no translations. They are the whole of the copy: the button has no visible text, and
|
|
27
|
+
* a refused clipboard stays silent rather than saying so.
|
|
28
|
+
*
|
|
29
|
+
* ```svelte
|
|
30
|
+
* <Copyable class="font-mono text-xs" copyableText={id}>
|
|
31
|
+
* <a class="link" href={href}>{id}</a>
|
|
32
|
+
* </Copyable>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
declare const Copyable: import("svelte").Component<Props, {}, "">;
|
|
36
|
+
type Copyable = ReturnType<typeof Copyable>;
|
|
37
|
+
export default Copyable;
|