@dorsk/tsumikit 0.1.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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/dist/autoresize.d.ts +11 -0
  4. package/dist/autoresize.js +24 -0
  5. package/dist/components/atoms/Badge.svelte +72 -0
  6. package/dist/components/atoms/Badge.svelte.d.ts +12 -0
  7. package/dist/components/atoms/Button.svelte +156 -0
  8. package/dist/components/atoms/Button.svelte.d.ts +13 -0
  9. package/dist/components/atoms/Card.svelte +46 -0
  10. package/dist/components/atoms/Card.svelte.d.ts +11 -0
  11. package/dist/components/atoms/Checkbox.svelte +99 -0
  12. package/dist/components/atoms/Checkbox.svelte.d.ts +10 -0
  13. package/dist/components/atoms/Chip.svelte +53 -0
  14. package/dist/components/atoms/Chip.svelte.d.ts +11 -0
  15. package/dist/components/atoms/Heading.svelte +66 -0
  16. package/dist/components/atoms/Heading.svelte.d.ts +13 -0
  17. package/dist/components/atoms/Icon.svelte +151 -0
  18. package/dist/components/atoms/Icon.svelte.d.ts +18 -0
  19. package/dist/components/atoms/Input.svelte +42 -0
  20. package/dist/components/atoms/Input.svelte.d.ts +10 -0
  21. package/dist/components/atoms/Link.svelte +31 -0
  22. package/dist/components/atoms/Link.svelte.d.ts +10 -0
  23. package/dist/components/atoms/Progress.svelte +59 -0
  24. package/dist/components/atoms/Progress.svelte.d.ts +9 -0
  25. package/dist/components/atoms/Select.svelte +95 -0
  26. package/dist/components/atoms/Select.svelte.d.ts +11 -0
  27. package/dist/components/atoms/Slider.svelte +136 -0
  28. package/dist/components/atoms/Slider.svelte.d.ts +14 -0
  29. package/dist/components/atoms/Switch.svelte +64 -0
  30. package/dist/components/atoms/Switch.svelte.d.ts +8 -0
  31. package/dist/components/atoms/Text.svelte +127 -0
  32. package/dist/components/atoms/Text.svelte.d.ts +16 -0
  33. package/dist/components/atoms/Textarea.svelte +62 -0
  34. package/dist/components/atoms/Textarea.svelte.d.ts +11 -0
  35. package/dist/components/layouts/AppShell.svelte +304 -0
  36. package/dist/components/layouts/AppShell.svelte.d.ts +21 -0
  37. package/dist/components/layouts/AutoGrid.svelte +36 -0
  38. package/dist/components/layouts/AutoGrid.svelte.d.ts +12 -0
  39. package/dist/components/layouts/Cluster.svelte +45 -0
  40. package/dist/components/layouts/Cluster.svelte.d.ts +14 -0
  41. package/dist/components/layouts/Container.svelte +40 -0
  42. package/dist/components/layouts/Container.svelte.d.ts +13 -0
  43. package/dist/components/layouts/NavItem.svelte +95 -0
  44. package/dist/components/layouts/NavItem.svelte.d.ts +14 -0
  45. package/dist/components/layouts/Stack.svelte +44 -0
  46. package/dist/components/layouts/Stack.svelte.d.ts +13 -0
  47. package/dist/components/molecules/Accordion.svelte +94 -0
  48. package/dist/components/molecules/Accordion.svelte.d.ts +16 -0
  49. package/dist/components/molecules/CodeBlock.svelte +119 -0
  50. package/dist/components/molecules/CodeBlock.svelte.d.ts +17 -0
  51. package/dist/components/molecules/CopyButton.svelte +80 -0
  52. package/dist/components/molecules/CopyButton.svelte.d.ts +13 -0
  53. package/dist/components/molecules/Dropzone.svelte +140 -0
  54. package/dist/components/molecules/Dropzone.svelte.d.ts +13 -0
  55. package/dist/components/molecules/Field.svelte +57 -0
  56. package/dist/components/molecules/Field.svelte.d.ts +12 -0
  57. package/dist/components/molecules/FileButton.svelte +68 -0
  58. package/dist/components/molecules/FileButton.svelte.d.ts +14 -0
  59. package/dist/components/molecules/FontScalePicker.svelte +21 -0
  60. package/dist/components/molecules/FontScalePicker.svelte.d.ts +6 -0
  61. package/dist/components/molecules/IconButton.svelte +36 -0
  62. package/dist/components/molecules/IconButton.svelte.d.ts +13 -0
  63. package/dist/components/molecules/Menu.svelte +120 -0
  64. package/dist/components/molecules/Menu.svelte.d.ts +17 -0
  65. package/dist/components/molecules/Modal.svelte +263 -0
  66. package/dist/components/molecules/Modal.svelte.d.ts +13 -0
  67. package/dist/components/molecules/OptionButton.svelte +76 -0
  68. package/dist/components/molecules/OptionButton.svelte.d.ts +10 -0
  69. package/dist/components/molecules/Popover.svelte +125 -0
  70. package/dist/components/molecules/Popover.svelte.d.ts +18 -0
  71. package/dist/components/molecules/RadioGroup.svelte +110 -0
  72. package/dist/components/molecules/RadioGroup.svelte.d.ts +16 -0
  73. package/dist/components/molecules/SelectButton.svelte +52 -0
  74. package/dist/components/molecules/SelectButton.svelte.d.ts +15 -0
  75. package/dist/components/molecules/Tabs.svelte +119 -0
  76. package/dist/components/molecules/Tabs.svelte.d.ts +15 -0
  77. package/dist/components/molecules/ThemePicker.svelte +22 -0
  78. package/dist/components/molecules/ThemePicker.svelte.d.ts +6 -0
  79. package/dist/components/molecules/Toaster.svelte +73 -0
  80. package/dist/components/molecules/Toaster.svelte.d.ts +18 -0
  81. package/dist/components/molecules/Toggle.svelte +68 -0
  82. package/dist/components/molecules/Toggle.svelte.d.ts +11 -0
  83. package/dist/components/molecules/Tooltip.svelte +106 -0
  84. package/dist/components/molecules/Tooltip.svelte.d.ts +10 -0
  85. package/dist/components/organisms/DataTable.svelte +145 -0
  86. package/dist/components/organisms/DataTable.svelte.d.ts +43 -0
  87. package/dist/env.d.ts +1 -0
  88. package/dist/env.js +4 -0
  89. package/dist/index.d.ts +46 -0
  90. package/dist/index.js +56 -0
  91. package/dist/stores/fontscale.svelte.d.ts +15 -0
  92. package/dist/stores/fontscale.svelte.js +49 -0
  93. package/dist/stores/theme.svelte.d.ts +96 -0
  94. package/dist/stores/theme.svelte.js +71 -0
  95. package/dist/stores/toast.svelte.d.ts +19 -0
  96. package/dist/stores/toast.svelte.js +26 -0
  97. package/dist/styles/app.css +522 -0
  98. package/dist/styles/variables.css +651 -0
  99. package/package.json +71 -0
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ import Button from '../atoms/Button.svelte';
4
+ import Icon from '../atoms/Icon.svelte';
5
+ import type { IconName } from '../atoms/Icon.svelte';
6
+
7
+ type IconButtonProps = HTMLButtonAttributes & {
8
+ icon: IconName;
9
+ label: string;
10
+ variant?: 'default' | 'primary' | 'ghost' | 'danger';
11
+ size?: number;
12
+ // Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
13
+ // no square `.btn-icon` box; just a muted glyph that brightens on hover.
14
+ inline?: boolean;
15
+ class?: string;
16
+ };
17
+
18
+ let {
19
+ icon,
20
+ label,
21
+ title = label,
22
+ variant = 'ghost',
23
+ size = 18,
24
+ inline = false,
25
+ disabled = false,
26
+ onclick,
27
+ class: klass = '',
28
+ ...rest
29
+ }: IconButtonProps = $props();
30
+ </script>
31
+
32
+ <!-- Composition: the icon-only button is a Button (canonical control styling)
33
+ carrying the square `.btn-icon` modifier, wrapping an Icon. -->
34
+ <Button {...rest} {variant} {disabled} {title} {onclick} class="{inline ? 'btn-icon-inline' : 'btn-icon'} {klass}" aria-label={label}>
35
+ <Icon name={icon} {size} />
36
+ </Button>
@@ -0,0 +1,13 @@
1
+ import type { HTMLButtonAttributes } from 'svelte/elements';
2
+ import type { IconName } from '../atoms/Icon.svelte';
3
+ type IconButtonProps = HTMLButtonAttributes & {
4
+ icon: IconName;
5
+ label: string;
6
+ variant?: 'default' | 'primary' | 'ghost' | 'danger';
7
+ size?: number;
8
+ inline?: boolean;
9
+ class?: string;
10
+ };
11
+ declare const IconButton: import("svelte").Component<IconButtonProps, {}, "">;
12
+ type IconButton = ReturnType<typeof IconButton>;
13
+ export default IconButton;
@@ -0,0 +1,120 @@
1
+ <script lang="ts" module>
2
+ export interface MenuItem {
3
+ label: string;
4
+ onselect: () => void;
5
+ icon?: import('../atoms/Icon.svelte').IconName;
6
+ danger?: boolean;
7
+ disabled?: boolean;
8
+ }
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ // Dropdown menu: a Popover whose panel is a WAI-ARIA `menu`. Items are
13
+ // `menuitem`s navigable with ↑/↓/Home/End, activated with Enter/Space (and
14
+ // click). Selecting an item runs its action and closes the menu. Focus moves
15
+ // to the first item when the menu opens. Escape / click-outside close it
16
+ // (inherited from the native popover).
17
+ import type { Snippet } from 'svelte';
18
+ import Popover from './Popover.svelte';
19
+ import Icon from '../atoms/Icon.svelte';
20
+
21
+ let {
22
+ label,
23
+ items,
24
+ trigger,
25
+ placement = 'bottom-start'
26
+ }: {
27
+ label: string;
28
+ items: MenuItem[];
29
+ trigger: Snippet;
30
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
31
+ } = $props();
32
+
33
+ let listEl = $state<HTMLDivElement | null>(null);
34
+
35
+ function buttons(): HTMLButtonElement[] {
36
+ return listEl
37
+ ? Array.from(listEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)'))
38
+ : [];
39
+ }
40
+ function focusAt(i: number) {
41
+ const b = buttons();
42
+ if (b.length) b[(i + b.length) % b.length].focus();
43
+ }
44
+ function onkeydown(e: KeyboardEvent) {
45
+ const b = buttons();
46
+ const i = b.indexOf(document.activeElement as HTMLButtonElement);
47
+ if (e.key === 'ArrowDown') {
48
+ e.preventDefault();
49
+ focusAt(i + 1);
50
+ } else if (e.key === 'ArrowUp') {
51
+ e.preventDefault();
52
+ focusAt(i - 1);
53
+ } else if (e.key === 'Home') {
54
+ e.preventDefault();
55
+ focusAt(0);
56
+ } else if (e.key === 'End') {
57
+ e.preventDefault();
58
+ focusAt(b.length - 1);
59
+ }
60
+ }
61
+ function select(item: MenuItem) {
62
+ if (item.disabled) return;
63
+ // Close the popover, then run the action.
64
+ listEl?.closest<HTMLElement>('[popover]')?.hidePopover();
65
+ item.onselect();
66
+ }
67
+ </script>
68
+
69
+ <Popover {label} {placement} {trigger} onopen={() => queueMicrotask(() => focusAt(0))}>
70
+ <div bind:this={listEl} role="menu" aria-label={label} class="menu" tabindex="-1" {onkeydown}>
71
+ {#each items as item (item.label)}
72
+ <button
73
+ type="button"
74
+ role="menuitem"
75
+ class="menu-item"
76
+ class:danger={item.danger}
77
+ disabled={item.disabled}
78
+ tabindex="-1"
79
+ onclick={() => select(item)}
80
+ >
81
+ {#if item.icon}<Icon name={item.icon} />{/if}
82
+ <span>{item.label}</span>
83
+ </button>
84
+ {/each}
85
+ </div>
86
+ </Popover>
87
+
88
+ <style>
89
+ .menu {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 1px;
93
+ }
94
+ .menu-item {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: var(--sp-2);
98
+ width: 100%;
99
+ text-align: left;
100
+ padding: var(--sp-2) var(--sp-3);
101
+ border: none;
102
+ background: none;
103
+ color: var(--text);
104
+ border-radius: var(--r-sm);
105
+ font-size: var(--fs-sm);
106
+ white-space: nowrap;
107
+ }
108
+ .menu-item:hover:not(:disabled),
109
+ .menu-item:focus-visible {
110
+ background: var(--bg-elevated-2);
111
+ outline: none;
112
+ }
113
+ .menu-item.danger {
114
+ color: var(--danger);
115
+ }
116
+ .menu-item:disabled {
117
+ opacity: 0.45;
118
+ cursor: not-allowed;
119
+ }
120
+ </style>
@@ -0,0 +1,17 @@
1
+ export interface MenuItem {
2
+ label: string;
3
+ onselect: () => void;
4
+ icon?: import('../atoms/Icon.svelte').IconName;
5
+ danger?: boolean;
6
+ disabled?: boolean;
7
+ }
8
+ import type { Snippet } from 'svelte';
9
+ type $$ComponentProps = {
10
+ label: string;
11
+ items: MenuItem[];
12
+ trigger: Snippet;
13
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
14
+ };
15
+ declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type Menu = ReturnType<typeof Menu>;
17
+ export default Menu;
@@ -0,0 +1,263 @@
1
+ <script lang="ts">
2
+ // Accessible dialog / bottom-sheet built on the native <dialog> element, so
3
+ // the platform gives us the hard parts for free: top-layer rendering above
4
+ // everything (no z-index races), a real focus trap, inert background, initial
5
+ // focus, focus restoration to the trigger, Escape-to-close and a styleable
6
+ // ::backdrop. We only add: open-on-mount, click-outside, and optional
7
+ // desktop resize (width persisted under `resizeKey`).
8
+ import type { Snippet } from 'svelte';
9
+ import { browser } from '../../env';
10
+ import IconButton from './IconButton.svelte';
11
+
12
+ let {
13
+ title,
14
+ onclose,
15
+ body,
16
+ footer,
17
+ resizeKey
18
+ }: {
19
+ title: string;
20
+ onclose: () => void;
21
+ body: Snippet;
22
+ footer?: Snippet;
23
+ /** When set, the sheet is horizontally resizable on desktop and the chosen
24
+ * width persists under this localStorage key. */
25
+ resizeKey?: string;
26
+ } = $props();
27
+
28
+ const titleId = `modal-title-${Math.random().toString(36).slice(2, 8)}`;
29
+
30
+ const MIN_W = 544; // 34rem at 16px base
31
+ const MAX_W = 1100;
32
+
33
+ function loadWidth(): number | null {
34
+ if (!browser || !resizeKey) return null;
35
+ const n = Number(localStorage.getItem(resizeKey));
36
+ return Number.isFinite(n) && n >= MIN_W ? Math.min(n, MAX_W) : null;
37
+ }
38
+ let width = $state<number | null>(loadWidth());
39
+ let dialogEl = $state<HTMLDialogElement | null>(null);
40
+
41
+ $effect(() => {
42
+ // Open as a modal (top layer + trap + inert background + focus mgmt). The
43
+ // native element restores focus to the trigger automatically on close.
44
+ dialogEl?.showModal();
45
+ });
46
+
47
+ // Click outside: <dialog fills the viewport; clicks on its padding-free self
48
+ // (not the inner .sheet) are backdrop clicks.
49
+ function onDialogClick(e: MouseEvent) {
50
+ if (e.target === dialogEl) onclose();
51
+ }
52
+
53
+ // --- resize ---
54
+ let resizing = $state(false);
55
+ let startX = 0;
56
+ let startW = 0;
57
+ function startResize(e: PointerEvent) {
58
+ resizing = true;
59
+ startX = e.clientX;
60
+ startW = (e.currentTarget as HTMLElement).closest('.sheet')!.getBoundingClientRect().width;
61
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
62
+ e.preventDefault();
63
+ }
64
+ // Coalesce pointermoves to one width update per frame. Pointer events fire
65
+ // faster than the display refresh, and each width change reflows the sheet +
66
+ // repaints its shadow — writing once per rAF caps that to the frame rate.
67
+ let rafId = 0;
68
+ let lastX = 0;
69
+ function onResize(e: PointerEvent) {
70
+ if (!resizing) return;
71
+ lastX = e.clientX;
72
+ if (rafId) return;
73
+ rafId = requestAnimationFrame(() => {
74
+ rafId = 0;
75
+ const next = startW + (lastX - startX) * 2; // centered: edge tracks cursor
76
+ width = Math.round(Math.max(MIN_W, Math.min(next, MAX_W, window.innerWidth - 32)));
77
+ });
78
+ }
79
+ function endResize(e: PointerEvent) {
80
+ if (!resizing) return;
81
+ resizing = false;
82
+ if (rafId) {
83
+ cancelAnimationFrame(rafId);
84
+ rafId = 0;
85
+ }
86
+ try {
87
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
88
+ } catch {
89
+ /* already released */
90
+ }
91
+ if (browser && resizeKey && width != null) localStorage.setItem(resizeKey, String(width));
92
+ }
93
+ </script>
94
+
95
+ <dialog
96
+ bind:this={dialogEl}
97
+ class="modal"
98
+ class:resizing
99
+ aria-labelledby={titleId}
100
+ style={width != null ? `--sheet-w: ${width}px` : undefined}
101
+ oncancel={(e) => {
102
+ e.preventDefault(); /* keep parent the source of truth for open state */
103
+ onclose();
104
+ }}
105
+ onclick={onDialogClick}
106
+ >
107
+ <div class="sheet">
108
+ <div class="sheet-head">
109
+ <span id={titleId} class="sheet-title truncate">{title}</span>
110
+ <div class="spacer"></div>
111
+ <IconButton icon="x" label="Close dialog" onclick={onclose} />
112
+ </div>
113
+ <div class="sheet-body">
114
+ {@render body()}
115
+ </div>
116
+ {#if footer}
117
+ <div class="sheet-foot">{@render footer()}</div>
118
+ {/if}
119
+ {#if resizeKey}
120
+ <div
121
+ class="sheet-resize"
122
+ role="separator"
123
+ aria-label="Resize dialog width"
124
+ aria-orientation="vertical"
125
+ onpointerdown={startResize}
126
+ onpointermove={onResize}
127
+ onpointerup={endResize}
128
+ onpointercancel={endResize}
129
+ ></div>
130
+ {/if}
131
+ </div>
132
+ </dialog>
133
+
134
+ <style>
135
+ /* Reset the UA dialog box: we own the surface via the inner .sheet. The
136
+ dialog itself is just the centering/backdrop layer. */
137
+ .modal {
138
+ margin: 0;
139
+ padding: 0;
140
+ border: 0;
141
+ background: none;
142
+ max-width: 100vw;
143
+ max-height: 100dvh;
144
+ width: 100%;
145
+ height: 100%;
146
+ color: var(--text);
147
+ /* center on desktop; the sheet anchors to the bottom on mobile */
148
+ display: flex;
149
+ align-items: flex-end;
150
+ justify-content: center;
151
+ }
152
+ .modal::backdrop {
153
+ background: rgba(0, 0, 0, 0.55);
154
+ /* animate the backdrop in (progressive enhancement) */
155
+ animation: backdrop-in 0.18s var(--ease);
156
+ }
157
+ @keyframes backdrop-in {
158
+ from {
159
+ opacity: 0;
160
+ }
161
+ }
162
+ @media (min-width: 640px) {
163
+ .modal {
164
+ align-items: center;
165
+ padding: var(--sp-6);
166
+ }
167
+ }
168
+
169
+ .sheet {
170
+ position: relative;
171
+ width: 100%;
172
+ max-width: 34rem;
173
+ max-height: calc(100dvh - var(--safe-top) - var(--sp-6));
174
+ display: flex;
175
+ flex-direction: column;
176
+ background: var(--bg-elevated);
177
+ border: 1px solid var(--border);
178
+ border-radius: var(--r-lg) var(--r-lg) 0 0;
179
+ box-shadow: var(--shadow-lg);
180
+ animation: sheet-up 0.18s var(--ease);
181
+ padding-bottom: var(--safe-bottom);
182
+ }
183
+ @media (min-width: 640px) {
184
+ .sheet {
185
+ border-radius: var(--r-lg);
186
+ padding-bottom: 0;
187
+ width: var(--sheet-w, 34rem);
188
+ max-width: min(var(--sheet-w, 34rem), calc(100vw - 2rem));
189
+ }
190
+ }
191
+ @keyframes sheet-up {
192
+ from {
193
+ transform: translateY(8%);
194
+ opacity: 0.4;
195
+ }
196
+ }
197
+ .modal.resizing .sheet {
198
+ user-select: none;
199
+ /* Make per-frame width changes cheap to paint: hint the animated property,
200
+ isolate layout/paint to the sheet, and drop the heavy blurred shadow
201
+ (40px blur) for a light one while dragging. */
202
+ will-change: width;
203
+ contain: layout paint;
204
+ box-shadow: var(--shadow-sm);
205
+ }
206
+
207
+ .sheet-head {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: var(--sp-2);
211
+ padding: var(--sp-4);
212
+ border-bottom: 1px solid var(--border);
213
+ }
214
+ .sheet-title {
215
+ font-size: var(--fs-lg);
216
+ font-weight: var(--fw-semibold);
217
+ }
218
+ .sheet-body {
219
+ padding: var(--sp-4);
220
+ overflow-y: auto;
221
+ -webkit-overflow-scrolling: touch;
222
+ }
223
+ .sheet-foot {
224
+ display: flex;
225
+ gap: var(--sp-2);
226
+ padding: var(--sp-4);
227
+ border-top: 1px solid var(--border);
228
+ }
229
+
230
+ .sheet-resize {
231
+ display: none;
232
+ }
233
+ @media (min-width: 640px) {
234
+ .sheet-resize {
235
+ display: block;
236
+ position: absolute;
237
+ top: 0;
238
+ bottom: 0;
239
+ right: 0;
240
+ width: 12px;
241
+ margin-right: -6px;
242
+ cursor: ew-resize;
243
+ touch-action: none;
244
+ z-index: 2;
245
+ }
246
+ .sheet-resize::after {
247
+ content: '';
248
+ position: absolute;
249
+ top: 50%;
250
+ right: 6px;
251
+ transform: translateY(-50%);
252
+ width: 3px;
253
+ height: 28px;
254
+ border-radius: 999px;
255
+ background: var(--border-strong);
256
+ transition: background 0.12s var(--ease);
257
+ }
258
+ .sheet-resize:hover::after,
259
+ .modal.resizing .sheet-resize::after {
260
+ background: var(--accent);
261
+ }
262
+ }
263
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ title: string;
4
+ onclose: () => void;
5
+ body: Snippet;
6
+ footer?: Snippet;
7
+ /** When set, the sheet is horizontally resizable on desktop and the chosen
8
+ * width persists under this localStorage key. */
9
+ resizeKey?: string;
10
+ };
11
+ declare const Modal: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type Modal = ReturnType<typeof Modal>;
13
+ export default Modal;
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ // Selection-card option — a bordered card that gains a colored ring + tint
3
+ // when `selected`. The accent is driven by `--opt-accent` (default accent),
4
+ // so each picker recolors per use (blue for run-target, brand color for the
5
+ // adapter, green/blue/red for permission mode). `row` lays the content out
6
+ // horizontally (icon + label) instead of the default label-over-hint column.
7
+ //
8
+ // Specializes the Button atom (ghost variant) so it inherits the canonical
9
+ // disabled/focus/transition behaviour; the card surface + selection ring are
10
+ // overrides. Button's variant classes are scoped via :where() (0 specificity),
11
+ // so `.btn.opt-btn` (0,2,0) wins cleanly over `.btn-ghost`.
12
+ import type { Snippet } from 'svelte';
13
+ import type { HTMLButtonAttributes } from 'svelte/elements';
14
+ import Button from '../atoms/Button.svelte';
15
+
16
+ let {
17
+ selected = false,
18
+ row = false,
19
+ class: klass = '',
20
+ children,
21
+ ...rest
22
+ }: HTMLButtonAttributes & {
23
+ selected?: boolean;
24
+ row?: boolean;
25
+ children?: Snippet;
26
+ } = $props();
27
+ </script>
28
+
29
+ <Button
30
+ {...rest}
31
+ variant="ghost"
32
+ class="opt-btn {row ? 'row' : ''} {selected ? 'sel' : ''} {klass}"
33
+ aria-pressed={selected}
34
+ >
35
+ {@render children?.()}
36
+ </Button>
37
+
38
+ <style>
39
+ /* Rendered inside Button, so target via :global; `.btn.opt-btn` outranks the
40
+ atom's :where()-scoped variant classes. */
41
+ :global(.btn.opt-btn) {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 2px;
45
+ padding: var(--sp-2);
46
+ min-height: 0;
47
+ background: var(--bg);
48
+ border: 1px solid var(--border-strong);
49
+ border-radius: var(--r-md);
50
+ color: var(--text);
51
+ text-align: left;
52
+ white-space: normal;
53
+ }
54
+ :global(.btn.opt-btn:hover:not(:disabled)) {
55
+ border-color: var(--border-strong);
56
+ background: var(--bg);
57
+ }
58
+ :global(.btn.opt-btn.row) {
59
+ flex-direction: row;
60
+ align-items: center;
61
+ justify-content: center;
62
+ gap: var(--sp-2);
63
+ color: var(--text-muted);
64
+ font-weight: var(--fw-medium);
65
+ }
66
+ :global(.btn.opt-btn.sel) {
67
+ --oc: var(--opt-accent, var(--accent));
68
+ border-color: var(--oc);
69
+ background: color-mix(in srgb, var(--oc) 14%, var(--bg));
70
+ color: var(--oc);
71
+ }
72
+ /* The slotted hint text (a global `.faint`) tints toward the accent too. */
73
+ :global(.btn.opt-btn.sel .faint) {
74
+ color: color-mix(in srgb, var(--oc) 70%, var(--text-muted));
75
+ }
76
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = HTMLButtonAttributes & {
4
+ selected?: boolean;
5
+ row?: boolean;
6
+ children?: Snippet;
7
+ };
8
+ declare const OptionButton: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type OptionButton = ReturnType<typeof OptionButton>;
10
+ export default OptionButton;
@@ -0,0 +1,125 @@
1
+ <script lang="ts">
2
+ // Floating panel built on the native Popover API. The platform gives us the
3
+ // top layer (no z-index races, renders above dialogs/sticky headers), light
4
+ // dismiss (click-outside), Escape-to-close and correct focus/`aria-expanded`
5
+ // wiring between the invoker and the panel — all declaratively via the
6
+ // `popovertarget` / `popover` attributes. We add only smart placement:
7
+ // position the panel against its trigger and flip into the viewport when it
8
+ // would overflow. (CSS anchor positioning would replace the JS here once it
9
+ // ships beyond Chromium; the popover semantics above are the hard part and
10
+ // are broadly supported today.)
11
+ import type { Snippet } from 'svelte';
12
+
13
+ type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
14
+
15
+ let {
16
+ placement = 'bottom-start',
17
+ gap = 6,
18
+ label,
19
+ trigger,
20
+ children,
21
+ onopen,
22
+ onclose
23
+ }: {
24
+ placement?: Placement;
25
+ gap?: number;
26
+ /** Accessible name for the trigger. */
27
+ label: string;
28
+ /** Trigger content (rendered inside a ghost button that owns the popover
29
+ * wiring). */
30
+ trigger: Snippet;
31
+ /** Panel content. */
32
+ children: Snippet;
33
+ onopen?: () => void;
34
+ onclose?: () => void;
35
+ } = $props();
36
+
37
+ const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
38
+ let triggerEl = $state<HTMLButtonElement | null>(null);
39
+ let panelEl = $state<HTMLDivElement | null>(null);
40
+
41
+ function place() {
42
+ if (!triggerEl || !panelEl) return;
43
+ const t = triggerEl.getBoundingClientRect();
44
+ const p = panelEl.getBoundingClientRect();
45
+ const vw = document.documentElement.clientWidth;
46
+ const vh = document.documentElement.clientHeight;
47
+
48
+ let top = placement.startsWith('bottom') ? t.bottom + gap : t.top - p.height - gap;
49
+ let left = placement.endsWith('end') ? t.right - p.width : t.left;
50
+
51
+ // Flip vertically / clamp horizontally to stay on screen.
52
+ if (top + p.height > vh && t.top - p.height - gap > 0) top = t.top - p.height - gap;
53
+ if (top < 0) top = gap;
54
+ left = Math.max(gap, Math.min(left, vw - p.width - gap));
55
+
56
+ panelEl.style.top = `${Math.round(top)}px`;
57
+ panelEl.style.left = `${Math.round(left)}px`;
58
+ }
59
+
60
+ function onToggle(e: ToggleEvent) {
61
+ if (e.newState === 'open') {
62
+ place();
63
+ addEventListener('scroll', place, true);
64
+ addEventListener('resize', place);
65
+ onopen?.();
66
+ } else {
67
+ removeEventListener('scroll', place, true);
68
+ removeEventListener('resize', place);
69
+ onclose?.();
70
+ }
71
+ }
72
+ </script>
73
+
74
+ <button
75
+ bind:this={triggerEl}
76
+ type="button"
77
+ class="btn btn-ghost btn-icon pop-trigger"
78
+ popovertarget={id}
79
+ aria-label={label}
80
+ >
81
+ {@render trigger()}
82
+ </button>
83
+
84
+ <div
85
+ bind:this={panelEl}
86
+ {id}
87
+ popover="auto"
88
+ class="pop-panel"
89
+ role="group"
90
+ aria-label={label}
91
+ ontoggle={onToggle}
92
+ >
93
+ {@render children()}
94
+ </div>
95
+
96
+ <style>
97
+ .pop-trigger {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ }
102
+ .pop-panel {
103
+ position: fixed;
104
+ margin: 0;
105
+ inset: auto; /* JS sets top/left; the popover lives in the top layer */
106
+ min-width: 10rem;
107
+ max-width: min(22rem, calc(100vw - 2 * var(--sp-3)));
108
+ padding: var(--sp-1);
109
+ background: var(--bg-elevated);
110
+ color: var(--text);
111
+ border: 1px solid var(--border-strong);
112
+ border-radius: var(--r-md);
113
+ box-shadow: var(--shadow-md);
114
+ }
115
+ /* Enter animation (skipped under reduced-motion via the global rule). */
116
+ .pop-panel:popover-open {
117
+ animation: pop-in 0.12s var(--ease);
118
+ }
119
+ @keyframes pop-in {
120
+ from {
121
+ opacity: 0;
122
+ transform: translateY(-4px);
123
+ }
124
+ }
125
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
3
+ type $$ComponentProps = {
4
+ placement?: Placement;
5
+ gap?: number;
6
+ /** Accessible name for the trigger. */
7
+ label: string;
8
+ /** Trigger content (rendered inside a ghost button that owns the popover
9
+ * wiring). */
10
+ trigger: Snippet;
11
+ /** Panel content. */
12
+ children: Snippet;
13
+ onopen?: () => void;
14
+ onclose?: () => void;
15
+ };
16
+ declare const Popover: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type Popover = ReturnType<typeof Popover>;
18
+ export default Popover;