@viniaraujo68/plinth 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/anchoring.d.ts +61 -0
- package/dist/anchoring.js +151 -0
- package/dist/async-action.svelte.d.ts +37 -0
- package/dist/async-action.svelte.js +64 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/tooltip.d.ts +37 -0
- package/dist/attachments/tooltip.js +166 -0
- package/dist/components/AsyncButton.svelte +49 -0
- package/dist/components/AsyncButton.svelte.d.ts +32 -0
- package/dist/components/Combobox.svelte +380 -0
- package/dist/components/Combobox.svelte.d.ts +74 -0
- package/dist/components/Copyable.svelte +116 -0
- package/dist/components/Copyable.svelte.d.ts +37 -0
- package/dist/components/DateRangePicker.svelte +266 -0
- package/dist/components/DateRangePicker.svelte.d.ts +66 -0
- package/dist/components/Dialog.svelte +102 -0
- package/dist/components/Dialog.svelte.d.ts +38 -0
- package/dist/components/Dropdown.svelte +204 -0
- package/dist/components/Dropdown.svelte.d.ts +49 -0
- package/dist/components/ErrorDisplay.svelte +59 -0
- package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
- package/dist/components/ErrorPage.svelte +119 -0
- package/dist/components/ErrorPage.svelte.d.ts +52 -0
- package/dist/components/JsonInput.svelte +106 -0
- package/dist/components/JsonInput.svelte.d.ts +40 -0
- package/dist/components/Listbox.svelte +257 -0
- package/dist/components/Listbox.svelte.d.ts +78 -0
- package/dist/components/LoadingButton.svelte +36 -0
- package/dist/components/LoadingButton.svelte.d.ts +22 -0
- package/dist/components/Logo.svelte +112 -0
- package/dist/components/Logo.svelte.d.ts +29 -0
- package/dist/components/Modal.svelte +187 -0
- package/dist/components/Modal.svelte.d.ts +62 -0
- package/dist/components/MultiSelect.svelte +256 -0
- package/dist/components/MultiSelect.svelte.d.ts +89 -0
- package/dist/components/OptionPanel.svelte +241 -0
- package/dist/components/OptionPanel.svelte.d.ts +51 -0
- package/dist/components/Paginator.svelte +205 -0
- package/dist/components/Paginator.svelte.d.ts +48 -0
- package/dist/components/RefreshButton.svelte +246 -0
- package/dist/components/RefreshButton.svelte.d.ts +46 -0
- package/dist/components/SegmentedControl.svelte +147 -0
- package/dist/components/SegmentedControl.svelte.d.ts +71 -0
- package/dist/components/Select.svelte +312 -0
- package/dist/components/Select.svelte.d.ts +68 -0
- package/dist/components/Skeleton.svelte +97 -0
- package/dist/components/Skeleton.svelte.d.ts +32 -0
- package/dist/components/Tooltip.svelte +60 -0
- package/dist/components/Tooltip.svelte.d.ts +58 -0
- package/dist/components/date-range.d.ts +101 -0
- package/dist/components/date-range.js +144 -0
- package/dist/components/index.d.ts +25 -0
- package/dist/components/index.js +37 -0
- package/dist/components/json-text.d.ts +22 -0
- package/dist/components/json-text.js +22 -0
- package/dist/components/pick.d.ts +18 -0
- package/dist/components/pick.js +18 -0
- package/dist/components/segmented.d.ts +7 -0
- package/dist/components/segmented.js +1 -0
- package/dist/components/select.d.ts +66 -0
- package/dist/components/select.js +90 -0
- package/dist/confirm/Confirmer.svelte +194 -0
- package/dist/confirm/Confirmer.svelte.d.ts +45 -0
- package/dist/confirm/challenge.d.ts +16 -0
- package/dist/confirm/challenge.js +16 -0
- package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
- package/dist/confirm/confirm-manager.svelte.js +149 -0
- package/dist/confirm/index.d.ts +3 -0
- package/dist/confirm/index.js +3 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.js +39 -0
- package/dist/formatters.d.ts +95 -0
- package/dist/formatters.js +146 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +138 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/routing.svelte.d.ts +169 -0
- package/dist/routing.svelte.js +208 -0
- package/dist/shell/AppShell.svelte +874 -0
- package/dist/shell/AppShell.svelte.d.ts +93 -0
- package/dist/shell/Breadcrumbs.svelte +78 -0
- package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
- package/dist/shell/crumbs.d.ts +23 -0
- package/dist/shell/crumbs.js +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +6 -0
- package/dist/table/ColumnMenu.svelte +249 -0
- package/dist/table/ColumnMenu.svelte.d.ts +47 -0
- package/dist/table/DataTable.svelte +643 -0
- package/dist/table/DataTable.svelte.d.ts +153 -0
- package/dist/table/columns.d.ts +55 -0
- package/dist/table/columns.js +153 -0
- package/dist/table/index.d.ts +4 -0
- package/dist/table/index.js +8 -0
- package/dist/table/sort.d.ts +48 -0
- package/dist/table/sort.js +139 -0
- package/dist/table/types.d.ts +93 -0
- package/dist/table/types.js +1 -0
- package/dist/theme/ThemeController.svelte +54 -0
- package/dist/theme/ThemeController.svelte.d.ts +27 -0
- package/dist/theme/ThemeToggle.svelte +119 -0
- package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
- package/dist/theme/css-color.d.ts +23 -0
- package/dist/theme/css-color.js +31 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/theme.css +410 -0
- package/dist/theme/theme.svelte.d.ts +54 -0
- package/dist/theme/theme.svelte.js +58 -0
- package/dist/toast/Toaster.svelte +257 -0
- package/dist/toast/Toaster.svelte.d.ts +35 -0
- package/dist/toast/index.d.ts +2 -0
- package/dist/toast/index.js +2 -0
- package/dist/toast/toast-manager.svelte.d.ts +99 -0
- package/dist/toast/toast-manager.svelte.js +176 -0
- package/dist/user/ScopedComponent.svelte +47 -0
- package/dist/user/ScopedComponent.svelte.d.ts +29 -0
- package/dist/user/anonymous.d.ts +21 -0
- package/dist/user/anonymous.js +26 -0
- package/dist/user/context.d.ts +55 -0
- package/dist/user/context.js +13 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.js +6 -0
- package/package.json +133 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 viniaraujo68
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,362 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plinth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A personal Svelte 5 design system, built on Tailwind 4 and daisyUI 5: a theme, an application
|
|
4
|
+
shell, interface primitives and the patterns around them — routing metadata, an identity seam, a
|
|
5
|
+
fetch wrapper and locale-aware formatters.
|
|
6
|
+
|
|
7
|
+
One package, one entry point per area, so an app that only wants a formatter never pays for a
|
|
8
|
+
shell. The library lives in `src/lib` and is published with `svelte-package`; everything in
|
|
9
|
+
`src/routes` is the showcase, a static site that doubles as the documentation and is never
|
|
10
|
+
published.
|
|
11
|
+
|
|
12
|
+
**Showcase:** <https://viniaraujo68.github.io/plinth>
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm i @viniaraujo68/plinth
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
An unreleased commit installs straight from GitHub with `npm i github:viniaraujo68/plinth#<ref>`:
|
|
21
|
+
npm runs the `prepare` script for a git dependency — and installs the dev dependencies needed to
|
|
22
|
+
do it — so `svelte-package` builds `dist` on the consumer's machine. Both give you the same import
|
|
23
|
+
specifiers, which are what every example below uses.
|
|
24
|
+
|
|
25
|
+
The package declares `svelte` and `@sveltejs/kit` as peer dependencies; Tailwind 4 and daisyUI 5
|
|
26
|
+
are the consumer's own, because the theme is a stylesheet the consumer imports rather than a
|
|
27
|
+
bundle the library emits. The `svelte` floor is `^5.57.0`, and it is a real floor rather than a
|
|
28
|
+
precaution: the user context is built on the `[get, set, has]` form of `createContext`, which
|
|
29
|
+
5.56 does not have — there `has` is `undefined` and the first component that reads the context
|
|
30
|
+
throws.
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
### 1. The stylesheet
|
|
35
|
+
|
|
36
|
+
The app owns its CSS chain. A SvelteKit app with its entry stylesheet at `src/routes/layout.css`:
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
/* The theme first: it is plain unlayered CSS, so it outranks everything daisyUI emits into a
|
|
40
|
+
cascade layer, and its `@import url(...)` for the fonts has to reach the top of the bundle. */
|
|
41
|
+
@import "@viniaraujo68/plinth/theme.css";
|
|
42
|
+
|
|
43
|
+
/* Tailwind only scans this project's own files. The library's classes live in a package it never
|
|
44
|
+
looks at, so point it at the shipped `dist` — the path is relative to THIS file. */
|
|
45
|
+
@source "../../node_modules/@viniaraujo68/plinth/dist";
|
|
46
|
+
|
|
47
|
+
@import "tailwindcss";
|
|
48
|
+
|
|
49
|
+
@plugin "daisyui" {
|
|
50
|
+
exclude: rootscrollgutter;
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then import it once, from the root layout: `import "./layout.css";`.
|
|
55
|
+
|
|
56
|
+
If the library is linked rather than installed (`npm link`, a workspace, a `file:` dependency),
|
|
57
|
+
`@source` has to point at wherever the real `dist` ended up — a symlinked path is not scanned
|
|
58
|
+
through.
|
|
59
|
+
|
|
60
|
+
### 2. The Vite build target
|
|
61
|
+
|
|
62
|
+
**This one is not optional.** Every color in the theme is declared once through `light-dark()`,
|
|
63
|
+
and that is what makes `[data-theme]` on any element re-theme its subtree. Lightning CSS — which
|
|
64
|
+
Vite runs to minify CSS — rewrites `light-dark()` into a pair of inherited guard variables
|
|
65
|
+
whenever the configured target browsers predate it. The rewrite is not equivalent: the guards
|
|
66
|
+
resolve once at `:root`, and a nested `[data-theme]` stops working, silently, in the production
|
|
67
|
+
build only.
|
|
68
|
+
|
|
69
|
+
Name targets that support `light-dark()` natively:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// vite.config.ts
|
|
73
|
+
export default defineConfig({
|
|
74
|
+
build: { cssTarget: ["chrome123", "edge123", "firefox120", "safari17.5"] },
|
|
75
|
+
plugins: [tailwindcss(), sveltekit()],
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 3. The route declaration
|
|
80
|
+
|
|
81
|
+
The route tree is the filesystem; this file only attaches presentation and access data to route
|
|
82
|
+
ids SvelteKit already knows. Nothing here re-states the hierarchy.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// src/lib/routes.ts
|
|
86
|
+
import type { RouteId } from "$app/types";
|
|
87
|
+
import type { RoutingConfig } from "@viniaraujo68/plinth/routing";
|
|
88
|
+
|
|
89
|
+
export const routes: RoutingConfig<RouteId> = {
|
|
90
|
+
meta: {
|
|
91
|
+
"/": { title: "Home", icon: "icon-[lucide--home]" },
|
|
92
|
+
"/orders": { title: "Orders", icon: "icon-[lucide--receipt]" },
|
|
93
|
+
"/orders/[id]": { title: ({ params }) => `Order ${params.id}` },
|
|
94
|
+
"/admin": { title: "Admin", icon: "icon-[lucide--shield]", requiredRoles: ["admin"] },
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// The glob has to be written here, in the app, and never inside the library: Vite rewrites the
|
|
98
|
+
// literal call at build time against the file it appears in, so a library issuing it on the
|
|
99
|
+
// app's behalf would scan its own package and come back empty-handed. Only the keys are read.
|
|
100
|
+
pages: import.meta.glob("/src/routes/**/+page.svelte"),
|
|
101
|
+
};
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Keying `meta` with `RouteId` from `$app/types` is what makes a renamed or deleted route a compile
|
|
105
|
+
error instead of a crumb that quietly stops appearing. `icon` is an uninterpreted string handed
|
|
106
|
+
straight to markup as `<span class={meta.icon}>` — an icon font or an Iconify utility class fits
|
|
107
|
+
without configuration; anything else goes through `AppShell`'s `icon` snippet.
|
|
108
|
+
|
|
109
|
+
### 4. The root layout
|
|
110
|
+
|
|
111
|
+
```svelte
|
|
112
|
+
<!-- src/routes/+layout.svelte -->
|
|
113
|
+
<script lang="ts">
|
|
114
|
+
import "./layout.css";
|
|
115
|
+
import { browser } from "$app/environment";
|
|
116
|
+
import { page } from "$app/state";
|
|
117
|
+
import type { Snippet } from "svelte";
|
|
118
|
+
import { RoutingContext, setRoutingContext } from "@viniaraujo68/plinth/routing";
|
|
119
|
+
import { AppShell } from "@viniaraujo68/plinth/shell";
|
|
120
|
+
import {
|
|
121
|
+
readThemePreference,
|
|
122
|
+
setThemeContext,
|
|
123
|
+
ThemeContext,
|
|
124
|
+
ThemeController,
|
|
125
|
+
ThemeToggle,
|
|
126
|
+
} from "@viniaraujo68/plinth/theme";
|
|
127
|
+
import { Toaster } from "@viniaraujo68/plinth/toast";
|
|
128
|
+
import { setUserContext } from "@viniaraujo68/plinth/user";
|
|
129
|
+
import { routes } from "$lib/routes.js";
|
|
130
|
+
|
|
131
|
+
const { children }: { children: Snippet } = $props();
|
|
132
|
+
|
|
133
|
+
// Contexts are installed while this component initialises, so every route below inherits them.
|
|
134
|
+
setThemeContext(new ThemeContext(browser ? readThemePreference() : "system"));
|
|
135
|
+
// SvelteKit already matched the URL; the routing context only projects that match through the
|
|
136
|
+
// meta map, so `page` is handed in as-is.
|
|
137
|
+
setRoutingContext(new RoutingContext(routes, page));
|
|
138
|
+
setUserContext({
|
|
139
|
+
status: "authenticated",
|
|
140
|
+
data: { name: "Ada Lovelace", email: "ada@example.com" },
|
|
141
|
+
hasRole: (role) => role === "admin",
|
|
142
|
+
hasAnyRole: (roles) => roles.length === 0 || roles.includes("admin"),
|
|
143
|
+
hasAllRoles: (roles) => roles.every((role) => role === "admin"),
|
|
144
|
+
});
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<ThemeController />
|
|
148
|
+
|
|
149
|
+
<div class="h-dvh">
|
|
150
|
+
<AppShell>
|
|
151
|
+
{#snippet brand({ collapsed })}
|
|
152
|
+
<span class="font-semibold tracking-tight">{collapsed ? "A" : "Acme"}</span>
|
|
153
|
+
{/snippet}
|
|
154
|
+
{#snippet footer()}
|
|
155
|
+
<ThemeToggle iconOnly />
|
|
156
|
+
{/snippet}
|
|
157
|
+
{@render children()}
|
|
158
|
+
</AppShell>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<!-- Outside the routed subtree: a toast fired just before a navigation has to outlive the page
|
|
162
|
+
that fired it. -->
|
|
163
|
+
<Toaster />
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`ThemeController` is a hidden checkbox that must be mounted exactly once, unconditionally — it is
|
|
167
|
+
what CSS keys the theme on, and every other picker in the app just writes `preference` on the
|
|
168
|
+
theme context. `AppShell` fills its parent, so give it one with a height. Its navigation lists the
|
|
169
|
+
titled first-level routes that have a page of their own; a titled route with no page stays a heading
|
|
170
|
+
in the breadcrumb trail and is left out of the navigation, which has nothing to link it to.
|
|
171
|
+
|
|
172
|
+
An app that cannot render the controller in the right state on the server — a prerendered site, a
|
|
173
|
+
client-only app — usually stamps `data-theme="plinth-light"` or `data-theme="plinth-dark"` on
|
|
174
|
+
`<html>` from a head script, so the very first paint is already right. That stamp is a **seed for
|
|
175
|
+
the first paint and nothing else.** The stylesheet gives it up the moment a checked controller is
|
|
176
|
+
under it, and `ThemeController` drops it outright when it mounts, so from hydration onwards the
|
|
177
|
+
preference is the only thing painting the page — including the "system" preference, which checks
|
|
178
|
+
nothing and would otherwise keep losing to a stale stamp forever. Do not read the root
|
|
179
|
+
`data-theme` back as the current theme: read `preference` on the theme context, or `dark` for the
|
|
180
|
+
resolved one. A `data-theme` on any element **below** the root is a different thing entirely and
|
|
181
|
+
is left alone — that is subtree theming, and it keeps working whatever the page theme is.
|
|
182
|
+
|
|
183
|
+
`setUserContext` is optional: without it the library falls back to an anonymous context whose role
|
|
184
|
+
checks all answer `true`, so a route tree annotated with `requiredRoles` still renders while there
|
|
185
|
+
is no authentication to evaluate it against. Any object matching the `UserContext` interface will
|
|
186
|
+
do — reactive if the answers can change, which a class with `$state` fields gives you.
|
|
187
|
+
|
|
188
|
+
### 5. A page
|
|
189
|
+
|
|
190
|
+
```svelte
|
|
191
|
+
<!-- src/routes/orders/+page.svelte -->
|
|
192
|
+
<script lang="ts">
|
|
193
|
+
import { Breadcrumbs } from "@viniaraujo68/plinth/shell";
|
|
194
|
+
import { DataTable } from "@viniaraujo68/plinth/table";
|
|
195
|
+
import type { Column } from "@viniaraujo68/plinth/table";
|
|
196
|
+
import { createFormatters } from "@viniaraujo68/plinth/formatters";
|
|
197
|
+
import { createHttpClient, errorMessage } from "@viniaraujo68/plinth/http";
|
|
198
|
+
import { toast } from "@viniaraujo68/plinth/toast";
|
|
199
|
+
|
|
200
|
+
interface Order {
|
|
201
|
+
id: string;
|
|
202
|
+
customer: string;
|
|
203
|
+
total: number;
|
|
204
|
+
placedAt: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const format = createFormatters("en-US");
|
|
208
|
+
const api = createHttpClient({ baseUrl: "/api", credentials: "include" });
|
|
209
|
+
|
|
210
|
+
// `key` alone reads the field, renders it and sorts by it. A formatted cell says what it shows
|
|
211
|
+
// with `value` and what it orders by with `sortBy`, so money sorts as a number and a date as a
|
|
212
|
+
// date rather than as the string on screen.
|
|
213
|
+
const columns: Column<Order>[] = [
|
|
214
|
+
{ key: "id", label: "Order" },
|
|
215
|
+
{ key: "customer", label: "Customer" },
|
|
216
|
+
{
|
|
217
|
+
key: "total",
|
|
218
|
+
label: "Total",
|
|
219
|
+
value: (row) => format.currency(row.total, "USD"),
|
|
220
|
+
sortBy: (row) => row.total,
|
|
221
|
+
numeric: true,
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: "placedAt",
|
|
225
|
+
label: "Placed",
|
|
226
|
+
value: (row) => format.date(row.placedAt),
|
|
227
|
+
sortBy: (row) => row.placedAt,
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
let rows = $state<Order[]>([]);
|
|
232
|
+
|
|
233
|
+
$effect(() => {
|
|
234
|
+
api
|
|
235
|
+
.get<Order[]>("/orders")
|
|
236
|
+
.then((loaded) => (rows = loaded))
|
|
237
|
+
.catch((error: unknown) => toast.error(errorMessage(error)));
|
|
238
|
+
});
|
|
239
|
+
</script>
|
|
240
|
+
|
|
241
|
+
<div class="flex flex-col gap-4 p-6">
|
|
242
|
+
<Breadcrumbs />
|
|
243
|
+
<DataTable {columns} {rows} rowKey={(row) => row.id} />
|
|
244
|
+
</div>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`Breadcrumbs` derives the trail from the routing context; it is never handed a list. `DataTable`
|
|
248
|
+
sorts in the browser and turns into cards on a narrow screen; given `columnsLabel`, `resizable` and
|
|
249
|
+
`storageKey`, it also lets the reader show, hide, reorder and resize its columns and remembers the
|
|
250
|
+
arrangement across reloads. `createFormatters` decides the
|
|
251
|
+
locale once, and `createHttpClient` takes the three things that actually differ between apps — the
|
|
252
|
+
base URL, how auth is injected, and how the backend spells an error body.
|
|
253
|
+
|
|
254
|
+
## Entry points
|
|
255
|
+
|
|
256
|
+
| Import | What it carries |
|
|
257
|
+
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
258
|
+
| `@viniaraujo68/plinth` | `AsyncAction`, the error reporter, `LIBRARY_VERSION` |
|
|
259
|
+
| `@viniaraujo68/plinth/theme` | `ThemeContext`, `ThemeController`, `ThemeToggle`, the cookie helpers, `createCssColorReader` |
|
|
260
|
+
| `@viniaraujo68/plinth/theme.css` | The stylesheet the tokens are declared in |
|
|
261
|
+
| `@viniaraujo68/plinth/attachments` | The `tooltip` attachment, usable on any tag |
|
|
262
|
+
| `@viniaraujo68/plinth/components` | The primitives, the `DateRange` and option-matching helpers |
|
|
263
|
+
| `@viniaraujo68/plinth/confirm` | `confirm`, `Confirmer`, the challenge helpers |
|
|
264
|
+
| `@viniaraujo68/plinth/shell` | `AppShell`, `Breadcrumbs` |
|
|
265
|
+
| `@viniaraujo68/plinth/table` | `DataTable`, the sorting and column-layout functions behind it, `competitionRanks`, the sort ↔ URL helpers |
|
|
266
|
+
| `@viniaraujo68/plinth/toast` | The toast queue and its host |
|
|
267
|
+
| `@viniaraujo68/plinth/routing` | `RoutingConfig`, `RoutingContext`, `resolvePathname`, `withSearchParams` |
|
|
268
|
+
| `@viniaraujo68/plinth/user` | `UserContext`, `ScopedComponent`, the anonymous default |
|
|
269
|
+
| `@viniaraujo68/plinth/http` | `createHttpClient`, `ApiError`, `errorMessage`, `errorStatus` |
|
|
270
|
+
| `@viniaraujo68/plinth/formatters` | `createFormatters`, over `Intl` |
|
|
271
|
+
|
|
272
|
+
The components entry point ships `AsyncButton`, `Combobox`, `Copyable`, `DateRangePicker`,
|
|
273
|
+
`Dialog`, `Dropdown`, `ErrorDisplay`, `ErrorPage`, `JsonInput`, `Listbox`, `LoadingButton`, `Logo`,
|
|
274
|
+
`Modal`, `MultiSelect`, `Paginator`, `RefreshButton`, `SegmentedControl`, `Select`, `Skeleton` and
|
|
275
|
+
`Tooltip`. Every one of them has a page in the showcase.
|
|
276
|
+
It also exports the mark's outline as data — `PICK_PATH` and `PICK_CLIP_PATH` — for anything
|
|
277
|
+
that wants the pick as a silhouette of its own.
|
|
278
|
+
|
|
279
|
+
`JsonInput` edits a JSON document as text: it reports through a bindable `invalid` whether the text
|
|
280
|
+
parses, shows the parser's message after the first blur, can reindent on blur, and sets the
|
|
281
|
+
textarea's custom validity so a form will not submit a broken document. `parseJsonText` and
|
|
282
|
+
`formatJsonText` are the same rules, for reading the text once it is sent.
|
|
283
|
+
|
|
284
|
+
Two of those pick one thing from a list, and which one to reach for is decided by the length of
|
|
285
|
+
the list rather than by a flag:
|
|
286
|
+
|
|
287
|
+
- **`Select`** — a trigger and a panel of rows, for a list short enough to read at a glance. There
|
|
288
|
+
is nothing to type into; the arrows walk it, the first letters of a label jump to it as in a
|
|
289
|
+
native select, and the trigger keeps the focus. `required` holds a form back while nothing is
|
|
290
|
+
selected.
|
|
291
|
+
- **`Combobox`** — the control bar IS the search field, for a list nobody is going to scroll.
|
|
292
|
+
Clicking it selects the text that is there so the next keystroke replaces it, the query filters
|
|
293
|
+
past accents and anywhere in the label, every word of it in any order, and anything that closes
|
|
294
|
+
the list without a pick puts the selected label back.
|
|
295
|
+
|
|
296
|
+
**`Listbox`** is the list on its own, with no trigger and no panel: it renders in place, holds the
|
|
297
|
+
focus on itself or on an optional search box, checks one value or an array of them, and jumps to
|
|
298
|
+
a typed prefix. Inside a `Dropdown`, `autofocus` hands it the keyboard as the panel opens.
|
|
299
|
+
|
|
300
|
+
**`MultiSelect`** picks several: the field shows the picks as removable chips, and opening it lists
|
|
301
|
+
every option under a search box, with the picked ones checked; a row is checked or unchecked
|
|
302
|
+
without closing the list. A disabled option that is already picked stays locked. `name` submits
|
|
303
|
+
one entry per pick and `required` holds the form back while nothing is picked.
|
|
304
|
+
|
|
305
|
+
Their panels — and `Dropdown`'s, and the one the `tooltip` attachment puts up — are placed with CSS
|
|
306
|
+
anchor positioning, and place themselves from script in a browser that does not have it yet
|
|
307
|
+
(Firefox, and Safari before iOS/macOS 26), so a panel is never left floating at the top of the
|
|
308
|
+
screen instead of under its control.
|
|
309
|
+
|
|
310
|
+
`Confirmer` is a component too, and it has its own showcase page, but it ships from
|
|
311
|
+
`@viniaraujo68/plinth/confirm` rather than from the components entry point: like `Toaster` it is
|
|
312
|
+
half of a pair, and the other half is a function any module calls with nothing plumbed through.
|
|
313
|
+
|
|
314
|
+
```svelte
|
|
315
|
+
<script lang="ts">
|
|
316
|
+
import { confirm } from "@viniaraujo68/plinth/confirm";
|
|
317
|
+
|
|
318
|
+
const remove = async (night: Night) => {
|
|
319
|
+
if (!(await confirm({ title: `Delete ${night.name}?`, danger: true }))) return;
|
|
320
|
+
await api.delete(`/nights/${night.id}`);
|
|
321
|
+
};
|
|
322
|
+
</script>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`await confirm(...)` is the whole call site, and it replaces the browser's own `confirm()` one line
|
|
326
|
+
for one line. `<Confirmer />` goes next to `<Toaster />` in the root layout and renders the queue
|
|
327
|
+
over `Modal`, so modality, the focus trap, Escape and returning focus to the opener stay the
|
|
328
|
+
browser's. Cancel, Escape, the close button, a click on the backdrop and the host unmounting all
|
|
329
|
+
resolve `false`; only the confirm action resolves `true`. Focus opens on cancel — never on the
|
|
330
|
+
destructive button — so a stray Enter cannot delete anything.
|
|
331
|
+
|
|
332
|
+
A second call while one is open queues behind it rather than replacing it or rejecting: replacing
|
|
333
|
+
would answer a question nobody was shown, and rejecting would throw at a call site written to read
|
|
334
|
+
a boolean. `challenge` is the stronger gate — the confirm action stays disabled until that exact
|
|
335
|
+
string has been typed, ends trimmed and nothing else folded — and focus opens in that input, where
|
|
336
|
+
Enter confirms only once it matches. The library ships no translations, so `confirmLabel`,
|
|
337
|
+
`cancelLabel`, `closeLabel` and `challengeLabel` on the host translate every confirm at once, and
|
|
338
|
+
any call overrides them through its own options.
|
|
339
|
+
|
|
340
|
+
## Development
|
|
341
|
+
|
|
342
|
+
```sh
|
|
343
|
+
npm install
|
|
344
|
+
npm run dev # showcase at localhost:5173
|
|
345
|
+
npm run check # svelte-check
|
|
346
|
+
npm run lint # prettier --check and eslint
|
|
347
|
+
npm run test:unit # vitest: a browser project and a node project
|
|
348
|
+
npm run test # the above, plus the playwright end-to-end run
|
|
349
|
+
npm run build # showcase build, then svelte-package and publint
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`npm run test:unit` needs a Chromium build: `npx playwright install chromium`.
|
|
353
|
+
|
|
354
|
+
`BASE_PATH=/plinth npm run build` reproduces what the Pages deploy does. Leave it unset anywhere
|
|
355
|
+
else — dev, preview and the end-to-end run all serve from the root.
|
|
356
|
+
|
|
357
|
+
Two workflows run on GitHub: every push to `main` deploys the showcase to Pages, and a `v*` tag
|
|
358
|
+
gates on check, lint and unit tests before `npm publish`.
|
|
359
|
+
|
|
360
|
+
## License
|
|
361
|
+
|
|
362
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the browser can place a popover against an anchor on its own.
|
|
3
|
+
*
|
|
4
|
+
* Both halves are asked for: an engine can ship the `anchor-name`/`anchor()` pair without
|
|
5
|
+
* `position-area`, and the library's stylesheets are written in terms of the areas.
|
|
6
|
+
*
|
|
7
|
+
* Memoised — the answer cannot change for the life of a document — and safe on the server, where
|
|
8
|
+
* there is no `CSS` object and the reply is `false`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function supportsAnchorPositioning(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* @internal Forces what {@link supportsAnchorPositioning} reports, or hands the question back to
|
|
13
|
+
* feature detection with `undefined`. A test seam and nothing else: the detection is memoised, so
|
|
14
|
+
* a spec that only stubs `CSS.supports` may find the answer already cached. Not exported from the
|
|
15
|
+
* package.
|
|
16
|
+
*/
|
|
17
|
+
export declare function overrideAnchorPositioningSupport(value: boolean | undefined): void;
|
|
18
|
+
export interface AnchoredPlacement {
|
|
19
|
+
/**
|
|
20
|
+
* Which side of the anchor the panel prefers. It flips to the other one when the preferred side
|
|
21
|
+
* has no room for it, which is the `position-try: top` / `flip-block` parity. Defaults to
|
|
22
|
+
* `"bottom"`.
|
|
23
|
+
*/
|
|
24
|
+
side?: "bottom" | "top";
|
|
25
|
+
/**
|
|
26
|
+
* How the panel lines up with the anchor across the inline axis. `position-area` centres a panel
|
|
27
|
+
* on its anchor, so `"center"` is the parity and the default; `"start"` only differs for a panel
|
|
28
|
+
* that is not as wide as the anchor.
|
|
29
|
+
*/
|
|
30
|
+
align?: "center" | "start";
|
|
31
|
+
/** Pixels kept between the anchor and the panel — the margin the anchored path carries across
|
|
32
|
+
that axis. */
|
|
33
|
+
gap?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Pixels kept between the panel and the edges of the screen when it has to be pulled back onto
|
|
36
|
+
* it — the margin the anchored path carries across the other axis, which is what a supporting
|
|
37
|
+
* browser keeps when it clamps an anchored panel in. Defaults to none, which is right for a
|
|
38
|
+
* panel whose anchored margin is `auto`: one as wide as a full-width control has nowhere to be
|
|
39
|
+
* pulled to.
|
|
40
|
+
*/
|
|
41
|
+
inset?: number;
|
|
42
|
+
/** Whether the panel takes the anchor's width: the `width: anchor-size(width)` parity. */
|
|
43
|
+
matchWidth?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Places `panel` against `anchor` from script, for a browser that cannot do it in CSS.
|
|
47
|
+
*
|
|
48
|
+
* Under the anchor by default, above it when asked for or when there is no room below, as wide as
|
|
49
|
+
* the anchor when the caller's stylesheet says `anchor-size(width)`, and never off the side of the
|
|
50
|
+
* screen. The panel is expected to be a shown popover: it is measured, so it has to have a box.
|
|
51
|
+
*
|
|
52
|
+
* The placement is kept up to date for as long as the panel is open — scrolling, resizing, a
|
|
53
|
+
* phone's on-screen keyboard, and a change of size at either end all move it — and the returned
|
|
54
|
+
* function is what ends that: it drops every listener and puts the inline styles back the way it
|
|
55
|
+
* found them. Call it on close, and call it again on the next open.
|
|
56
|
+
*
|
|
57
|
+
* `position-try: left, right` has no counterpart here: a panel that fits on neither side of the
|
|
58
|
+
* anchor is clamped into the viewport instead of moved beside it. It stays reachable, which is the
|
|
59
|
+
* whole point of the fallback, and no supporting browser takes this path.
|
|
60
|
+
*/
|
|
61
|
+
export declare function positionUnder(panel: HTMLElement, anchor: HTMLElement, placement?: AnchoredPlacement): () => void;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* Every popover in this library is placed with CSS anchor positioning -- `anchor-name`,
|
|
2
|
+
`position-area`, `position-try`, `anchor-size()` -- which is the right tool and also a very
|
|
3
|
+
recent one: Chromium 125, Safari only with iOS/macOS 26, and nothing stable in Firefox. A
|
|
4
|
+
browser without it does not degrade to something reasonable. The anchored declarations are
|
|
5
|
+
dropped, the UA popover rules (`position: fixed; inset: 0; margin: auto`) are what is left, and
|
|
6
|
+
the panel lands in the middle of the screen at an intrinsic width -- or, once an author margin
|
|
7
|
+
overrides part of that `auto`, pinned to the top edge, which is the shape the bug was reported
|
|
8
|
+
in from a real phone.
|
|
9
|
+
|
|
10
|
+
This module is the other half of the enhancement: where the CSS works nothing here runs, and
|
|
11
|
+
where it does not, script writes the same geometry the stylesheet would have produced. */
|
|
12
|
+
let support;
|
|
13
|
+
const detect = () => typeof CSS !== "undefined" &&
|
|
14
|
+
CSS.supports("anchor-name: --x") &&
|
|
15
|
+
CSS.supports("position-area: bottom");
|
|
16
|
+
/**
|
|
17
|
+
* Whether the browser can place a popover against an anchor on its own.
|
|
18
|
+
*
|
|
19
|
+
* Both halves are asked for: an engine can ship the `anchor-name`/`anchor()` pair without
|
|
20
|
+
* `position-area`, and the library's stylesheets are written in terms of the areas.
|
|
21
|
+
*
|
|
22
|
+
* Memoised — the answer cannot change for the life of a document — and safe on the server, where
|
|
23
|
+
* there is no `CSS` object and the reply is `false`.
|
|
24
|
+
*/
|
|
25
|
+
export function supportsAnchorPositioning() {
|
|
26
|
+
support ??= detect();
|
|
27
|
+
return support;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @internal Forces what {@link supportsAnchorPositioning} reports, or hands the question back to
|
|
31
|
+
* feature detection with `undefined`. A test seam and nothing else: the detection is memoised, so
|
|
32
|
+
* a spec that only stubs `CSS.supports` may find the answer already cached. Not exported from the
|
|
33
|
+
* package.
|
|
34
|
+
*/
|
|
35
|
+
export function overrideAnchorPositioningSupport(value) {
|
|
36
|
+
support = value;
|
|
37
|
+
}
|
|
38
|
+
const clamp = (value, lowest, highest) => highest < lowest ? lowest : Math.min(Math.max(value, lowest), highest);
|
|
39
|
+
/**
|
|
40
|
+
* Places `panel` against `anchor` from script, for a browser that cannot do it in CSS.
|
|
41
|
+
*
|
|
42
|
+
* Under the anchor by default, above it when asked for or when there is no room below, as wide as
|
|
43
|
+
* the anchor when the caller's stylesheet says `anchor-size(width)`, and never off the side of the
|
|
44
|
+
* screen. The panel is expected to be a shown popover: it is measured, so it has to have a box.
|
|
45
|
+
*
|
|
46
|
+
* The placement is kept up to date for as long as the panel is open — scrolling, resizing, a
|
|
47
|
+
* phone's on-screen keyboard, and a change of size at either end all move it — and the returned
|
|
48
|
+
* function is what ends that: it drops every listener and puts the inline styles back the way it
|
|
49
|
+
* found them. Call it on close, and call it again on the next open.
|
|
50
|
+
*
|
|
51
|
+
* `position-try: left, right` has no counterpart here: a panel that fits on neither side of the
|
|
52
|
+
* anchor is clamped into the viewport instead of moved beside it. It stays reachable, which is the
|
|
53
|
+
* whole point of the fallback, and no supporting browser takes this path.
|
|
54
|
+
*/
|
|
55
|
+
export function positionUnder(panel, anchor, placement = {}) {
|
|
56
|
+
const { side = "bottom", align = "center", gap = 0, inset = 0, matchWidth = false } = placement;
|
|
57
|
+
const original = new Map();
|
|
58
|
+
const write = (property, value) => {
|
|
59
|
+
if (!original.has(property))
|
|
60
|
+
original.set(property, panel.style.getPropertyValue(property));
|
|
61
|
+
panel.style.setProperty(property, value);
|
|
62
|
+
};
|
|
63
|
+
const place = () => {
|
|
64
|
+
const target = anchor.getBoundingClientRect();
|
|
65
|
+
/* Document coordinates, not viewport ones. A popover sits in the top layer, whose containing
|
|
66
|
+
block is the initial containing block, so an absolutely positioned panel is offset from the
|
|
67
|
+
document's origin and scrolls with the page on its own. `position: fixed` was the obvious
|
|
68
|
+
choice and the wrong one: iOS Safari renders fixed boxes against the visual viewport once
|
|
69
|
+
the keyboard is up or the page is zoomed, while `getBoundingClientRect()` keeps answering in
|
|
70
|
+
layout-viewport coordinates -- a `Combobox` list landed a keyboard's height away from its
|
|
71
|
+
field. Absolute boxes and client rects agree, whatever the visual viewport is doing. */
|
|
72
|
+
const scrollX = window.scrollX;
|
|
73
|
+
const scrollY = window.scrollY;
|
|
74
|
+
const targetTop = target.top + scrollY;
|
|
75
|
+
const targetBottom = target.bottom + scrollY;
|
|
76
|
+
const targetLeft = target.left + scrollX;
|
|
77
|
+
write("position", "absolute");
|
|
78
|
+
write("margin", "0");
|
|
79
|
+
/* Both of these are the initial values, so writing them costs nothing in the browser this
|
|
80
|
+
exists for -- it does not know the properties and drops the declarations. What they buy is
|
|
81
|
+
that nothing else can be positioning the panel at the same time: an engine that took
|
|
82
|
+
`anchor-name` but not `position-area`, and a supporting engine a test has forced down this
|
|
83
|
+
path, would otherwise resolve the `top` below against the anchored containing block. */
|
|
84
|
+
write("position-area", "none");
|
|
85
|
+
write("position-try-fallbacks", "none");
|
|
86
|
+
/* The UA popover rules leave `inset: 0` behind, and a box with both `left` and `right` set
|
|
87
|
+
stretches to fill its containing block instead of taking its own width. Zeroing `left`
|
|
88
|
+
before measuring also gives a panel with no width of its own the whole viewport to
|
|
89
|
+
shrink-to-fit against, so the reading below is the widest it can be -- and the clamp keeps
|
|
90
|
+
it there, so moving it cannot narrow it again. */
|
|
91
|
+
write("right", "auto");
|
|
92
|
+
write("bottom", "auto");
|
|
93
|
+
write("left", "0");
|
|
94
|
+
if (matchWidth)
|
|
95
|
+
write("width", `${target.width}px`);
|
|
96
|
+
const { width, height } = panel.getBoundingClientRect();
|
|
97
|
+
// A panel with no box is a panel that is not being shown -- a size observation that arrived
|
|
98
|
+
// after the close, most likely. Nothing to place, and nothing worth writing a stale top for.
|
|
99
|
+
if (width === 0 && height === 0)
|
|
100
|
+
return;
|
|
101
|
+
/* The visual viewport is the part of the page a person can actually see: what is left above
|
|
102
|
+
a phone's keyboard, or the zoomed-in window. That is the region the panel has to fit in and
|
|
103
|
+
be clamped to, and `pageTop`/`pageLeft` already put it in document coordinates. */
|
|
104
|
+
const visual = window.visualViewport;
|
|
105
|
+
const view = visual
|
|
106
|
+
? { top: visual.pageTop, left: visual.pageLeft, width: visual.width, height: visual.height }
|
|
107
|
+
: {
|
|
108
|
+
top: scrollY,
|
|
109
|
+
left: scrollX,
|
|
110
|
+
width: document.documentElement.clientWidth,
|
|
111
|
+
height: document.documentElement.clientHeight,
|
|
112
|
+
};
|
|
113
|
+
// The gap goes between the anchor and the panel and the inset between the panel and the edge
|
|
114
|
+
// of the screen, which is what makes "it fits" the same question the clamp below answers.
|
|
115
|
+
const fitsBelow = height <= view.top + view.height - targetBottom - gap - inset;
|
|
116
|
+
const fitsAbove = height <= targetTop - view.top - gap - inset;
|
|
117
|
+
const above = side === "top" ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
|
|
118
|
+
const top = above ? targetTop - gap - height : targetBottom + gap;
|
|
119
|
+
const left = align === "start" ? targetLeft : targetLeft + (target.width - width) / 2;
|
|
120
|
+
write("top", `${clamp(top, view.top + inset, view.top + view.height - inset - height)}px`);
|
|
121
|
+
write("left", `${clamp(left, view.left + inset, view.left + view.width - inset - width)}px`);
|
|
122
|
+
};
|
|
123
|
+
place();
|
|
124
|
+
/* Capture, because a scroll that moves the anchor is very often a scroll inside something --
|
|
125
|
+
a modal's body, a scrolling table -- and a `scroll` event from an element never reaches the
|
|
126
|
+
window by bubbling. */
|
|
127
|
+
window.addEventListener("scroll", place, { capture: true, passive: true });
|
|
128
|
+
window.addEventListener("resize", place);
|
|
129
|
+
/* The visual viewport is the one a phone's on-screen keyboard and pinch-zoom change, and
|
|
130
|
+
neither of those fires `resize` on the window. */
|
|
131
|
+
window.visualViewport?.addEventListener("resize", place);
|
|
132
|
+
window.visualViewport?.addEventListener("scroll", place);
|
|
133
|
+
/* Either box changing size changes the placement: the flip and both clamps are measurements. It
|
|
134
|
+
is also what keeps a `Combobox` panel under its field while the query filters rows away, with
|
|
135
|
+
no reactive plumbing in the component. */
|
|
136
|
+
const observer = new ResizeObserver(place);
|
|
137
|
+
observer.observe(panel);
|
|
138
|
+
observer.observe(anchor);
|
|
139
|
+
return () => {
|
|
140
|
+
observer.disconnect();
|
|
141
|
+
window.removeEventListener("scroll", place, { capture: true });
|
|
142
|
+
window.removeEventListener("resize", place);
|
|
143
|
+
window.visualViewport?.removeEventListener("resize", place);
|
|
144
|
+
window.visualViewport?.removeEventListener("scroll", place);
|
|
145
|
+
for (const [property, value] of original)
|
|
146
|
+
if (value)
|
|
147
|
+
panel.style.setProperty(property, value);
|
|
148
|
+
else
|
|
149
|
+
panel.style.removeProperty(property);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Shape of the work an action wraps. `never[]` keeps `Parameters<T>` inferring at the call site. */
|
|
2
|
+
export type AsyncFn = (...args: never[]) => Promise<unknown>;
|
|
3
|
+
export type AsyncActionStatus = "idle" | "pending" | "success" | "error";
|
|
4
|
+
/**
|
|
5
|
+
* A promise-returning function plus the four-state machine every button around one ends up
|
|
6
|
+
* re-implementing: idle, pending, success, error.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { AsyncAction } from "@viniaraujo68/plinth";
|
|
11
|
+
*
|
|
12
|
+
* const save = new AsyncAction((draft: Draft) => api.save(draft));
|
|
13
|
+
* </script>
|
|
14
|
+
*
|
|
15
|
+
* <button disabled={save.isPending} onclick={() => save.run(draft)}>Save</button>
|
|
16
|
+
* {#if save.isError}<p class="text-error">Could not save.</p>{/if}
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare class AsyncAction<T extends AsyncFn> {
|
|
20
|
+
#private;
|
|
21
|
+
private fn;
|
|
22
|
+
status: AsyncActionStatus;
|
|
23
|
+
error: unknown;
|
|
24
|
+
isPending: boolean;
|
|
25
|
+
isSuccess: boolean;
|
|
26
|
+
isError: boolean;
|
|
27
|
+
constructor(fn: T);
|
|
28
|
+
reset: () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Runs the action and records the outcome.
|
|
31
|
+
*
|
|
32
|
+
* It never rejects: an event handler has nowhere to catch, so a rethrow here would surface as an
|
|
33
|
+
* unhandled rejection instead of as UI. Failure is `error` and `isError`. A run that failed —
|
|
34
|
+
* or that was superseded by a later `run`/`reset` — resolves to `undefined`.
|
|
35
|
+
*/
|
|
36
|
+
run: (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined>;
|
|
37
|
+
}
|