@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,187 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import Dialog from "./Dialog.svelte";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Names the dialog for assistive technology, and heads it visually. */
|
|
8
|
+
title: string;
|
|
9
|
+
/**
|
|
10
|
+
* The scrollable body. Optional: a dialog whose whole question fits in its title -- a confirm
|
|
11
|
+
* is the usual one -- would otherwise render an empty padded strip between the header and the
|
|
12
|
+
* actions. Omitted, there is no body element at all, and the header's own rule becomes the
|
|
13
|
+
* single divider above the footer.
|
|
14
|
+
*/
|
|
15
|
+
children?: Snippet;
|
|
16
|
+
/** Actions, laid out end-aligned under a divider. Nothing is rendered when it is omitted. */
|
|
17
|
+
footer?: Snippet;
|
|
18
|
+
/** Extra classes for the box -- width, max-width, a taller cap. Not for the backdrop. */
|
|
19
|
+
class?: ClassValue;
|
|
20
|
+
/** Accessible name of the close button. A prop because the library ships no translations. */
|
|
21
|
+
closeLabel?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the modal can be dismissed without a decision. `false` removes the close button and
|
|
24
|
+
* blocks both Escape and the click outside, which makes the footer the only way out -- so a
|
|
25
|
+
* modal that sets it owes the user an explicit action there.
|
|
26
|
+
*/
|
|
27
|
+
dismissible?: boolean;
|
|
28
|
+
/** Called once the modal has actually closed, whichever way it was closed. */
|
|
29
|
+
onclose?: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
title,
|
|
34
|
+
children,
|
|
35
|
+
footer,
|
|
36
|
+
class: className,
|
|
37
|
+
closeLabel = "Close",
|
|
38
|
+
dismissible = true,
|
|
39
|
+
onclose,
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
|
|
42
|
+
const titleId = $props.id();
|
|
43
|
+
|
|
44
|
+
let dialog = $state<Dialog>();
|
|
45
|
+
let box = $state<HTMLDivElement>();
|
|
46
|
+
let closeButton = $state<HTMLButtonElement>();
|
|
47
|
+
let open = $state(false);
|
|
48
|
+
let pressedOutside = false;
|
|
49
|
+
|
|
50
|
+
/** Open the modal. The content is mounted by this call, not before it. */
|
|
51
|
+
export function show() {
|
|
52
|
+
open = true;
|
|
53
|
+
dialog?.show();
|
|
54
|
+
|
|
55
|
+
// The browser's initial focus lands on the first focusable descendant, which is the ✕ --
|
|
56
|
+
// opening a dialog on "close" is a poor first move, and it tells a screen reader nothing about
|
|
57
|
+
// what was opened. Anything the content marks `autofocus` is chosen ahead of the button, so
|
|
58
|
+
// only this fallback needs correcting, and the box is the target the dialog pattern names.
|
|
59
|
+
if (closeButton && document.activeElement === closeButton) box?.focus();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Close the modal. `onclose` fires after the element reports itself closed. */
|
|
63
|
+
export function close() {
|
|
64
|
+
dialog?.close();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (!open) return;
|
|
69
|
+
|
|
70
|
+
// A native modal makes the rest of the document inert, but it does not stop it from SCROLLING
|
|
71
|
+
// behind the backdrop. Saving the inline value instead of toggling a class is what lets a
|
|
72
|
+
// modal opened over another modal put back exactly what it found. Compensating for the
|
|
73
|
+
// scrollbar that disappears with it is the app's call, through `scrollbar-gutter: stable`.
|
|
74
|
+
const previousOverflow = document.body.style.overflow;
|
|
75
|
+
document.body.style.overflow = "hidden";
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
document.body.style.overflow = previousOverflow;
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<!--
|
|
84
|
+
@component
|
|
85
|
+
A dialog with chrome: a title, a close button, an optional scrollable body and an optional footer.
|
|
86
|
+
|
|
87
|
+
Composition over `Dialog`, so modality, the focus trap, Escape and returning focus to the opener
|
|
88
|
+
stay the browser's job. What is added on top is everything a native modal leaves to the page, and
|
|
89
|
+
all of it shows up first on a phone: the background is kept from scrolling behind the backdrop,
|
|
90
|
+
the box is capped in `dvh` so the on-screen keyboard cannot push the submit button out of reach,
|
|
91
|
+
the close button is a full 44px touch target, and the body scrolls under a header that does not.
|
|
92
|
+
|
|
93
|
+
The content is mounted on `show()` and unmounted after it closes, so an expensive form inside a
|
|
94
|
+
rarely opened modal costs nothing until it is opened.
|
|
95
|
+
|
|
96
|
+
```svelte
|
|
97
|
+
<script lang="ts">
|
|
98
|
+
let editor = $state<Modal>();
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<button onclick={() => editor?.show()}>Edit</button>
|
|
102
|
+
|
|
103
|
+
<Modal bind:this={editor} title="Edit profile" class="max-w-lg">
|
|
104
|
+
<form id="profile">...</form>
|
|
105
|
+
|
|
106
|
+
{#snippet footer()}
|
|
107
|
+
<button class="btn" onclick={() => editor?.close()}>Cancel</button>
|
|
108
|
+
<button class="btn btn-primary" form="profile">Save</button>
|
|
109
|
+
{/snippet}
|
|
110
|
+
</Modal>
|
|
111
|
+
```
|
|
112
|
+
-->
|
|
113
|
+
|
|
114
|
+
<Dialog
|
|
115
|
+
bind:this={dialog}
|
|
116
|
+
class="modal motion-reduce:transition-none"
|
|
117
|
+
aria-labelledby={titleId}
|
|
118
|
+
onclose={() => {
|
|
119
|
+
open = false;
|
|
120
|
+
onclose?.();
|
|
121
|
+
}}
|
|
122
|
+
oncancel={(event) => {
|
|
123
|
+
if (!dismissible) event.preventDefault();
|
|
124
|
+
}}
|
|
125
|
+
onpointerdown={(event) => {
|
|
126
|
+
// A press that starts inside the box and ends outside it still produces a click on the dialog,
|
|
127
|
+
// because the click target is the common ancestor of the two. Selecting text and dragging past
|
|
128
|
+
// the edge would close the modal and lose the form, so the press has to have started outside.
|
|
129
|
+
pressedOutside = event.target === event.currentTarget;
|
|
130
|
+
}}
|
|
131
|
+
onclick={(event) => {
|
|
132
|
+
if (dismissible && pressedOutside && event.target === event.currentTarget) close();
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
bind:this={box}
|
|
137
|
+
tabindex="-1"
|
|
138
|
+
class={[
|
|
139
|
+
// dvh, not vh: the visible viewport shrinks when the on-screen keyboard opens, and a `vh`
|
|
140
|
+
// cap would leave the footer under it.
|
|
141
|
+
"modal-box flex max-h-[calc(100dvh-40px)] flex-col overflow-hidden p-0 motion-reduce:transition-none",
|
|
142
|
+
className,
|
|
143
|
+
]}
|
|
144
|
+
>
|
|
145
|
+
<header
|
|
146
|
+
class="flex flex-none items-start justify-between gap-3 border-b border-base-content/10 px-6 py-4"
|
|
147
|
+
>
|
|
148
|
+
<h2 id={titleId} class="text-lg leading-7 font-semibold">{title}</h2>
|
|
149
|
+
{#if dismissible}
|
|
150
|
+
<button
|
|
151
|
+
bind:this={closeButton}
|
|
152
|
+
type="button"
|
|
153
|
+
class="btn -my-2 -mr-3 size-11 shrink-0 btn-ghost text-base"
|
|
154
|
+
aria-label={closeLabel}
|
|
155
|
+
onclick={close}
|
|
156
|
+
>
|
|
157
|
+
<!-- The negative margins let the 44px target eat into the header padding instead of
|
|
158
|
+
growing the header by the difference. -->
|
|
159
|
+
✕
|
|
160
|
+
</button>
|
|
161
|
+
{/if}
|
|
162
|
+
</header>
|
|
163
|
+
|
|
164
|
+
{#if children}
|
|
165
|
+
<!-- `min-h-0` is load-bearing: a flex child defaults to `min-height: auto` and refuses to
|
|
166
|
+
shrink below its content, which would push the box past its own max-height and take the
|
|
167
|
+
scrolling with it. `overscroll-contain` keeps a flick at the end of a long form from
|
|
168
|
+
scrolling the page underneath. -->
|
|
169
|
+
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5">
|
|
170
|
+
{@render children()}
|
|
171
|
+
</div>
|
|
172
|
+
{/if}
|
|
173
|
+
|
|
174
|
+
{#if footer}
|
|
175
|
+
<!-- Two adjacent rules read as one thick line, so with no body between them the footer drops
|
|
176
|
+
its own and lets the header's separate the title from the actions. -->
|
|
177
|
+
<footer
|
|
178
|
+
class={[
|
|
179
|
+
"flex flex-none flex-wrap justify-end gap-2 px-6 py-4",
|
|
180
|
+
children && "border-t border-base-content/10",
|
|
181
|
+
]}
|
|
182
|
+
>
|
|
183
|
+
{@render footer()}
|
|
184
|
+
</footer>
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
187
|
+
</Dialog>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ClassValue } from "svelte/elements";
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Names the dialog for assistive technology, and heads it visually. */
|
|
5
|
+
title: string;
|
|
6
|
+
/**
|
|
7
|
+
* The scrollable body. Optional: a dialog whose whole question fits in its title -- a confirm
|
|
8
|
+
* is the usual one -- would otherwise render an empty padded strip between the header and the
|
|
9
|
+
* actions. Omitted, there is no body element at all, and the header's own rule becomes the
|
|
10
|
+
* single divider above the footer.
|
|
11
|
+
*/
|
|
12
|
+
children?: Snippet;
|
|
13
|
+
/** Actions, laid out end-aligned under a divider. Nothing is rendered when it is omitted. */
|
|
14
|
+
footer?: Snippet;
|
|
15
|
+
/** Extra classes for the box -- width, max-width, a taller cap. Not for the backdrop. */
|
|
16
|
+
class?: ClassValue;
|
|
17
|
+
/** Accessible name of the close button. A prop because the library ships no translations. */
|
|
18
|
+
closeLabel?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Whether the modal can be dismissed without a decision. `false` removes the close button and
|
|
21
|
+
* blocks both Escape and the click outside, which makes the footer the only way out -- so a
|
|
22
|
+
* modal that sets it owes the user an explicit action there.
|
|
23
|
+
*/
|
|
24
|
+
dismissible?: boolean;
|
|
25
|
+
/** Called once the modal has actually closed, whichever way it was closed. */
|
|
26
|
+
onclose?: () => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A dialog with chrome: a title, a close button, an optional scrollable body and an optional footer.
|
|
30
|
+
*
|
|
31
|
+
* Composition over `Dialog`, so modality, the focus trap, Escape and returning focus to the opener
|
|
32
|
+
* stay the browser's job. What is added on top is everything a native modal leaves to the page, and
|
|
33
|
+
* all of it shows up first on a phone: the background is kept from scrolling behind the backdrop,
|
|
34
|
+
* the box is capped in `dvh` so the on-screen keyboard cannot push the submit button out of reach,
|
|
35
|
+
* the close button is a full 44px touch target, and the body scrolls under a header that does not.
|
|
36
|
+
*
|
|
37
|
+
* The content is mounted on `show()` and unmounted after it closes, so an expensive form inside a
|
|
38
|
+
* rarely opened modal costs nothing until it is opened.
|
|
39
|
+
*
|
|
40
|
+
* ```svelte
|
|
41
|
+
* <script lang="ts">
|
|
42
|
+
* let editor = $state<Modal>();
|
|
43
|
+
* </script>
|
|
44
|
+
*
|
|
45
|
+
* <button onclick={() => editor?.show()}>Edit</button>
|
|
46
|
+
*
|
|
47
|
+
* <Modal bind:this={editor} title="Edit profile" class="max-w-lg">
|
|
48
|
+
* <form id="profile">...</form>
|
|
49
|
+
*
|
|
50
|
+
* {#snippet footer()}
|
|
51
|
+
* <button class="btn" onclick={() => editor?.close()}>Cancel</button>
|
|
52
|
+
* <button class="btn btn-primary" form="profile">Save</button>
|
|
53
|
+
* {/snippet}
|
|
54
|
+
* </Modal>
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
declare const Modal: import("svelte").Component<Props, {
|
|
58
|
+
show: () => void;
|
|
59
|
+
close: () => void;
|
|
60
|
+
}, "">;
|
|
61
|
+
type Modal = ReturnType<typeof Modal>;
|
|
62
|
+
export default Modal;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script lang="ts" generics="O extends SelectOption = SelectOption">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import Dropdown from "./Dropdown.svelte";
|
|
5
|
+
import Listbox from "./Listbox.svelte";
|
|
6
|
+
import type { SelectOption } from "./select.js";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The rows, in the order they are listed. `value` is the key, so it has to be unique. */
|
|
10
|
+
options: readonly O[];
|
|
11
|
+
/** The picked values, in the order they were picked. Bindable, and reported through `onchange`. */
|
|
12
|
+
value?: string[];
|
|
13
|
+
onchange?: (value: string[]) => void;
|
|
14
|
+
/** Shown in the field while nothing is picked. */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/** What the search reads for a row. Defaults to its label. */
|
|
17
|
+
searchText?: (option: O) => string;
|
|
18
|
+
searchPlaceholder?: string;
|
|
19
|
+
/** Shown in place of the list when the search matches nothing. */
|
|
20
|
+
emptyLabel?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Shows a spinner, a placeholder chip for each pick whose option has not arrived yet, and
|
|
23
|
+
* `loadingLabel` in an empty list.
|
|
24
|
+
*/
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
loadingLabel?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Blocks the surrounding form's submission while nothing is picked. */
|
|
29
|
+
required?: boolean;
|
|
30
|
+
/** Submits every picked value under this name with a surrounding `<form>`. */
|
|
31
|
+
name?: string;
|
|
32
|
+
/** The field's id, for a `<label for>`. */
|
|
33
|
+
id?: string;
|
|
34
|
+
"aria-label"?: string;
|
|
35
|
+
"aria-labelledby"?: string;
|
|
36
|
+
/** Accessible name of a chip's remove button. */
|
|
37
|
+
removeLabel?: (label: string) => string;
|
|
38
|
+
/** Renders a chip's content in place of its label. */
|
|
39
|
+
chip?: Snippet<[O]>;
|
|
40
|
+
/**
|
|
41
|
+
* Renders a row in place of its label. The row is already a `role="option"` button, so the
|
|
42
|
+
* snippet must not hold buttons, links or anything else interactive.
|
|
43
|
+
*/
|
|
44
|
+
option?: Snippet<[O]>;
|
|
45
|
+
/** Classes for the field — width, margins. */
|
|
46
|
+
class?: ClassValue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let {
|
|
50
|
+
options,
|
|
51
|
+
value = $bindable([]),
|
|
52
|
+
onchange,
|
|
53
|
+
placeholder = "Select…",
|
|
54
|
+
searchText,
|
|
55
|
+
searchPlaceholder,
|
|
56
|
+
emptyLabel,
|
|
57
|
+
loading = false,
|
|
58
|
+
loadingLabel,
|
|
59
|
+
disabled = false,
|
|
60
|
+
required = false,
|
|
61
|
+
name,
|
|
62
|
+
id,
|
|
63
|
+
"aria-label": ariaLabel,
|
|
64
|
+
"aria-labelledby": ariaLabelledby,
|
|
65
|
+
removeLabel = (label) => `Remove ${label}`,
|
|
66
|
+
chip,
|
|
67
|
+
option,
|
|
68
|
+
class: className,
|
|
69
|
+
}: Props = $props();
|
|
70
|
+
|
|
71
|
+
const uid = $props.id();
|
|
72
|
+
const triggerId = $derived(id ?? `plinth-multiselect-${uid}`);
|
|
73
|
+
const listboxId = `plinth-multiselect-${uid}-listbox`;
|
|
74
|
+
|
|
75
|
+
let dropdown = $state<ReturnType<typeof Dropdown>>();
|
|
76
|
+
let isOpen = $state(false);
|
|
77
|
+
|
|
78
|
+
const picks = $derived([...new Set(value)]);
|
|
79
|
+
const byValue = $derived(new Map(options.map((candidate) => [candidate.value, candidate])));
|
|
80
|
+
const isLocked = (picked: string) => byValue.get(picked)?.disabled === true;
|
|
81
|
+
|
|
82
|
+
const commit = (next: string[]) => {
|
|
83
|
+
value = next;
|
|
84
|
+
onchange?.(next);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const remove = (picked: string) => commit(picks.filter((other) => other !== picked));
|
|
88
|
+
|
|
89
|
+
const toggle = (picked: O) => {
|
|
90
|
+
if (picks.includes(picked.value)) remove(picked.value);
|
|
91
|
+
else commit([...picks, picked.value]);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const focusField = () => document.getElementById(triggerId)?.focus({ preventScroll: true });
|
|
95
|
+
|
|
96
|
+
const onkeydown = (event: KeyboardEvent) => {
|
|
97
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
dropdown?.open();
|
|
100
|
+
} else if (event.key === "Backspace" && !event.repeat) {
|
|
101
|
+
const last = picks.findLast((picked) => !isLocked(picked));
|
|
102
|
+
if (last === undefined) return;
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
remove(last);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<!--
|
|
110
|
+
@component
|
|
111
|
+
Picks several options. The field shows the picks as removable chips, in the order they were made;
|
|
112
|
+
opening it lists every option under a search box, with the picked ones checked, and Enter or a
|
|
113
|
+
click checks or unchecks a row without closing the list. The search folds case and accents, and a
|
|
114
|
+
row matches when it contains every word typed.
|
|
115
|
+
|
|
116
|
+
A disabled option cannot be checked or unchecked: picked, it is locked, and its chip has no ✕. On
|
|
117
|
+
the field, the arrows open the list and Backspace removes the last pick that is not locked. The
|
|
118
|
+
chips' remove buttons are not Tab stops; from the keyboard, a pick is unchecked in the list.
|
|
119
|
+
|
|
120
|
+
It is a `Dropdown` with a `Listbox` inside, so the focus moves into the search box as the list
|
|
121
|
+
opens and comes back to the field as it closes. Shift+Tab from the search box reaches the field
|
|
122
|
+
with the list still open, and an arrow key sends the focus back in.
|
|
123
|
+
|
|
124
|
+
`required` blocks the surrounding form while nothing is picked, and `name` submits one entry per
|
|
125
|
+
pick.
|
|
126
|
+
|
|
127
|
+
```svelte
|
|
128
|
+
<MultiSelect options={people} bind:value={members} aria-label="Members">
|
|
129
|
+
{#snippet chip(person)}{person.label}{/snippet}
|
|
130
|
+
</MultiSelect>
|
|
131
|
+
```
|
|
132
|
+
-->
|
|
133
|
+
|
|
134
|
+
<div
|
|
135
|
+
class={[
|
|
136
|
+
"plinth-multiselect select relative flex h-auto min-h-11 w-full flex-wrap items-center gap-1 py-1",
|
|
137
|
+
picks.length > 0 && "ps-1.5",
|
|
138
|
+
disabled && "cursor-not-allowed border-base-200 bg-base-200 text-base-content/40",
|
|
139
|
+
className,
|
|
140
|
+
]}
|
|
141
|
+
onfocusout={(event) => {
|
|
142
|
+
const next = event.relatedTarget;
|
|
143
|
+
if (next instanceof Node && !event.currentTarget.contains(next)) dropdown?.close();
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{#each picks as picked (picked)}
|
|
147
|
+
{@const pickedOption = byValue.get(picked)}
|
|
148
|
+
{@const label = pickedOption?.label ?? picked}
|
|
149
|
+
{#if !pickedOption && loading}
|
|
150
|
+
<span class="h-6.5 w-20 skeleton rounded-field"></span>
|
|
151
|
+
{:else}
|
|
152
|
+
<span
|
|
153
|
+
class="pointer-events-none relative z-1 inline-flex max-w-full items-center gap-1 rounded-field border border-base-content/15 bg-base-200 py-0.5 ps-2 pe-1 text-sm"
|
|
154
|
+
>
|
|
155
|
+
<span class="min-w-0 truncate">
|
|
156
|
+
{#if pickedOption && chip}{@render chip(pickedOption)}{:else}{label}{/if}
|
|
157
|
+
</span>
|
|
158
|
+
{#if !disabled && !isLocked(picked)}
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
tabindex="-1"
|
|
162
|
+
class="pointer-events-auto relative flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-base-content/50 after:absolute after:-inset-1.5 hover:bg-base-content/10 hover:text-base-content"
|
|
163
|
+
aria-label={removeLabel(label)}
|
|
164
|
+
onclick={() => {
|
|
165
|
+
remove(picked);
|
|
166
|
+
focusField();
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<svg
|
|
170
|
+
class="size-3"
|
|
171
|
+
viewBox="0 0 24 24"
|
|
172
|
+
fill="none"
|
|
173
|
+
stroke="currentColor"
|
|
174
|
+
stroke-width="2.5"
|
|
175
|
+
stroke-linecap="round"
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
>
|
|
178
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
179
|
+
</svg>
|
|
180
|
+
</button>
|
|
181
|
+
{/if}
|
|
182
|
+
</span>
|
|
183
|
+
{/if}
|
|
184
|
+
{:else}
|
|
185
|
+
<span class="min-w-0 truncate text-base-content/50">{placeholder}</span>
|
|
186
|
+
{/each}
|
|
187
|
+
|
|
188
|
+
{#if loading}
|
|
189
|
+
<span class="loading ms-auto loading-xs self-center loading-spinner"></span>
|
|
190
|
+
{/if}
|
|
191
|
+
|
|
192
|
+
<Dropdown
|
|
193
|
+
bind:this={dropdown}
|
|
194
|
+
bind:expanded={isOpen}
|
|
195
|
+
id={triggerId}
|
|
196
|
+
role="combobox"
|
|
197
|
+
class="absolute inset-0 cursor-pointer rounded-field focus-visible:outline-none disabled:cursor-not-allowed"
|
|
198
|
+
panelClass="rounded-box border border-base-content/10 bg-base-100 p-1 whitespace-normal text-base-content shadow-lg"
|
|
199
|
+
matchWidth
|
|
200
|
+
{disabled}
|
|
201
|
+
aria-label={ariaLabel}
|
|
202
|
+
aria-labelledby={ariaLabelledby}
|
|
203
|
+
aria-haspopup="listbox"
|
|
204
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
205
|
+
{onkeydown}
|
|
206
|
+
>
|
|
207
|
+
<span class="sr-only">
|
|
208
|
+
{picks.flatMap((picked) => byValue.get(picked)?.label ?? (loading ? [] : picked)).join(", ")}
|
|
209
|
+
</span>
|
|
210
|
+
|
|
211
|
+
{#snippet content()}
|
|
212
|
+
<Listbox
|
|
213
|
+
id={listboxId}
|
|
214
|
+
{options}
|
|
215
|
+
value={picks}
|
|
216
|
+
onpick={toggle}
|
|
217
|
+
searchable
|
|
218
|
+
{searchText}
|
|
219
|
+
{searchPlaceholder}
|
|
220
|
+
{emptyLabel}
|
|
221
|
+
{loading}
|
|
222
|
+
{loadingLabel}
|
|
223
|
+
autofocus
|
|
224
|
+
aria-label={ariaLabel}
|
|
225
|
+
aria-labelledby={ariaLabelledby}
|
|
226
|
+
{option}
|
|
227
|
+
/>
|
|
228
|
+
{/snippet}
|
|
229
|
+
</Dropdown>
|
|
230
|
+
|
|
231
|
+
{#if name}
|
|
232
|
+
{#each picks as picked (picked)}
|
|
233
|
+
<input type="hidden" {name} value={picked} {disabled} />
|
|
234
|
+
{/each}
|
|
235
|
+
{/if}
|
|
236
|
+
|
|
237
|
+
{#if required}
|
|
238
|
+
<input
|
|
239
|
+
class="sr-only"
|
|
240
|
+
tabindex="-1"
|
|
241
|
+
aria-hidden="true"
|
|
242
|
+
{disabled}
|
|
243
|
+
{required}
|
|
244
|
+
value={picks.join(",")}
|
|
245
|
+
onfocus={focusField}
|
|
246
|
+
/>
|
|
247
|
+
{/if}
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<style>
|
|
251
|
+
.plinth-multiselect:has(> :global([aria-expanded="true"])) {
|
|
252
|
+
background-image:
|
|
253
|
+
linear-gradient(135deg, #0000 50%, currentColor 50%),
|
|
254
|
+
linear-gradient(45deg, currentColor 50%, #0000 50%);
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ClassValue } from "svelte/elements";
|
|
3
|
+
import type { SelectOption } from "./select.js";
|
|
4
|
+
declare function $$render<O extends SelectOption = SelectOption>(): {
|
|
5
|
+
props: {
|
|
6
|
+
/** The rows, in the order they are listed. `value` is the key, so it has to be unique. */
|
|
7
|
+
options: readonly O[];
|
|
8
|
+
/** The picked values, in the order they were picked. Bindable, and reported through `onchange`. */
|
|
9
|
+
value?: string[];
|
|
10
|
+
onchange?: (value: string[]) => void;
|
|
11
|
+
/** Shown in the field while nothing is picked. */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** What the search reads for a row. Defaults to its label. */
|
|
14
|
+
searchText?: (option: O) => string;
|
|
15
|
+
searchPlaceholder?: string;
|
|
16
|
+
/** Shown in place of the list when the search matches nothing. */
|
|
17
|
+
emptyLabel?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Shows a spinner, a placeholder chip for each pick whose option has not arrived yet, and
|
|
20
|
+
* `loadingLabel` in an empty list.
|
|
21
|
+
*/
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
loadingLabel?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
/** Blocks the surrounding form's submission while nothing is picked. */
|
|
26
|
+
required?: boolean;
|
|
27
|
+
/** Submits every picked value under this name with a surrounding `<form>`. */
|
|
28
|
+
name?: string;
|
|
29
|
+
/** The field's id, for a `<label for>`. */
|
|
30
|
+
id?: string;
|
|
31
|
+
"aria-label"?: string;
|
|
32
|
+
"aria-labelledby"?: string;
|
|
33
|
+
/** Accessible name of a chip's remove button. */
|
|
34
|
+
removeLabel?: (label: string) => string;
|
|
35
|
+
/** Renders a chip's content in place of its label. */
|
|
36
|
+
chip?: Snippet<[O]>;
|
|
37
|
+
/**
|
|
38
|
+
* Renders a row in place of its label. The row is already a `role="option"` button, so the
|
|
39
|
+
* snippet must not hold buttons, links or anything else interactive.
|
|
40
|
+
*/
|
|
41
|
+
option?: Snippet<[O]>;
|
|
42
|
+
/** Classes for the field — width, margins. */
|
|
43
|
+
class?: ClassValue;
|
|
44
|
+
};
|
|
45
|
+
exports: {};
|
|
46
|
+
bindings: "value";
|
|
47
|
+
slots: {};
|
|
48
|
+
events: {};
|
|
49
|
+
};
|
|
50
|
+
declare class __sveltets_Render<O extends SelectOption = SelectOption> {
|
|
51
|
+
props(): ReturnType<typeof $$render<O>>['props'];
|
|
52
|
+
events(): ReturnType<typeof $$render<O>>['events'];
|
|
53
|
+
slots(): ReturnType<typeof $$render<O>>['slots'];
|
|
54
|
+
bindings(): "value";
|
|
55
|
+
exports(): {};
|
|
56
|
+
}
|
|
57
|
+
interface $$IsomorphicComponent {
|
|
58
|
+
new <O extends SelectOption = SelectOption>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<O>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<O>['props']>, ReturnType<__sveltets_Render<O>['events']>, ReturnType<__sveltets_Render<O>['slots']>> & {
|
|
59
|
+
$$bindings?: ReturnType<__sveltets_Render<O>['bindings']>;
|
|
60
|
+
} & ReturnType<__sveltets_Render<O>['exports']>;
|
|
61
|
+
<O extends SelectOption = SelectOption>(internal: unknown, props: ReturnType<__sveltets_Render<O>['props']> & {}): ReturnType<__sveltets_Render<O>['exports']>;
|
|
62
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Picks several options. The field shows the picks as removable chips, in the order they were made;
|
|
66
|
+
* opening it lists every option under a search box, with the picked ones checked, and Enter or a
|
|
67
|
+
* click checks or unchecks a row without closing the list. The search folds case and accents, and a
|
|
68
|
+
* row matches when it contains every word typed.
|
|
69
|
+
*
|
|
70
|
+
* A disabled option cannot be checked or unchecked: picked, it is locked, and its chip has no ✕. On
|
|
71
|
+
* the field, the arrows open the list and Backspace removes the last pick that is not locked. The
|
|
72
|
+
* chips' remove buttons are not Tab stops; from the keyboard, a pick is unchecked in the list.
|
|
73
|
+
*
|
|
74
|
+
* It is a `Dropdown` with a `Listbox` inside, so the focus moves into the search box as the list
|
|
75
|
+
* opens and comes back to the field as it closes. Shift+Tab from the search box reaches the field
|
|
76
|
+
* with the list still open, and an arrow key sends the focus back in.
|
|
77
|
+
*
|
|
78
|
+
* `required` blocks the surrounding form while nothing is picked, and `name` submits one entry per
|
|
79
|
+
* pick.
|
|
80
|
+
*
|
|
81
|
+
* ```svelte
|
|
82
|
+
* <MultiSelect options={people} bind:value={members} aria-label="Members">
|
|
83
|
+
* {#snippet chip(person)}{person.label}{/snippet}
|
|
84
|
+
* </MultiSelect>
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
declare const MultiSelect: $$IsomorphicComponent;
|
|
88
|
+
type MultiSelect<O extends SelectOption = SelectOption> = InstanceType<typeof MultiSelect<O>>;
|
|
89
|
+
export default MultiSelect;
|