@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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -2
  3. package/dist/anchoring.d.ts +61 -0
  4. package/dist/anchoring.js +151 -0
  5. package/dist/async-action.svelte.d.ts +37 -0
  6. package/dist/async-action.svelte.js +64 -0
  7. package/dist/attachments/index.d.ts +2 -0
  8. package/dist/attachments/index.js +2 -0
  9. package/dist/attachments/sortable.d.ts +6 -0
  10. package/dist/attachments/sortable.js +227 -0
  11. package/dist/attachments/tooltip.d.ts +37 -0
  12. package/dist/attachments/tooltip.js +166 -0
  13. package/dist/components/AsyncButton.svelte +49 -0
  14. package/dist/components/AsyncButton.svelte.d.ts +32 -0
  15. package/dist/components/Combobox.svelte +380 -0
  16. package/dist/components/Combobox.svelte.d.ts +74 -0
  17. package/dist/components/Copyable.svelte +116 -0
  18. package/dist/components/Copyable.svelte.d.ts +37 -0
  19. package/dist/components/DateRangePicker.svelte +266 -0
  20. package/dist/components/DateRangePicker.svelte.d.ts +66 -0
  21. package/dist/components/Dialog.svelte +102 -0
  22. package/dist/components/Dialog.svelte.d.ts +38 -0
  23. package/dist/components/DragHandle.svelte +26 -0
  24. package/dist/components/DragHandle.svelte.d.ts +7 -0
  25. package/dist/components/Dropdown.svelte +204 -0
  26. package/dist/components/Dropdown.svelte.d.ts +49 -0
  27. package/dist/components/ErrorDisplay.svelte +59 -0
  28. package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
  29. package/dist/components/ErrorPage.svelte +119 -0
  30. package/dist/components/ErrorPage.svelte.d.ts +52 -0
  31. package/dist/components/JsonInput.svelte +106 -0
  32. package/dist/components/JsonInput.svelte.d.ts +40 -0
  33. package/dist/components/Listbox.svelte +257 -0
  34. package/dist/components/Listbox.svelte.d.ts +78 -0
  35. package/dist/components/LoadingButton.svelte +36 -0
  36. package/dist/components/LoadingButton.svelte.d.ts +22 -0
  37. package/dist/components/Logo.svelte +112 -0
  38. package/dist/components/Logo.svelte.d.ts +29 -0
  39. package/dist/components/Modal.svelte +187 -0
  40. package/dist/components/Modal.svelte.d.ts +62 -0
  41. package/dist/components/MultiSelect.svelte +256 -0
  42. package/dist/components/MultiSelect.svelte.d.ts +89 -0
  43. package/dist/components/OptionPanel.svelte +241 -0
  44. package/dist/components/OptionPanel.svelte.d.ts +51 -0
  45. package/dist/components/Paginator.svelte +205 -0
  46. package/dist/components/Paginator.svelte.d.ts +48 -0
  47. package/dist/components/RefreshButton.svelte +246 -0
  48. package/dist/components/RefreshButton.svelte.d.ts +46 -0
  49. package/dist/components/SegmentedControl.svelte +147 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +71 -0
  51. package/dist/components/Select.svelte +312 -0
  52. package/dist/components/Select.svelte.d.ts +68 -0
  53. package/dist/components/Skeleton.svelte +97 -0
  54. package/dist/components/Skeleton.svelte.d.ts +32 -0
  55. package/dist/components/Tooltip.svelte +60 -0
  56. package/dist/components/Tooltip.svelte.d.ts +58 -0
  57. package/dist/components/date-range.d.ts +101 -0
  58. package/dist/components/date-range.js +144 -0
  59. package/dist/components/index.d.ts +26 -0
  60. package/dist/components/index.js +38 -0
  61. package/dist/components/json-text.d.ts +22 -0
  62. package/dist/components/json-text.js +22 -0
  63. package/dist/components/pick.d.ts +18 -0
  64. package/dist/components/pick.js +18 -0
  65. package/dist/components/segmented.d.ts +7 -0
  66. package/dist/components/segmented.js +1 -0
  67. package/dist/components/select.d.ts +66 -0
  68. package/dist/components/select.js +90 -0
  69. package/dist/confirm/Confirmer.svelte +194 -0
  70. package/dist/confirm/Confirmer.svelte.d.ts +45 -0
  71. package/dist/confirm/challenge.d.ts +16 -0
  72. package/dist/confirm/challenge.js +16 -0
  73. package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
  74. package/dist/confirm/confirm-manager.svelte.js +149 -0
  75. package/dist/confirm/index.d.ts +3 -0
  76. package/dist/confirm/index.js +3 -0
  77. package/dist/error.d.ts +25 -0
  78. package/dist/error.js +39 -0
  79. package/dist/formatters.d.ts +95 -0
  80. package/dist/formatters.js +146 -0
  81. package/dist/http.d.ts +68 -0
  82. package/dist/http.js +138 -0
  83. package/dist/index.d.ts +10 -0
  84. package/dist/index.js +14 -0
  85. package/dist/routing.svelte.d.ts +169 -0
  86. package/dist/routing.svelte.js +208 -0
  87. package/dist/shell/AppShell.svelte +874 -0
  88. package/dist/shell/AppShell.svelte.d.ts +93 -0
  89. package/dist/shell/Breadcrumbs.svelte +78 -0
  90. package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
  91. package/dist/shell/crumbs.d.ts +23 -0
  92. package/dist/shell/crumbs.js +1 -0
  93. package/dist/shell/index.d.ts +3 -0
  94. package/dist/shell/index.js +6 -0
  95. package/dist/table/ColumnMenu.svelte +249 -0
  96. package/dist/table/ColumnMenu.svelte.d.ts +47 -0
  97. package/dist/table/DataTable.svelte +643 -0
  98. package/dist/table/DataTable.svelte.d.ts +153 -0
  99. package/dist/table/columns.d.ts +55 -0
  100. package/dist/table/columns.js +153 -0
  101. package/dist/table/index.d.ts +4 -0
  102. package/dist/table/index.js +8 -0
  103. package/dist/table/sort.d.ts +48 -0
  104. package/dist/table/sort.js +139 -0
  105. package/dist/table/types.d.ts +93 -0
  106. package/dist/table/types.js +1 -0
  107. package/dist/theme/ThemeController.svelte +54 -0
  108. package/dist/theme/ThemeController.svelte.d.ts +27 -0
  109. package/dist/theme/ThemeToggle.svelte +119 -0
  110. package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
  111. package/dist/theme/css-color.d.ts +23 -0
  112. package/dist/theme/css-color.js +31 -0
  113. package/dist/theme/index.d.ts +4 -0
  114. package/dist/theme/index.js +5 -0
  115. package/dist/theme/theme.css +410 -0
  116. package/dist/theme/theme.svelte.d.ts +54 -0
  117. package/dist/theme/theme.svelte.js +58 -0
  118. package/dist/toast/Toaster.svelte +257 -0
  119. package/dist/toast/Toaster.svelte.d.ts +35 -0
  120. package/dist/toast/index.d.ts +2 -0
  121. package/dist/toast/index.js +2 -0
  122. package/dist/toast/toast-manager.svelte.d.ts +99 -0
  123. package/dist/toast/toast-manager.svelte.js +176 -0
  124. package/dist/user/ScopedComponent.svelte +47 -0
  125. package/dist/user/ScopedComponent.svelte.d.ts +29 -0
  126. package/dist/user/anonymous.d.ts +21 -0
  127. package/dist/user/anonymous.js +26 -0
  128. package/dist/user/context.d.ts +55 -0
  129. package/dist/user/context.js +13 -0
  130. package/dist/user/index.d.ts +3 -0
  131. package/dist/user/index.js +6 -0
  132. 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
- # Temporary Holding Version
1
+ # plinth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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, and `sortable`, which reorders a list from its drag handles |
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`, `DragHandle`, `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
+ }