@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,204 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { flushSync, onDestroy, type Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue, HTMLButtonAttributes } from "svelte/elements";
|
|
4
|
+
import { positionUnder, supportsAnchorPositioning } from "../anchoring.js";
|
|
5
|
+
|
|
6
|
+
type Props = HTMLButtonAttributes & {
|
|
7
|
+
children: Snippet;
|
|
8
|
+
content: Snippet;
|
|
9
|
+
/** Classes for the panel — the only part of this component the trigger's `class` cannot
|
|
10
|
+
reach, since the trigger is the root. */
|
|
11
|
+
panelClass?: ClassValue | null;
|
|
12
|
+
/** Whether the panel is open, however it got there. Bind to read it; the popover decides it. */
|
|
13
|
+
expanded?: boolean;
|
|
14
|
+
/** Hangs the panel just under the trigger at the trigger's width, as a field's list does. */
|
|
15
|
+
matchWidth?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
children,
|
|
20
|
+
content,
|
|
21
|
+
panelClass,
|
|
22
|
+
expanded = $bindable(false),
|
|
23
|
+
matchWidth = false,
|
|
24
|
+
type,
|
|
25
|
+
...rest
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
// `popovertarget` needs a document-unique id, and `$props.id()` is the only source of one that
|
|
29
|
+
// is stable across server render and hydration.
|
|
30
|
+
const uid = $props.id();
|
|
31
|
+
const panelId = `plinth-dropdown-${uid}`;
|
|
32
|
+
const anchorName = `--${panelId}`;
|
|
33
|
+
|
|
34
|
+
const FIELD_GAP = 4;
|
|
35
|
+
|
|
36
|
+
let isMounted = $state(false);
|
|
37
|
+
let trigger = $state<HTMLButtonElement>();
|
|
38
|
+
let panel = $state<HTMLElement>();
|
|
39
|
+
|
|
40
|
+
let stopPositioning: (() => void) | undefined;
|
|
41
|
+
|
|
42
|
+
/* Without anchor positioning the placement rules at the bottom of this file are dropped and
|
|
43
|
+
the UA popover ones -- `position: fixed; inset: 0; margin: auto` -- are all that is left,
|
|
44
|
+
which floats the panel in the middle of the screen with no relation to the trigger. Script
|
|
45
|
+
places it instead, to the geometry `position-area: bottom` gives: centred on the trigger,
|
|
46
|
+
right under it, and above it when there is no room below. */
|
|
47
|
+
const reposition = () => {
|
|
48
|
+
stopPositioning?.();
|
|
49
|
+
stopPositioning = undefined;
|
|
50
|
+
if (!panel || !trigger || supportsAnchorPositioning()) return;
|
|
51
|
+
|
|
52
|
+
// The edge margin the stylesheet gives the panel, read before the script takes the margins
|
|
53
|
+
// over, so both paths keep the same distance from the side of the screen.
|
|
54
|
+
const edge = parseFloat(getComputedStyle(panel).marginLeft) || 0;
|
|
55
|
+
stopPositioning = positionUnder(
|
|
56
|
+
panel,
|
|
57
|
+
trigger,
|
|
58
|
+
matchWidth ? { gap: FIELD_GAP, matchWidth } : { inset: edge },
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const releasePositioning = () => {
|
|
63
|
+
stopPositioning?.();
|
|
64
|
+
stopPositioning = undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// A panel left open when the dropdown is destroyed would otherwise leave the reposition
|
|
68
|
+
// listeners on the window, holding a detached element.
|
|
69
|
+
onDestroy(releasePositioning);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Opens the panel, or, when it is already open, moves focus back to its `autofocus` element, so a
|
|
73
|
+
* caller can send the keyboard back into the panel after Shift+Tab took it out.
|
|
74
|
+
*/
|
|
75
|
+
export function open() {
|
|
76
|
+
if (!panel) return;
|
|
77
|
+
if (panel.matches(":popover-open")) {
|
|
78
|
+
panel.querySelector<HTMLElement>("[autofocus]")?.focus();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
panel.showPopover();
|
|
82
|
+
reposition();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Closes the panel — for dismissing it once an action inside `content` has finished. */
|
|
86
|
+
export function close() {
|
|
87
|
+
panel?.hidePopover();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* A hidden popover stops rendering entirely, which freezes any exit transition mid-flight and
|
|
91
|
+
means its `transitionend` never arrives. Asking the element whether it is actually visible is
|
|
92
|
+
the only reading that holds for both an animated and an instant close. */
|
|
93
|
+
const unmountWhenHidden = () => {
|
|
94
|
+
if (!panel || panel.matches(":popover-open")) return;
|
|
95
|
+
if (!panel.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
|
|
96
|
+
isMounted = false;
|
|
97
|
+
};
|
|
98
|
+
/* See OptionPanel: a descendant's transition must not be mistaken for the panel's exit. */
|
|
99
|
+
const onTransitionSettled = (event: TransitionEvent) => {
|
|
100
|
+
if (event.target === event.currentTarget) unmountWhenHidden();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onBeforeToggle = (event: ToggleEvent) => {
|
|
104
|
+
if (event.newState === "open") flushSync(() => (isMounted = true));
|
|
105
|
+
else if (panel?.contains(document.activeElement)) trigger?.focus({ preventScroll: true });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onToggle = (event: ToggleEvent) => {
|
|
109
|
+
expanded = event.newState === "open";
|
|
110
|
+
if (expanded) {
|
|
111
|
+
reposition();
|
|
112
|
+
} else {
|
|
113
|
+
releasePositioning();
|
|
114
|
+
unmountWhenHidden();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<!--
|
|
120
|
+
@component
|
|
121
|
+
A button that toggles a panel through the native popover API, so clicking the trigger again,
|
|
122
|
+
clicking outside, and pressing Escape all dismiss it without a single listener of our own. The
|
|
123
|
+
button is the root: class, handlers and aria go straight onto it, and `panelClass` styles the
|
|
124
|
+
panel.
|
|
125
|
+
|
|
126
|
+
The popover itself — not a local flag — is the source of truth for open/closed; `aria-expanded`
|
|
127
|
+
and the bindable `expanded` are read back from its `toggle` event. Content mounts just before the
|
|
128
|
+
panel shows, however it was opened, and unmounts once the panel is really hidden, so an expensive
|
|
129
|
+
menu costs nothing until it is used. An element inside it marked `autofocus` takes the focus as the
|
|
130
|
+
panel shows, and closing the panel with the focus inside it hands the focus back to the trigger.
|
|
131
|
+
|
|
132
|
+
`open()` and `close()` are exposed for `bind:this`, which is what a form inside the panel needs to
|
|
133
|
+
dismiss itself after submitting. `open()` on a panel that is already open moves the focus back to
|
|
134
|
+
its `autofocus` element.
|
|
135
|
+
|
|
136
|
+
A panel that would run off the side of the screen is pulled back onto it and keeps half a rem from
|
|
137
|
+
the edge, in both the anchored and the scripted placement; `--dropdown-edge` changes the distance.
|
|
138
|
+
`matchWidth` hangs it instead just under the trigger and exactly as wide, for a trigger that is a
|
|
139
|
+
field, the way the `Select` panel hangs under its control.
|
|
140
|
+
|
|
141
|
+
```svelte
|
|
142
|
+
<Dropdown bind:this={menu} class="btn" panelClass="dropdown menu w-56 rounded-box bg-base-200 p-2">
|
|
143
|
+
Actions
|
|
144
|
+
{#snippet content()}
|
|
145
|
+
<li><button onclick={() => menu.close()}>Archive</button></li>
|
|
146
|
+
{/snippet}
|
|
147
|
+
</Dropdown>
|
|
148
|
+
```
|
|
149
|
+
-->
|
|
150
|
+
|
|
151
|
+
<button
|
|
152
|
+
{...rest}
|
|
153
|
+
bind:this={trigger}
|
|
154
|
+
type={type ?? "button"}
|
|
155
|
+
popovertarget={panelId}
|
|
156
|
+
aria-expanded={expanded}
|
|
157
|
+
style:anchor-name={anchorName}
|
|
158
|
+
>
|
|
159
|
+
{@render children()}
|
|
160
|
+
</button>
|
|
161
|
+
<div
|
|
162
|
+
bind:this={panel}
|
|
163
|
+
class={[panelClass, matchWidth && "match-width"]}
|
|
164
|
+
popover
|
|
165
|
+
id={panelId}
|
|
166
|
+
style:position-anchor={anchorName}
|
|
167
|
+
onbeforetoggle={onBeforeToggle}
|
|
168
|
+
ontoggle={onToggle}
|
|
169
|
+
ontransitionend={onTransitionSettled}
|
|
170
|
+
ontransitioncancel={onTransitionSettled}
|
|
171
|
+
>
|
|
172
|
+
{#if isMounted}
|
|
173
|
+
{@render content()}
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<style>
|
|
178
|
+
[popover] {
|
|
179
|
+
position-area: bottom;
|
|
180
|
+
position-try: top, left, right;
|
|
181
|
+
/* A panel wider than the room left beside its trigger is shifted back onto the screen, and the
|
|
182
|
+
shift stops at the margin box: with no inline margin a menu opened from a trigger at the edge
|
|
183
|
+
of a phone lands flush against the glass. The custom property is the way to change it, since
|
|
184
|
+
this rule outranks a utility class passed through `panelClass`. */
|
|
185
|
+
margin-inline: var(--dropdown-edge, 0.5rem);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
[popover].match-width {
|
|
189
|
+
position-try: top;
|
|
190
|
+
box-sizing: border-box;
|
|
191
|
+
width: anchor-size(width);
|
|
192
|
+
margin-inline: 0;
|
|
193
|
+
margin-block: 0.25rem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* A closed popover is hidden by a rule in the UA stylesheet, which every author rule outranks --
|
|
197
|
+
including the `display: flex` that daisyUI's `menu` and friends carry. Passing one of those
|
|
198
|
+
through `panelClass` would otherwise pin the panel open with no error anywhere. Restating the
|
|
199
|
+
rule here puts it at a specificity a utility class cannot reach, while leaving an exit
|
|
200
|
+
transition possible for a caller who asks for one with `transition-behavior: allow-discrete`. */
|
|
201
|
+
[popover]:not(:popover-open) {
|
|
202
|
+
display: none;
|
|
203
|
+
}
|
|
204
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Snippet } from "svelte";
|
|
2
|
+
import type { ClassValue, HTMLButtonAttributes } from "svelte/elements";
|
|
3
|
+
type Props = HTMLButtonAttributes & {
|
|
4
|
+
children: Snippet;
|
|
5
|
+
content: Snippet;
|
|
6
|
+
/** Classes for the panel — the only part of this component the trigger's `class` cannot
|
|
7
|
+
reach, since the trigger is the root. */
|
|
8
|
+
panelClass?: ClassValue | null;
|
|
9
|
+
/** Whether the panel is open, however it got there. Bind to read it; the popover decides it. */
|
|
10
|
+
expanded?: boolean;
|
|
11
|
+
/** Hangs the panel just under the trigger at the trigger's width, as a field's list does. */
|
|
12
|
+
matchWidth?: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A button that toggles a panel through the native popover API, so clicking the trigger again,
|
|
16
|
+
* clicking outside, and pressing Escape all dismiss it without a single listener of our own. The
|
|
17
|
+
* button is the root: class, handlers and aria go straight onto it, and `panelClass` styles the
|
|
18
|
+
* panel.
|
|
19
|
+
*
|
|
20
|
+
* The popover itself — not a local flag — is the source of truth for open/closed; `aria-expanded`
|
|
21
|
+
* and the bindable `expanded` are read back from its `toggle` event. Content mounts just before the
|
|
22
|
+
* panel shows, however it was opened, and unmounts once the panel is really hidden, so an expensive
|
|
23
|
+
* menu costs nothing until it is used. An element inside it marked `autofocus` takes the focus as the
|
|
24
|
+
* panel shows, and closing the panel with the focus inside it hands the focus back to the trigger.
|
|
25
|
+
*
|
|
26
|
+
* `open()` and `close()` are exposed for `bind:this`, which is what a form inside the panel needs to
|
|
27
|
+
* dismiss itself after submitting. `open()` on a panel that is already open moves the focus back to
|
|
28
|
+
* its `autofocus` element.
|
|
29
|
+
*
|
|
30
|
+
* A panel that would run off the side of the screen is pulled back onto it and keeps half a rem from
|
|
31
|
+
* the edge, in both the anchored and the scripted placement; `--dropdown-edge` changes the distance.
|
|
32
|
+
* `matchWidth` hangs it instead just under the trigger and exactly as wide, for a trigger that is a
|
|
33
|
+
* field, the way the `Select` panel hangs under its control.
|
|
34
|
+
*
|
|
35
|
+
* ```svelte
|
|
36
|
+
* <Dropdown bind:this={menu} class="btn" panelClass="dropdown menu w-56 rounded-box bg-base-200 p-2">
|
|
37
|
+
* Actions
|
|
38
|
+
* {#snippet content()}
|
|
39
|
+
* <li><button onclick={() => menu.close()}>Archive</button></li>
|
|
40
|
+
* {/snippet}
|
|
41
|
+
* </Dropdown>
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
declare const Dropdown: import("svelte").Component<Props, {
|
|
45
|
+
open: () => void;
|
|
46
|
+
close: () => void;
|
|
47
|
+
}, "expanded">;
|
|
48
|
+
type Dropdown = ReturnType<typeof Dropdown>;
|
|
49
|
+
export default Dropdown;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Wire the `reset` a `svelte:boundary` hands to its `failed` snippet. Omitted, no button. */
|
|
4
|
+
reset?: () => void;
|
|
5
|
+
title?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
reset,
|
|
11
|
+
title = "Something went wrong",
|
|
12
|
+
message = "The application ran into an unexpected problem.",
|
|
13
|
+
}: Props = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<!--
|
|
17
|
+
@component
|
|
18
|
+
Calm in-place fallback for a crashed subtree: it says what happened and offers the way out.
|
|
19
|
+
|
|
20
|
+
It shows no technical detail on purpose — a stack is not source-mapped in production and the
|
|
21
|
+
message was written for whoever wrote the code, not for whoever is reading the screen. Reporting
|
|
22
|
+
is not this component's job either: it renders on every retry, so a report from here would be
|
|
23
|
+
counted once per render. Wire `reportError` on the boundary's `onerror` instead.
|
|
24
|
+
|
|
25
|
+
```svelte
|
|
26
|
+
<svelte:boundary onerror={reportError}>
|
|
27
|
+
{@render children()}
|
|
28
|
+
{#snippet failed(_error, reset)}<ErrorDisplay {reset} />{/snippet}
|
|
29
|
+
</svelte:boundary>
|
|
30
|
+
```
|
|
31
|
+
-->
|
|
32
|
+
|
|
33
|
+
<div
|
|
34
|
+
class="flex h-full w-full flex-col items-center justify-center gap-2 p-8 text-center"
|
|
35
|
+
role="alert"
|
|
36
|
+
>
|
|
37
|
+
<svg
|
|
38
|
+
class="size-12 text-base-content/40"
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
stroke-width="1.5"
|
|
43
|
+
stroke-linecap="round"
|
|
44
|
+
stroke-linejoin="round"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
>
|
|
47
|
+
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
|
48
|
+
<path d="M12 9v4" />
|
|
49
|
+
<path d="M12 17h.01" />
|
|
50
|
+
</svg>
|
|
51
|
+
<p class="text-xs font-medium tracking-widest text-base-content/55 uppercase">Error</p>
|
|
52
|
+
<p class="text-xl font-semibold">{title}</p>
|
|
53
|
+
<p class="max-w-md text-sm text-base-content/60">{message}</p>
|
|
54
|
+
{#if reset}
|
|
55
|
+
<div class="mt-4">
|
|
56
|
+
<button type="button" class="btn btn-primary" onclick={reset}>Try again</button>
|
|
57
|
+
</div>
|
|
58
|
+
{/if}
|
|
59
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** Wire the `reset` a `svelte:boundary` hands to its `failed` snippet. Omitted, no button. */
|
|
3
|
+
reset?: () => void;
|
|
4
|
+
title?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Calm in-place fallback for a crashed subtree: it says what happened and offers the way out.
|
|
9
|
+
*
|
|
10
|
+
* It shows no technical detail on purpose — a stack is not source-mapped in production and the
|
|
11
|
+
* message was written for whoever wrote the code, not for whoever is reading the screen. Reporting
|
|
12
|
+
* is not this component's job either: it renders on every retry, so a report from here would be
|
|
13
|
+
* counted once per render. Wire `reportError` on the boundary's `onerror` instead.
|
|
14
|
+
*
|
|
15
|
+
* ```svelte
|
|
16
|
+
* <svelte:boundary onerror={reportError}>
|
|
17
|
+
* {@render children()}
|
|
18
|
+
* {#snippet failed(_error, reset)}<ErrorDisplay {reset} />{/snippet}
|
|
19
|
+
* </svelte:boundary>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
declare const ErrorDisplay: import("svelte").Component<Props, {}, "">;
|
|
23
|
+
type ErrorDisplay = ReturnType<typeof ErrorDisplay>;
|
|
24
|
+
export default ErrorDisplay;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
/** HTTP status behind the failure. 404 gets its own wording and icon. */
|
|
4
|
+
status: number;
|
|
5
|
+
/** Message from `error(status, message)`. Ignored for a 404, where the path says more. */
|
|
6
|
+
message?: string;
|
|
7
|
+
/** Path that was asked for. Shown only on a 404. */
|
|
8
|
+
pathname?: string;
|
|
9
|
+
/** Where "back to start" goes. Pass `resolve("/")` from an app with a base path. */
|
|
10
|
+
homeHref?: string;
|
|
11
|
+
/** The chip above the heading, given the status. */
|
|
12
|
+
statusLabel?: (status: number) => string;
|
|
13
|
+
/** Heading of a 404. */
|
|
14
|
+
notFoundTitle?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Body of a 404, given the path when one was passed. Left out, the English sentence renders
|
|
17
|
+
* with the address in a monospace run of its own, which a returned string cannot carry.
|
|
18
|
+
*/
|
|
19
|
+
notFoundBody?: (pathname: string | undefined) => string;
|
|
20
|
+
/** Heading of every other status. */
|
|
21
|
+
errorTitle?: string;
|
|
22
|
+
/** Body of every other status, used when no `message` was passed. */
|
|
23
|
+
errorBody?: string;
|
|
24
|
+
/** Label of the link home. */
|
|
25
|
+
homeLabel?: string;
|
|
26
|
+
/** Label of the reload button, which a 404 does not offer. */
|
|
27
|
+
reloadLabel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
status,
|
|
32
|
+
message,
|
|
33
|
+
pathname,
|
|
34
|
+
homeHref = "/",
|
|
35
|
+
statusLabel = (status: number) => `Error ${status}`,
|
|
36
|
+
notFoundTitle = "Page not found",
|
|
37
|
+
notFoundBody,
|
|
38
|
+
errorTitle = "Something went wrong",
|
|
39
|
+
errorBody = "The application ran into an unexpected problem.",
|
|
40
|
+
homeLabel = "Back to start",
|
|
41
|
+
reloadLabel = "Reload",
|
|
42
|
+
}: Props = $props();
|
|
43
|
+
|
|
44
|
+
const notFound = $derived(status === 404);
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<!--
|
|
48
|
+
@component
|
|
49
|
+
Route-level error page: status-aware, human-facing, rendered from an app's `+error.svelte`.
|
|
50
|
+
|
|
51
|
+
Everything it shows arrives as a prop rather than being read from `$app/state` inside the
|
|
52
|
+
component. That keeps it renderable — and testable — outside a SvelteKit navigation, and it keeps
|
|
53
|
+
the choice of what to reveal at the app's own `+error.svelte`, which is where the app can tell an
|
|
54
|
+
intentional `error(403, "...")` message apart from one it would rather not print.
|
|
55
|
+
|
|
56
|
+
Every word of the copy is a prop with an English default, because the library ships no
|
|
57
|
+
translations: `statusLabel`, `notFoundTitle`, `notFoundBody`, `errorTitle`, `errorBody`,
|
|
58
|
+
`homeLabel` and `reloadLabel`. `message` still wins over `errorBody` whenever the app passed one.
|
|
59
|
+
|
|
60
|
+
```svelte
|
|
61
|
+
<script lang="ts">
|
|
62
|
+
import { page } from "$app/state";
|
|
63
|
+
import { resolve } from "$app/paths";
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<ErrorPage status={page.status} message={page.error?.message} pathname={page.url.pathname}
|
|
67
|
+
homeHref={resolve("/")} />
|
|
68
|
+
```
|
|
69
|
+
-->
|
|
70
|
+
|
|
71
|
+
<div class="flex h-full w-full flex-col items-center justify-center gap-2 p-8 text-center">
|
|
72
|
+
<svg
|
|
73
|
+
class="size-12 text-base-content/40"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
stroke-width="1.5"
|
|
78
|
+
stroke-linecap="round"
|
|
79
|
+
stroke-linejoin="round"
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
>
|
|
82
|
+
{#if notFound}
|
|
83
|
+
<path d="m16.24 7.76-1.8 5.41a2 2 0 0 1-1.27 1.27l-5.41 1.8 1.8-5.41a2 2 0 0 1 1.27-1.27z" />
|
|
84
|
+
<circle cx="12" cy="12" r="10" />
|
|
85
|
+
{:else}
|
|
86
|
+
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
|
87
|
+
<path d="M12 9v4" />
|
|
88
|
+
<path d="M12 17h.01" />
|
|
89
|
+
{/if}
|
|
90
|
+
</svg>
|
|
91
|
+
<p class="text-xs font-medium tracking-widest text-base-content/55 uppercase">
|
|
92
|
+
{statusLabel(status)}
|
|
93
|
+
</p>
|
|
94
|
+
<h1 class="text-xl font-semibold">
|
|
95
|
+
{notFound ? notFoundTitle : errorTitle}
|
|
96
|
+
</h1>
|
|
97
|
+
<p class="max-w-md text-sm break-words text-base-content/60">
|
|
98
|
+
{#if notFound}
|
|
99
|
+
{#if notFoundBody}
|
|
100
|
+
{notFoundBody(pathname)}
|
|
101
|
+
{:else}
|
|
102
|
+
The address {#if pathname}<span class="font-mono break-all">{pathname}</span>{:else}you
|
|
103
|
+
asked for{/if} does not exist, or has moved.
|
|
104
|
+
{/if}
|
|
105
|
+
{:else}
|
|
106
|
+
{message ?? errorBody}
|
|
107
|
+
{/if}
|
|
108
|
+
</p>
|
|
109
|
+
<div class="mt-4 flex gap-2">
|
|
110
|
+
<!-- The link is resolved by the app, which is the side that knows its route ids and its base
|
|
111
|
+
path; the library only renders what it was handed. -->
|
|
112
|
+
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
|
113
|
+
<a class="btn btn-primary" href={homeHref}>{homeLabel}</a>
|
|
114
|
+
{#if !notFound}
|
|
115
|
+
<!-- A reload is worth offering only for a failure that might not repeat; a 404 will. -->
|
|
116
|
+
<button type="button" class="btn" onclick={() => location.reload()}>{reloadLabel}</button>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** HTTP status behind the failure. 404 gets its own wording and icon. */
|
|
3
|
+
status: number;
|
|
4
|
+
/** Message from `error(status, message)`. Ignored for a 404, where the path says more. */
|
|
5
|
+
message?: string;
|
|
6
|
+
/** Path that was asked for. Shown only on a 404. */
|
|
7
|
+
pathname?: string;
|
|
8
|
+
/** Where "back to start" goes. Pass `resolve("/")` from an app with a base path. */
|
|
9
|
+
homeHref?: string;
|
|
10
|
+
/** The chip above the heading, given the status. */
|
|
11
|
+
statusLabel?: (status: number) => string;
|
|
12
|
+
/** Heading of a 404. */
|
|
13
|
+
notFoundTitle?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Body of a 404, given the path when one was passed. Left out, the English sentence renders
|
|
16
|
+
* with the address in a monospace run of its own, which a returned string cannot carry.
|
|
17
|
+
*/
|
|
18
|
+
notFoundBody?: (pathname: string | undefined) => string;
|
|
19
|
+
/** Heading of every other status. */
|
|
20
|
+
errorTitle?: string;
|
|
21
|
+
/** Body of every other status, used when no `message` was passed. */
|
|
22
|
+
errorBody?: string;
|
|
23
|
+
/** Label of the link home. */
|
|
24
|
+
homeLabel?: string;
|
|
25
|
+
/** Label of the reload button, which a 404 does not offer. */
|
|
26
|
+
reloadLabel?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Route-level error page: status-aware, human-facing, rendered from an app's `+error.svelte`.
|
|
30
|
+
*
|
|
31
|
+
* Everything it shows arrives as a prop rather than being read from `$app/state` inside the
|
|
32
|
+
* component. That keeps it renderable — and testable — outside a SvelteKit navigation, and it keeps
|
|
33
|
+
* the choice of what to reveal at the app's own `+error.svelte`, which is where the app can tell an
|
|
34
|
+
* intentional `error(403, "...")` message apart from one it would rather not print.
|
|
35
|
+
*
|
|
36
|
+
* Every word of the copy is a prop with an English default, because the library ships no
|
|
37
|
+
* translations: `statusLabel`, `notFoundTitle`, `notFoundBody`, `errorTitle`, `errorBody`,
|
|
38
|
+
* `homeLabel` and `reloadLabel`. `message` still wins over `errorBody` whenever the app passed one.
|
|
39
|
+
*
|
|
40
|
+
* ```svelte
|
|
41
|
+
* <script lang="ts">
|
|
42
|
+
* import { page } from "$app/state";
|
|
43
|
+
* import { resolve } from "$app/paths";
|
|
44
|
+
* </script>
|
|
45
|
+
*
|
|
46
|
+
* <ErrorPage status={page.status} message={page.error?.message} pathname={page.url.pathname}
|
|
47
|
+
* homeHref={resolve("/")} />
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare const ErrorPage: import("svelte").Component<Props, {}, "">;
|
|
51
|
+
type ErrorPage = ReturnType<typeof ErrorPage>;
|
|
52
|
+
export default ErrorPage;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Attachment } from "svelte/attachments";
|
|
3
|
+
import type { ClassValue, HTMLTextareaAttributes } from "svelte/elements";
|
|
4
|
+
import { formatJsonText, parseJsonText } from "./json-text.js";
|
|
5
|
+
|
|
6
|
+
type Props = Omit<
|
|
7
|
+
HTMLTextareaAttributes,
|
|
8
|
+
"bind:value" | "children" | "class" | "onblur" | "required" | "value"
|
|
9
|
+
> & {
|
|
10
|
+
/** The text being edited. Bindable: the field holds text, never a parsed object. */
|
|
11
|
+
value?: string;
|
|
12
|
+
/** `true` while the text does not parse. Bind it to gate a submit. */
|
|
13
|
+
invalid?: boolean;
|
|
14
|
+
/** The field's visible label. */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Reformats the text on blur when it parses. Text that does not parse is left as typed. */
|
|
17
|
+
formatOnBlur?: boolean;
|
|
18
|
+
/** Rejects blank text, in `invalid` and in the surrounding form's validation. */
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** Leads the error shown under the field; the parser's own message follows it. */
|
|
21
|
+
invalidLabel?: string;
|
|
22
|
+
/** The error for blank text when `required`. */
|
|
23
|
+
requiredLabel?: string;
|
|
24
|
+
/** Classes for the whole field. The textarea is where every other attribute goes. */
|
|
25
|
+
class?: ClassValue;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
value = $bindable(""),
|
|
30
|
+
invalid = $bindable(false),
|
|
31
|
+
label,
|
|
32
|
+
formatOnBlur = false,
|
|
33
|
+
required = false,
|
|
34
|
+
invalidLabel = "Invalid JSON",
|
|
35
|
+
requiredLabel = "Required",
|
|
36
|
+
rows = 12,
|
|
37
|
+
class: className,
|
|
38
|
+
...rest
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
const uid = $props.id();
|
|
42
|
+
const fieldId = $derived(rest.id ?? `plinth-json-input-${uid}`);
|
|
43
|
+
const errorId = `plinth-json-input-${uid}-error`;
|
|
44
|
+
|
|
45
|
+
let touched = $state(false);
|
|
46
|
+
|
|
47
|
+
const result = $derived(parseJsonText(value, { required, requiredMessage: requiredLabel }));
|
|
48
|
+
const message = $derived(result.valid ? "" : `${invalidLabel}: ${result.error}`);
|
|
49
|
+
const showError = $derived(touched && !result.valid);
|
|
50
|
+
|
|
51
|
+
$effect(() => {
|
|
52
|
+
const next = !result.valid;
|
|
53
|
+
if (invalid !== next) invalid = next;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const validity: Attachment<HTMLTextAreaElement> = (field) => {
|
|
57
|
+
field.setCustomValidity(message);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onblur = () => {
|
|
61
|
+
touched = true;
|
|
62
|
+
if (formatOnBlur) value = formatJsonText(value);
|
|
63
|
+
};
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<!--
|
|
67
|
+
@component
|
|
68
|
+
A JSON text field: it parses as you type, reports whether the text parses through a bindable
|
|
69
|
+
`invalid`, and can reformat the document on blur.
|
|
70
|
+
|
|
71
|
+
It holds text, never a parsed object. A document is invalid for most of the time it is being
|
|
72
|
+
edited, and holding the object would throw away what was typed. Bind `invalid` to gate a submit,
|
|
73
|
+
then parse the text once when sending, with `parseJsonText` or `JSON.parse`.
|
|
74
|
+
|
|
75
|
+
The error waits for the first blur, since every document is invalid while its first brace is being
|
|
76
|
+
typed, and it carries the parser's own message, which names where the document broke. The same
|
|
77
|
+
message is the textarea's custom validity, so a surrounding form will not submit text that does not
|
|
78
|
+
parse.
|
|
79
|
+
|
|
80
|
+
```svelte
|
|
81
|
+
<JsonInput bind:value={text} bind:invalid label="Settings" formatOnBlur required />
|
|
82
|
+
<button disabled={invalid} onclick={() => save(JSON.parse(text))}>Save</button>
|
|
83
|
+
```
|
|
84
|
+
-->
|
|
85
|
+
|
|
86
|
+
<div class={["plinth-json-input fieldset w-full", className]}>
|
|
87
|
+
{#if label}
|
|
88
|
+
<label class="fieldset-legend" for={fieldId}>{label}</label>
|
|
89
|
+
{/if}
|
|
90
|
+
<textarea
|
|
91
|
+
{...rest}
|
|
92
|
+
{@attach validity}
|
|
93
|
+
id={fieldId}
|
|
94
|
+
class={["textarea w-full font-mono text-xs", showError && "textarea-error"]}
|
|
95
|
+
{rows}
|
|
96
|
+
{required}
|
|
97
|
+
spellcheck="false"
|
|
98
|
+
aria-invalid={showError}
|
|
99
|
+
aria-describedby={[rest["aria-describedby"], showError && errorId].filter(Boolean).join(" ") ||
|
|
100
|
+
undefined}
|
|
101
|
+
bind:value
|
|
102
|
+
{onblur}></textarea>
|
|
103
|
+
{#if showError}
|
|
104
|
+
<p id={errorId} class="label whitespace-normal text-error">{message}</p>
|
|
105
|
+
{/if}
|
|
106
|
+
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ClassValue, HTMLTextareaAttributes } from "svelte/elements";
|
|
2
|
+
type Props = Omit<HTMLTextareaAttributes, "bind:value" | "children" | "class" | "onblur" | "required" | "value"> & {
|
|
3
|
+
/** The text being edited. Bindable: the field holds text, never a parsed object. */
|
|
4
|
+
value?: string;
|
|
5
|
+
/** `true` while the text does not parse. Bind it to gate a submit. */
|
|
6
|
+
invalid?: boolean;
|
|
7
|
+
/** The field's visible label. */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Reformats the text on blur when it parses. Text that does not parse is left as typed. */
|
|
10
|
+
formatOnBlur?: boolean;
|
|
11
|
+
/** Rejects blank text, in `invalid` and in the surrounding form's validation. */
|
|
12
|
+
required?: boolean;
|
|
13
|
+
/** Leads the error shown under the field; the parser's own message follows it. */
|
|
14
|
+
invalidLabel?: string;
|
|
15
|
+
/** The error for blank text when `required`. */
|
|
16
|
+
requiredLabel?: string;
|
|
17
|
+
/** Classes for the whole field. The textarea is where every other attribute goes. */
|
|
18
|
+
class?: ClassValue;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A JSON text field: it parses as you type, reports whether the text parses through a bindable
|
|
22
|
+
* `invalid`, and can reformat the document on blur.
|
|
23
|
+
*
|
|
24
|
+
* It holds text, never a parsed object. A document is invalid for most of the time it is being
|
|
25
|
+
* edited, and holding the object would throw away what was typed. Bind `invalid` to gate a submit,
|
|
26
|
+
* then parse the text once when sending, with `parseJsonText` or `JSON.parse`.
|
|
27
|
+
*
|
|
28
|
+
* The error waits for the first blur, since every document is invalid while its first brace is being
|
|
29
|
+
* typed, and it carries the parser's own message, which names where the document broke. The same
|
|
30
|
+
* message is the textarea's custom validity, so a surrounding form will not submit text that does not
|
|
31
|
+
* parse.
|
|
32
|
+
*
|
|
33
|
+
* ```svelte
|
|
34
|
+
* <JsonInput bind:value={text} bind:invalid label="Settings" formatOnBlur required />
|
|
35
|
+
* <button disabled={invalid} onclick={() => save(JSON.parse(text))}>Save</button>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
declare const JsonInput: import("svelte").Component<Props, {}, "invalid" | "value">;
|
|
39
|
+
type JsonInput = ReturnType<typeof JsonInput>;
|
|
40
|
+
export default JsonInput;
|