@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
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A promise-returning function plus the four-state machine every button around one ends up
3
+ * re-implementing: idle, pending, success, error.
4
+ *
5
+ * ```svelte
6
+ * <script lang="ts">
7
+ * import { AsyncAction } from "@viniaraujo68/plinth";
8
+ *
9
+ * const save = new AsyncAction((draft: Draft) => api.save(draft));
10
+ * </script>
11
+ *
12
+ * <button disabled={save.isPending} onclick={() => save.run(draft)}>Save</button>
13
+ * {#if save.isError}<p class="text-error">Could not save.</p>{/if}
14
+ * ```
15
+ */
16
+ export class AsyncAction {
17
+ fn;
18
+ status = $state("idle");
19
+ error = $state(null);
20
+ isPending = $derived(this.status === "pending");
21
+ isSuccess = $derived(this.status === "success");
22
+ isError = $derived(this.status === "error");
23
+ /**
24
+ * Identifies the run whose result is allowed to write state. A second click while the first
25
+ * call is still in flight makes the first one stale: without this, a slow success landing after
26
+ * a fast failure would paint the button green.
27
+ */
28
+ #currentRun = 0;
29
+ constructor(fn) {
30
+ this.fn = fn;
31
+ }
32
+ reset = () => {
33
+ this.status = "idle";
34
+ this.error = null;
35
+ // Anything still in flight is abandoned rather than allowed to resurrect a settled state.
36
+ this.#currentRun++;
37
+ };
38
+ /**
39
+ * Runs the action and records the outcome.
40
+ *
41
+ * It never rejects: an event handler has nowhere to catch, so a rethrow here would surface as an
42
+ * unhandled rejection instead of as UI. Failure is `error` and `isError`. A run that failed —
43
+ * or that was superseded by a later `run`/`reset` — resolves to `undefined`.
44
+ */
45
+ run = async (...args) => {
46
+ const run = ++this.#currentRun;
47
+ this.status = "pending";
48
+ this.error = null;
49
+ try {
50
+ const result = (await this.fn(...args));
51
+ if (run !== this.#currentRun)
52
+ return undefined;
53
+ this.status = "success";
54
+ return result;
55
+ }
56
+ catch (failure) {
57
+ if (run !== this.#currentRun)
58
+ return undefined;
59
+ this.status = "error";
60
+ this.error = failure;
61
+ return undefined;
62
+ }
63
+ };
64
+ }
@@ -0,0 +1,2 @@
1
+ export { sortable, type SortableOptions } from "./sortable.js";
2
+ export { tooltip, tooltipPanel, type TooltipOptions } from "./tooltip.js";
@@ -0,0 +1,2 @@
1
+ export { sortable } from "./sortable.js";
2
+ export { tooltip, tooltipPanel } from "./tooltip.js";
@@ -0,0 +1,6 @@
1
+ import type { Attachment } from "svelte/attachments";
2
+ export interface SortableOptions {
3
+ onmove: (from: number, to: number) => void;
4
+ disabled?: boolean;
5
+ }
6
+ export declare const sortable: (options: SortableOptions) => Attachment<HTMLElement>;
@@ -0,0 +1,227 @@
1
+ import { tick } from "svelte";
2
+ const ITEM = "[data-sortable-item]";
3
+ const HANDLE = "[data-sortable-handle]";
4
+ const DRAGGING = "data-sortable-dragging";
5
+ const ANIMATION_ID = "plinth-sortable";
6
+ const SHIFT_MS = 180;
7
+ const SETTLE_MS = 160;
8
+ const EASING = "cubic-bezier(0.2, 0, 0, 1)";
9
+ const KEY_STEPS = {
10
+ ArrowLeft: -1,
11
+ ArrowUp: -1,
12
+ ArrowRight: 1,
13
+ ArrowDown: 1,
14
+ };
15
+ const destination = (key, from, last) => {
16
+ if (key === "Home")
17
+ return 0;
18
+ if (key === "End")
19
+ return last;
20
+ return key in KEY_STEPS ? from + KEY_STEPS[key] : null;
21
+ };
22
+ const layoutCenter = (element) => ({
23
+ x: element.offsetLeft + element.offsetWidth / 2,
24
+ y: element.offsetTop + element.offsetHeight / 2,
25
+ });
26
+ const draggedCenter = (current) => ({
27
+ x: current.startLeft + current.item.offsetWidth / 2 + current.x - current.grabX,
28
+ y: current.startTop + current.item.offsetHeight / 2 + current.y - current.grabY,
29
+ });
30
+ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
31
+ const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
32
+ const cancelShift = (element) => {
33
+ for (const animation of element.getAnimations())
34
+ if (animation.id === ANIMATION_ID)
35
+ animation.cancel();
36
+ };
37
+ const offsetOf = (element) => {
38
+ const [x = "0", y = "0"] = element.style.translate.split(" ");
39
+ return { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
40
+ };
41
+ export const sortable = (options) => (container) => {
42
+ let drag = null;
43
+ let shifting = false;
44
+ let frame = 0;
45
+ const items = () => [...container.querySelectorAll(ITEM)];
46
+ const handleOf = (event) => {
47
+ const handle = event.target?.closest(HANDLE) ?? null;
48
+ return handle && container.contains(handle) ? handle : null;
49
+ };
50
+ const shift = async (move, still) => {
51
+ const before = new Map(items().map((element) => [element, element.getBoundingClientRect()]));
52
+ move();
53
+ await tick();
54
+ if (reducedMotion())
55
+ return;
56
+ for (const element of items()) {
57
+ const previous = before.get(element);
58
+ if (element === still || !previous)
59
+ continue;
60
+ cancelShift(element);
61
+ const current = element.getBoundingClientRect();
62
+ const dx = previous.left - current.left;
63
+ const dy = previous.top - current.top;
64
+ if (!dx && !dy)
65
+ continue;
66
+ element.animate([{ translate: `${dx}px ${dy}px` }, { translate: "0px 0px" }], {
67
+ duration: SHIFT_MS,
68
+ easing: EASING,
69
+ id: ANIMATION_ID,
70
+ });
71
+ }
72
+ };
73
+ const follow = () => {
74
+ if (!drag)
75
+ return;
76
+ const { item } = drag;
77
+ const dx = drag.x - drag.grabX - (item.offsetLeft - drag.startLeft);
78
+ const dy = drag.y - drag.grabY - (item.offsetTop - drag.startTop);
79
+ item.style.translate = `${dx}px ${dy}px`;
80
+ };
81
+ const targetFor = (current) => {
82
+ const all = items();
83
+ const from = all.indexOf(current.item);
84
+ if (from < 0)
85
+ return null;
86
+ const center = draggedCenter(current);
87
+ let index = -1;
88
+ let nearest = Infinity;
89
+ for (const [position, element] of all.entries()) {
90
+ if (element === current.item)
91
+ continue;
92
+ const gap = distance(center, layoutCenter(element));
93
+ if (gap < nearest) {
94
+ nearest = gap;
95
+ index = position;
96
+ }
97
+ }
98
+ if (index < 0)
99
+ return null;
100
+ const target = all[index];
101
+ const middle = layoutCenter(target);
102
+ const sameRow = Math.abs(target.offsetTop - current.item.offsetTop) < target.offsetHeight / 2;
103
+ const forward = index > from;
104
+ const passed = sameRow ? forward === center.x > middle.x : forward === center.y > middle.y;
105
+ return passed ? { from, to: index } : null;
106
+ };
107
+ const step = () => {
108
+ if (!drag || shifting)
109
+ return;
110
+ follow();
111
+ const target = targetFor(drag);
112
+ if (!target)
113
+ return;
114
+ shifting = true;
115
+ const { item } = drag;
116
+ void shift(() => {
117
+ options.onmove(target.from, target.to);
118
+ }, item).then(() => {
119
+ shifting = false;
120
+ follow();
121
+ frame = requestAnimationFrame(step);
122
+ });
123
+ };
124
+ const settle = (current) => {
125
+ const { item, restore } = current;
126
+ const offset = offsetOf(item);
127
+ item.style.translate = "";
128
+ item.removeAttribute(DRAGGING);
129
+ const done = () => {
130
+ item.style.position = restore.position;
131
+ item.style.zIndex = restore.zIndex;
132
+ item.style.willChange = restore.willChange;
133
+ };
134
+ if (reducedMotion() || (!offset.x && !offset.y)) {
135
+ done();
136
+ return;
137
+ }
138
+ const animation = item.animate([{ translate: `${offset.x}px ${offset.y}px` }, { translate: "0px 0px" }], { duration: SETTLE_MS, easing: EASING });
139
+ void animation.finished.then(done, done);
140
+ };
141
+ const stop = () => {
142
+ cancelAnimationFrame(frame);
143
+ window.removeEventListener("pointermove", onPointerMove);
144
+ window.removeEventListener("pointerup", onPointerUp);
145
+ window.removeEventListener("pointercancel", onPointerUp);
146
+ if (!drag)
147
+ return;
148
+ const current = drag;
149
+ drag = null;
150
+ settle(current);
151
+ };
152
+ function onPointerMove(event) {
153
+ if (drag?.pointerId !== event.pointerId)
154
+ return;
155
+ drag.x = event.clientX;
156
+ drag.y = event.clientY;
157
+ step();
158
+ }
159
+ function onPointerUp(event) {
160
+ if (drag?.pointerId === event.pointerId)
161
+ stop();
162
+ }
163
+ const onPointerDown = (event) => {
164
+ if (options.disabled || event.button !== 0 || drag)
165
+ return;
166
+ const item = handleOf(event)?.closest(ITEM);
167
+ if (!item)
168
+ return;
169
+ event.preventDefault();
170
+ cancelShift(item);
171
+ drag = {
172
+ item,
173
+ pointerId: event.pointerId,
174
+ grabX: event.clientX,
175
+ grabY: event.clientY,
176
+ startLeft: item.offsetLeft,
177
+ startTop: item.offsetTop,
178
+ x: event.clientX,
179
+ y: event.clientY,
180
+ restore: {
181
+ position: item.style.position,
182
+ zIndex: item.style.zIndex,
183
+ willChange: item.style.willChange,
184
+ },
185
+ };
186
+ if (getComputedStyle(item).position === "static")
187
+ item.style.position = "relative";
188
+ item.style.zIndex = "10";
189
+ item.style.willChange = "translate";
190
+ item.setAttribute(DRAGGING, "");
191
+ window.addEventListener("pointermove", onPointerMove);
192
+ window.addEventListener("pointerup", onPointerUp);
193
+ window.addEventListener("pointercancel", onPointerUp);
194
+ };
195
+ const onTouchMove = (event) => {
196
+ if (drag)
197
+ event.preventDefault();
198
+ };
199
+ const onKeyDown = (event) => {
200
+ if (options.disabled || drag)
201
+ return;
202
+ const item = handleOf(event)?.closest(ITEM);
203
+ if (!item)
204
+ return;
205
+ const all = items();
206
+ const from = all.indexOf(item);
207
+ const last = all.length - 1;
208
+ const to = destination(event.key, from, last);
209
+ if (to === null || from < 0)
210
+ return;
211
+ event.preventDefault();
212
+ if (to < 0 || to > last || to === from)
213
+ return;
214
+ void shift(() => {
215
+ options.onmove(from, to);
216
+ }).then(() => items()[to]?.querySelector(HANDLE)?.focus());
217
+ };
218
+ container.addEventListener("pointerdown", onPointerDown);
219
+ container.addEventListener("touchmove", onTouchMove, { passive: false });
220
+ container.addEventListener("keydown", onKeyDown);
221
+ return () => {
222
+ stop();
223
+ container.removeEventListener("pointerdown", onPointerDown);
224
+ container.removeEventListener("touchmove", onTouchMove);
225
+ container.removeEventListener("keydown", onKeyDown);
226
+ };
227
+ };
@@ -0,0 +1,37 @@
1
+ import type { Attachment } from "svelte/attachments";
2
+ export interface TooltipOptions {
3
+ /** Keeps the wiring attached but suppresses the panel, so a caller can gate a tooltip on
4
+ state — a rail label that is only worth showing while the rail is collapsed, say — without
5
+ conditionally applying the attachment and losing the listeners on every toggle. */
6
+ disabled?: boolean;
7
+ }
8
+ /**
9
+ * Attaches a plain-text tooltip to the host, shown on hover and on keyboard focus.
10
+ *
11
+ * The host stays the root of whatever the caller renders, so layout that depends on the element's
12
+ * own position — `join-item`, a grid cell, a flex child — keeps working untouched.
13
+ *
14
+ * ```svelte
15
+ * <button class="btn" {@attach tooltip("Archive")}>…</button>
16
+ * ```
17
+ *
18
+ * The panel is created lazily and owned by this attachment, which is what makes it cheap enough
19
+ * for one per row of a long list. Rich content belongs in markup instead — see `tooltipPanel`.
20
+ */
21
+ export declare function tooltip(text: string, options?: TooltipOptions): Attachment<HTMLElement>;
22
+ /**
23
+ * Wires the same hover and focus behavior between the host and a panel the caller renders itself,
24
+ * which is what rich content needs: a snippet rendered in the caller's markup keeps its component
25
+ * context, its scoped styles and its reactivity, none of which survive `createElement`.
26
+ *
27
+ * The panel is given the sanctioned tooltip styling when the attachment runs.
28
+ *
29
+ * Reactivity is `{@attach}`'s own: the expression re-runs whenever `panel` changes, tearing the
30
+ * old binding down and building a new one.
31
+ *
32
+ * ```svelte
33
+ * <span {@attach tooltipPanel(panel)}>…</span>
34
+ * <div bind:this={panel} popover="manual" role="tooltip">rich <strong>content</strong></div>
35
+ * ```
36
+ */
37
+ export declare function tooltipPanel(panel: HTMLElement | undefined | null, options?: TooltipOptions): Attachment<HTMLElement>;
@@ -0,0 +1,166 @@
1
+ import { positionUnder, supportsAnchorPositioning } from "../anchoring.js";
2
+ /* Anchor positioning links a panel to its trigger through a document-wide custom ident, so every
3
+ binding needs an ident nothing else uses. A module-level counter is enough: the pair is created
4
+ and torn down together, and the value never leaves this module. */
5
+ let bindingCount = 0;
6
+ /** The panel's `margin` below, in pixels: `0.375rem` at the default root size. The script
7
+ fallback needs the number, since it places the panel from `getBoundingClientRect()` readings. */
8
+ const GAP = 6;
9
+ /* The panel is styled imperatively rather than through utility classes because the panel of the
10
+ string variant is created by this module, outside any component — a consumer's Tailwind build
11
+ has no source file to scan for those classes and would emit nothing. Reading the daisyUI custom
12
+ properties keeps it themed anyway, including inside a `data-theme` subtree. */
13
+ const applyPanelStyle = (panel, anchorName) => {
14
+ panel.popover = "manual";
15
+ panel.role = "tooltip";
16
+ panel.style.setProperty("position-anchor", anchorName);
17
+ panel.style.setProperty("position-area", "top");
18
+ panel.style.setProperty("position-try-fallbacks", "flip-block, flip-inline");
19
+ Object.assign(panel.style, {
20
+ // A tooltip that swallows the pointer is a flicker loop: content taller than a small trigger
21
+ // ends up covering it, the host sees mouseleave, the panel hides, the host sees mouseenter
22
+ // again. Staying transparent to hit-testing is what breaks the cycle.
23
+ pointerEvents: "none",
24
+ whiteSpace: "normal",
25
+ overflowWrap: "break-word",
26
+ margin: "0.375rem",
27
+ width: "max-content",
28
+ maxWidth: "20rem",
29
+ padding: "0.25rem 0.5rem",
30
+ fontSize: "0.8125rem",
31
+ lineHeight: "1.25",
32
+ color: "var(--color-base-content)",
33
+ background: "var(--color-base-100)",
34
+ border: "1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent)",
35
+ borderRadius: "var(--radius-field)",
36
+ boxShadow: "0 8px 24px rgb(0 0 0 / 0.2)",
37
+ });
38
+ };
39
+ const bind = (host, resolvePanel, options,
40
+ /* A panel the caller rendered is already in the document and visible; only `popover` takes it
41
+ out of the flow, so it has to be prepared immediately. A panel this module owns is created on
42
+ demand and can stay unprepared until the first show. */
43
+ prepareImmediately) => {
44
+ const panelId = `plinth-tooltip-${bindingCount++}`;
45
+ const anchorName = `--${panelId}`;
46
+ let panel;
47
+ // The host may already carry either of these from the caller's own markup; both are restored
48
+ // verbatim on teardown so an attachment that re-runs leaves nothing behind.
49
+ const inheritedDescribedBy = host.getAttribute("aria-describedby");
50
+ const inheritedAnchorName = host.style.getPropertyValue("anchor-name");
51
+ host.style.setProperty("anchor-name", anchorName);
52
+ const prepare = () => {
53
+ if (panel)
54
+ return panel;
55
+ panel = resolvePanel();
56
+ if (!panel)
57
+ return undefined;
58
+ panel.id = panelId;
59
+ applyPanelStyle(panel, anchorName);
60
+ host.setAttribute("aria-describedby", [inheritedDescribedBy, panelId].filter(Boolean).join(" "));
61
+ return panel;
62
+ };
63
+ if (prepareImmediately)
64
+ prepare();
65
+ let stopPositioning;
66
+ const show = () => {
67
+ // Read through `options` at show time, not at bind time: a caller passing a plain object that
68
+ // it mutates would otherwise be stuck with whatever the flag was when the pair was created.
69
+ if (options.disabled)
70
+ return;
71
+ const shown = prepare();
72
+ if (!shown)
73
+ return;
74
+ shown.showPopover();
75
+ /* Without anchor positioning the `position-area` above is dropped and the UA popover rules
76
+ are all that is left, which -- with the `margin` overriding their `margin: auto` -- pins the
77
+ panel to the top corner of the screen. Script places it instead, to the same geometry:
78
+ centred on the host, above it, and below it when there is no room, which is `flip-block`. */
79
+ stopPositioning?.();
80
+ stopPositioning = supportsAnchorPositioning()
81
+ ? undefined
82
+ : positionUnder(shown, host, { side: "top", gap: GAP, inset: GAP });
83
+ };
84
+ const hide = () => {
85
+ stopPositioning?.();
86
+ stopPositioning = undefined;
87
+ panel?.hidePopover();
88
+ };
89
+ // A manual popover has no light dismiss, and the pointer never enters the panel, so Escape on
90
+ // the host is the only way out for someone reading the tooltip from the keyboard.
91
+ const onKeydown = (event) => {
92
+ if (event.key === "Escape")
93
+ hide();
94
+ };
95
+ host.addEventListener("mouseenter", show);
96
+ host.addEventListener("mouseleave", hide);
97
+ host.addEventListener("focusin", show);
98
+ host.addEventListener("focusout", hide);
99
+ host.addEventListener("keydown", onKeydown);
100
+ return () => {
101
+ host.removeEventListener("mouseenter", show);
102
+ host.removeEventListener("mouseleave", hide);
103
+ host.removeEventListener("focusin", show);
104
+ host.removeEventListener("focusout", hide);
105
+ host.removeEventListener("keydown", onKeydown);
106
+ hide();
107
+ host.style.removeProperty("anchor-name");
108
+ if (inheritedAnchorName)
109
+ host.style.setProperty("anchor-name", inheritedAnchorName);
110
+ if (inheritedDescribedBy)
111
+ host.setAttribute("aria-describedby", inheritedDescribedBy);
112
+ else
113
+ host.removeAttribute("aria-describedby");
114
+ };
115
+ };
116
+ /**
117
+ * Attaches a plain-text tooltip to the host, shown on hover and on keyboard focus.
118
+ *
119
+ * The host stays the root of whatever the caller renders, so layout that depends on the element's
120
+ * own position — `join-item`, a grid cell, a flex child — keeps working untouched.
121
+ *
122
+ * ```svelte
123
+ * <button class="btn" {@attach tooltip("Archive")}>…</button>
124
+ * ```
125
+ *
126
+ * The panel is created lazily and owned by this attachment, which is what makes it cheap enough
127
+ * for one per row of a long list. Rich content belongs in markup instead — see `tooltipPanel`.
128
+ */
129
+ export function tooltip(text, options = {}) {
130
+ return (host) => {
131
+ let ownedPanel;
132
+ const unbind = bind(host, () => {
133
+ if (!ownedPanel) {
134
+ ownedPanel = document.createElement("div");
135
+ ownedPanel.textContent = text;
136
+ // Appended to the body rather than next to the host: an anchored popover escapes
137
+ // `overflow: hidden` and stacking contexts only from the top layer, and the top layer
138
+ // is only reachable from an element the host does not clip.
139
+ document.body.appendChild(ownedPanel);
140
+ }
141
+ return ownedPanel;
142
+ }, options, false);
143
+ return () => {
144
+ unbind();
145
+ ownedPanel?.remove();
146
+ };
147
+ };
148
+ }
149
+ /**
150
+ * Wires the same hover and focus behavior between the host and a panel the caller renders itself,
151
+ * which is what rich content needs: a snippet rendered in the caller's markup keeps its component
152
+ * context, its scoped styles and its reactivity, none of which survive `createElement`.
153
+ *
154
+ * The panel is given the sanctioned tooltip styling when the attachment runs.
155
+ *
156
+ * Reactivity is `{@attach}`'s own: the expression re-runs whenever `panel` changes, tearing the
157
+ * old binding down and building a new one.
158
+ *
159
+ * ```svelte
160
+ * <span {@attach tooltipPanel(panel)}>…</span>
161
+ * <div bind:this={panel} popover="manual" role="tooltip">rich <strong>content</strong></div>
162
+ * ```
163
+ */
164
+ export function tooltipPanel(panel, options = {}) {
165
+ return (host) => bind(host, () => panel ?? undefined, options, true);
166
+ }
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLButtonAttributes } from "svelte/elements";
4
+ import { AsyncAction } from "../async-action.svelte.js";
5
+ import { reportError } from "../error.js";
6
+ import LoadingButton from "./LoadingButton.svelte";
7
+
8
+ type ButtonClickEvent = MouseEvent & { currentTarget: EventTarget & HTMLButtonElement };
9
+
10
+ interface Props extends Omit<HTMLButtonAttributes, "onclick"> {
11
+ children: Snippet;
12
+ /** May return a promise; the button stays busy until it settles. */
13
+ onclick?: (event: ButtonClickEvent) => unknown;
14
+ /**
15
+ * Handles a rejection from `onclick`. Without it the failure goes to `reportError`, so a
16
+ * silent swallow is never the default.
17
+ */
18
+ onerror?: (error: unknown) => void;
19
+ }
20
+
21
+ let { children, onclick, onerror, ...rest }: Props = $props();
22
+
23
+ const action = new AsyncAction(async (event: ButtonClickEvent) => await onclick?.(event));
24
+
25
+ const handleClick = async (event: ButtonClickEvent) => {
26
+ await action.run(event);
27
+ if (action.isError) (onerror ?? reportError)(action.error);
28
+ };
29
+ </script>
30
+
31
+ <!--
32
+ @component
33
+ `LoadingButton` that derives its own busy state from the handler it was given: the click is
34
+ awaited, the spinner runs for exactly as long as the promise, and a second click cannot start
35
+ while the first is in flight.
36
+
37
+ The rejection is caught here — an event handler has no caller to catch for it — and handed to
38
+ `onerror`, or to `reportError` when the app has not said what to do with it.
39
+
40
+ ```svelte
41
+ <AsyncButton class="btn btn-primary" onclick={() => api.publish(id)} onerror={showToast}>
42
+ Publish
43
+ </AsyncButton>
44
+ ```
45
+ -->
46
+
47
+ <LoadingButton {...rest} loading={action.isPending} onclick={handleClick}>
48
+ {@render children()}
49
+ </LoadingButton>
@@ -0,0 +1,32 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLButtonAttributes } from "svelte/elements";
3
+ type ButtonClickEvent = MouseEvent & {
4
+ currentTarget: EventTarget & HTMLButtonElement;
5
+ };
6
+ interface Props extends Omit<HTMLButtonAttributes, "onclick"> {
7
+ children: Snippet;
8
+ /** May return a promise; the button stays busy until it settles. */
9
+ onclick?: (event: ButtonClickEvent) => unknown;
10
+ /**
11
+ * Handles a rejection from `onclick`. Without it the failure goes to `reportError`, so a
12
+ * silent swallow is never the default.
13
+ */
14
+ onerror?: (error: unknown) => void;
15
+ }
16
+ /**
17
+ * `LoadingButton` that derives its own busy state from the handler it was given: the click is
18
+ * awaited, the spinner runs for exactly as long as the promise, and a second click cannot start
19
+ * while the first is in flight.
20
+ *
21
+ * The rejection is caught here — an event handler has no caller to catch for it — and handed to
22
+ * `onerror`, or to `reportError` when the app has not said what to do with it.
23
+ *
24
+ * ```svelte
25
+ * <AsyncButton class="btn btn-primary" onclick={() => api.publish(id)} onerror={showToast}>
26
+ * Publish
27
+ * </AsyncButton>
28
+ * ```
29
+ */
30
+ declare const AsyncButton: import("svelte").Component<Props, {}, "">;
31
+ type AsyncButton = ReturnType<typeof AsyncButton>;
32
+ export default AsyncButton;