@mystravil/hardpoint 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +625 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +235 -0
  10. package/dist/components/Checkbox.svelte.d.ts +23 -0
  11. package/dist/components/CheckboxGroup.svelte +207 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
  13. package/dist/components/Combobox.svelte +901 -0
  14. package/dist/components/Combobox.svelte.d.ts +72 -0
  15. package/dist/components/Dialog.svelte +359 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/ImageViewer.svelte +764 -0
  24. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  25. package/dist/components/KeyValueList.svelte +114 -0
  26. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  27. package/dist/components/ListDetail.svelte +218 -0
  28. package/dist/components/ListDetail.svelte.d.ts +37 -0
  29. package/dist/components/LoadRegion.svelte +219 -0
  30. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  31. package/dist/components/MediaFrame.svelte +110 -0
  32. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  33. package/dist/components/MediaImage.svelte +256 -0
  34. package/dist/components/MediaImage.svelte.d.ts +16 -0
  35. package/dist/components/Menu.svelte +258 -0
  36. package/dist/components/Menu.svelte.d.ts +36 -0
  37. package/dist/components/MenuCheckboxItem.svelte +119 -0
  38. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  39. package/dist/components/MenuGroup.svelte +50 -0
  40. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  41. package/dist/components/MenuItem.svelte +94 -0
  42. package/dist/components/MenuItem.svelte.d.ts +19 -0
  43. package/dist/components/MenuRadioGroup.svelte +62 -0
  44. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  45. package/dist/components/MenuRadioItem.svelte +110 -0
  46. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  47. package/dist/components/MenuSeparator.svelte +28 -0
  48. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  49. package/dist/components/Meter.svelte +283 -0
  50. package/dist/components/Meter.svelte.d.ts +66 -0
  51. package/dist/components/NativeSelect.svelte +135 -0
  52. package/dist/components/NativeSelect.svelte.d.ts +52 -0
  53. package/dist/components/Notice.svelte +262 -0
  54. package/dist/components/Notice.svelte.d.ts +30 -0
  55. package/dist/components/Pagination.svelte +132 -0
  56. package/dist/components/Pagination.svelte.d.ts +18 -0
  57. package/dist/components/Popover.svelte +222 -0
  58. package/dist/components/Popover.svelte.d.ts +35 -0
  59. package/dist/components/Progress.svelte +157 -0
  60. package/dist/components/Progress.svelte.d.ts +34 -0
  61. package/dist/components/RadioGroup.svelte +440 -0
  62. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  63. package/dist/components/Select.svelte +471 -0
  64. package/dist/components/Select.svelte.d.ts +48 -0
  65. package/dist/components/Skeleton.svelte +149 -0
  66. package/dist/components/Skeleton.svelte.d.ts +24 -0
  67. package/dist/components/Spinner.svelte +118 -0
  68. package/dist/components/Spinner.svelte.d.ts +13 -0
  69. package/dist/components/Stat.svelte +253 -0
  70. package/dist/components/Stat.svelte.d.ts +46 -0
  71. package/dist/components/Steps.svelte +189 -0
  72. package/dist/components/Steps.svelte.d.ts +31 -0
  73. package/dist/components/Switch.svelte +222 -0
  74. package/dist/components/Switch.svelte.d.ts +18 -0
  75. package/dist/components/Table.svelte +504 -0
  76. package/dist/components/Table.svelte.d.ts +92 -0
  77. package/dist/components/Tabs.svelte +246 -0
  78. package/dist/components/Tabs.svelte.d.ts +44 -0
  79. package/dist/components/Tag.svelte +40 -0
  80. package/dist/components/Tag.svelte.d.ts +15 -0
  81. package/dist/components/TextArea.svelte +272 -0
  82. package/dist/components/TextArea.svelte.d.ts +28 -0
  83. package/dist/components/TextField.svelte +256 -0
  84. package/dist/components/TextField.svelte.d.ts +32 -0
  85. package/dist/components/Timeline.svelte +156 -0
  86. package/dist/components/Timeline.svelte.d.ts +30 -0
  87. package/dist/components/Toaster.svelte +411 -0
  88. package/dist/components/Toaster.svelte.d.ts +11 -0
  89. package/dist/components/Tooltip.svelte +200 -0
  90. package/dist/components/Tooltip.svelte.d.ts +38 -0
  91. package/dist/components/TooltipGroup.svelte +46 -0
  92. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  93. package/dist/components/internal/Scrim.svelte +52 -0
  94. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  95. package/dist/components/internal/Sheet.svelte +263 -0
  96. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  97. package/dist/components/internal/fade-in.d.ts +10 -0
  98. package/dist/components/internal/fade-in.js +20 -0
  99. package/dist/components/internal/groups.d.ts +14 -0
  100. package/dist/components/internal/groups.js +23 -0
  101. package/dist/components/internal/resolve.d.ts +8 -0
  102. package/dist/components/internal/resolve.js +247 -0
  103. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  104. package/dist/components/internal/swipe-dismiss.js +220 -0
  105. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  106. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  107. package/dist/components/toast.svelte.d.ts +57 -0
  108. package/dist/components/toast.svelte.js +84 -0
  109. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  110. package/dist/fonts/OFL.txt +93 -0
  111. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  112. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  113. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  114. package/dist/fonts.css +71 -0
  115. package/dist/index.d.ts +45 -0
  116. package/dist/index.js +45 -0
  117. package/dist/motion/Motion.svelte +15 -0
  118. package/dist/motion/Motion.svelte.d.ts +9 -0
  119. package/dist/motion/context.d.ts +18 -0
  120. package/dist/motion/context.js +26 -0
  121. package/dist/motion/index.d.ts +3 -0
  122. package/dist/motion/index.js +3 -0
  123. package/dist/motion/transitions.d.ts +31 -0
  124. package/dist/motion/transitions.js +112 -0
  125. package/dist/tokens.css +283 -0
  126. package/package.json +76 -6
@@ -0,0 +1,72 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ export type ComboboxItem = {
4
+ value: string;
5
+ /** The option's text: what the default filter matches, and the field's text once chosen. */
6
+ label: string;
7
+ /** A second line in `--muted`. */
8
+ description?: string;
9
+ /**
10
+ * The heading the option sits under, as written; the default filter
11
+ * matches it too. Options with the same group share one heading, in the
12
+ * order each group first appears; options without one come first.
13
+ */
14
+ group?: string;
15
+ disabled?: boolean;
16
+ };
17
+ export type ComboboxItemSnippetProps = {
18
+ item: ComboboxItem;
19
+ selected: boolean;
20
+ highlighted: boolean;
21
+ };
22
+ export type ComboboxLabelBeside = boolean | 'medium' | 'expanded' | 'large';
23
+ /** Whether an item stays in the list for the typed text (trimmed, never empty). */
24
+ export type ComboboxFilter = (item: ComboboxItem, query: string) => boolean;
25
+ /**
26
+ * Attributes and handlers not listed here go to the field control: the
27
+ * text input from 600px, the button that opens the sheet below.
28
+ */
29
+ export type ComboboxProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'id'> & {
30
+ items: ComboboxItem[];
31
+ /** The chosen item's `value`; `''` for none. It changes only by choosing from the list. */
32
+ value?: string;
33
+ open?: boolean;
34
+ /** The caps field label above the field, and the sheet's title on compact widths. */
35
+ label: string;
36
+ /** Shown in `--muted` in the empty field and in the sheet's search input. Never the only label. */
37
+ placeholder?: string;
38
+ /** Guidance under the field in `--muted`, linked by `aria-describedby`. */
39
+ hint?: string;
40
+ /**
41
+ * The error to show under the field, with an icon, linked by
42
+ * `aria-describedby`; it sets `aria-invalid`. Say how to fix it.
43
+ */
44
+ error?: string;
45
+ disabled?: boolean;
46
+ /** Submits the value with a form, through a hidden input. */
47
+ name?: string;
48
+ /**
49
+ * The label beside the field instead of above it: always (`true`), or
50
+ * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
51
+ * The hint and the error stay under the field.
52
+ */
53
+ labelBeside?: ComboboxLabelBeside;
54
+ /**
55
+ * Replaces the default filter: the label or the group contains the text,
56
+ * ignoring case and accents.
57
+ */
58
+ filter?: ComboboxFilter;
59
+ /** Shown in place of the options when nothing matches. */
60
+ emptyText?: string;
61
+ /** The sheet's close button name. */
62
+ closeLabel?: string;
63
+ /** The field control's id; the label points at it. */
64
+ id?: string;
65
+ /** The field control: the text input from 600px, the button below. */
66
+ ref?: HTMLElement | null;
67
+ /** Renders an option's content in place of its label and description. */
68
+ item?: Snippet<[ComboboxItemSnippetProps]>;
69
+ };
70
+ declare const Combobox: import("svelte").Component<ComboboxProps, {}, "value" | "ref" | "open">;
71
+ type Combobox = ReturnType<typeof Combobox>;
72
+ export default Combobox;
@@ -0,0 +1,359 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { Dialog as BitsDialog } from 'bits-ui';
4
+
5
+ export type DialogTriggerSnippetProps = {
6
+ /** Spread onto the button that opens the dialog. */
7
+ props: Record<string, unknown>;
8
+ };
9
+
10
+ export type DialogActionsSnippetProps = {
11
+ /** Closes the dialog; focus returns to what had it before. */
12
+ close: () => void;
13
+ };
14
+
15
+ /**
16
+ * Attributes and handlers not listed here go to the dialog element (Bits
17
+ * UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
18
+ * `onEscapeKeydown` and `onInteractOutside`.
19
+ */
20
+ export type DialogProps = Omit<
21
+ BitsDialog.ContentProps,
22
+ 'child' | 'children' | 'forceMount' | 'ref' | 'title'
23
+ > & {
24
+ open?: boolean;
25
+ /** The caps title and the dialog's accessible name. */
26
+ title: string;
27
+ /** A line in `--muted` under the title, and the accessible description. */
28
+ description?: string;
29
+ /**
30
+ * Below 600px, a full-screen dialog instead of a bottom sheet: for long
31
+ * or complex forms. While the on-screen keyboard is up, it fills the
32
+ * space above it. From 600px the dialog is centred either way. In
33
+ * every form a press outside does not close it, so typed input is not
34
+ * lost by accident; Escape and the close button still do.
35
+ */
36
+ fullscreen?: boolean;
37
+ /** The button that opens the dialog: spread `props` onto it. */
38
+ trigger?: Snippet<[DialogTriggerSnippetProps]>;
39
+ /** The body, under the title and description. */
40
+ children?: Snippet;
41
+ /** The action row at the bottom, primary action last. */
42
+ actions?: Snippet<[DialogActionsSnippetProps]>;
43
+ /** The close button's accessible name. */
44
+ closeLabel?: string;
45
+ /** The dialog element. */
46
+ ref?: HTMLElement | null;
47
+ };
48
+ </script>
49
+
50
+ <script lang="ts">
51
+ import { MediaQuery } from 'svelte/reactivity';
52
+ import { fade, sheet } from '../motion/index.js';
53
+ import Scrim from './internal/Scrim.svelte';
54
+ import Sheet from './internal/Sheet.svelte';
55
+ import { followVisualViewport } from './internal/visual-viewport.svelte.js';
56
+
57
+ const uid = $props.id();
58
+
59
+ let {
60
+ open = $bindable(false),
61
+ title,
62
+ description,
63
+ fullscreen = false,
64
+ trigger,
65
+ children,
66
+ actions,
67
+ closeLabel = 'Close',
68
+ ref = $bindable(null),
69
+ class: className,
70
+ ...rest
71
+ }: DialogProps = $props();
72
+
73
+ const titleId = `${uid}-title`;
74
+ const descriptionId = `${uid}-description`;
75
+
76
+ // The form follows the width (BRIEF, Apps and phones: Overlays): a bottom
77
+ // sheet below 600px, or a full-screen dialog when asked; centred from
78
+ // 600px.
79
+ const wide = new MediaQuery('min-width: 600px', true);
80
+ const form = $derived(wide.current ? 'centred' : fullscreen ? 'fullscreen' : 'sheet');
81
+
82
+ let bodyNode = $state<HTMLElement | null>(null);
83
+
84
+ // The full-screen form follows the visual viewport while it is open, as
85
+ // the Sheet does (decided 2026-11-10; internal/visual-viewport.svelte.ts):
86
+ // while the on-screen keyboard shortens it, the panel's top sits on its
87
+ // top and the panel takes its height, so the title row, the body and the
88
+ // action row stay above the keyboard, and the focused field in the body
89
+ // stays in sight. A form that changes while open drops the keyboard line.
90
+ const viewport = followVisualViewport(
91
+ () => open && form === 'fullscreen',
92
+ () => (form === 'fullscreen' ? bodyNode : null)
93
+ );
94
+ const lifted = $derived(form === 'fullscreen' ? viewport.current : null);
95
+
96
+ // A fullscreen form ignores presses outside, so typed input is not lost;
97
+ // an app can override it through rest props. Bits UI also hands the
98
+ // option on in the content snippet's props; the snippet drops it there,
99
+ // because it is not an HTML attribute.
100
+
101
+ function close() {
102
+ open = false;
103
+ }
104
+
105
+ // The centred dialog fades in place; the full-screen one slides up like
106
+ // a sheet. Both at the dialog timing; reduced motion fades both.
107
+ function panel(node: Element) {
108
+ return form === 'fullscreen' ? sheet(node) : fade(node);
109
+ }
110
+ </script>
111
+
112
+ <BitsDialog.Root bind:open>
113
+ {#if trigger}
114
+ <BitsDialog.Trigger>
115
+ {#snippet child({ props })}
116
+ {@render trigger({ props })}
117
+ {/snippet}
118
+ </BitsDialog.Trigger>
119
+ {/if}
120
+
121
+ <BitsDialog.Portal>
122
+ <BitsDialog.Content
123
+ forceMount
124
+ bind:ref
125
+ aria-labelledby={titleId}
126
+ aria-describedby={description ? descriptionId : undefined}
127
+ interactOutsideBehavior={fullscreen ? 'ignore' : 'close'}
128
+ {...rest}
129
+ >
130
+ {#snippet child({ props: { interactOutsideBehavior: _, ...props }, open: shown })}
131
+ {#if form === 'sheet'}
132
+ <Sheet
133
+ {...props}
134
+ class={['hp-dialog', className]}
135
+ data-form="sheet"
136
+ open={shown}
137
+ layer="dialog"
138
+ {title}
139
+ {titleId}
140
+ titleTag="h2"
141
+ {closeLabel}
142
+ onclose={close}
143
+ footer={actions ? actionRow : undefined}
144
+ >
145
+ {@render body()}
146
+ </Sheet>
147
+ {:else}
148
+ <Scrim open={shown} layer="dialog" />
149
+ {#if shown}
150
+ <div
151
+ {...props}
152
+ class={['hp-dialog', className]}
153
+ data-form={form}
154
+ style:top={lifted ? `${lifted.top}px` : undefined}
155
+ style:bottom={lifted ? 'auto' : undefined}
156
+ style:height={lifted ? `${lifted.height}px` : undefined}
157
+ style:padding-bottom={lifted ? '0px' : undefined}
158
+ transition:panel
159
+ >
160
+ <div class="hp-dialog-head" data-form={form}>
161
+ <h2 id={titleId} class="hp-dialog-title hp-view-title">{title}</h2>
162
+ <button type="button" class="hp-dialog-close" aria-label={closeLabel} onclick={close}>
163
+ <svg
164
+ viewBox="0 0 16 16"
165
+ fill="none"
166
+ stroke="currentColor"
167
+ stroke-width="1.5"
168
+ stroke-linecap="square"
169
+ aria-hidden="true"
170
+ >
171
+ <path d="M3 3l10 10M13 3L3 13" />
172
+ </svg>
173
+ </button>
174
+ </div>
175
+ {@render body()}
176
+ {#if actions}
177
+ {@render actionRow()}
178
+ {/if}
179
+ </div>
180
+ {/if}
181
+ {/if}
182
+ {/snippet}
183
+ </BitsDialog.Content>
184
+ </BitsDialog.Portal>
185
+ </BitsDialog.Root>
186
+
187
+ {#snippet body()}
188
+ <div
189
+ class="hp-dialog-body"
190
+ data-form={form}
191
+ data-end={actions ? undefined : ''}
192
+ bind:this={bodyNode}
193
+ >
194
+ {#if description}
195
+ <p id={descriptionId} class="hp-dialog-description">{description}</p>
196
+ {/if}
197
+ {@render children?.()}
198
+ </div>
199
+ {/snippet}
200
+
201
+ {#snippet actionRow()}
202
+ <div class="hp-dialog-actions" data-form={form}>
203
+ {@render actions?.({ close })}
204
+ </div>
205
+ {/snippet}
206
+
207
+ <style>
208
+ /* ---- Centred (600px and up) and full screen (compact) --------------- */
209
+
210
+ .hp-dialog[data-form='centred'],
211
+ .hp-dialog[data-form='fullscreen'] {
212
+ position: fixed;
213
+ z-index: var(--layer-dialog);
214
+ display: flex;
215
+ flex-direction: column;
216
+ box-sizing: border-box;
217
+ background: var(--canvas);
218
+ color: var(--text);
219
+ overscroll-behavior: contain;
220
+ /* Bits UI may have turned pointer events off on <body>. */
221
+ pointer-events: auto;
222
+ }
223
+
224
+ /* A square black panel with a 3:1 boundary, centred in the safe area. */
225
+ .hp-dialog[data-form='centred'] {
226
+ inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
227
+ env(safe-area-inset-left);
228
+ width: min(560px, 100% - 2 * var(--gutter));
229
+ height: fit-content;
230
+ max-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 2 * var(--space-8));
231
+ margin: auto;
232
+ border: 1px solid var(--border-control);
233
+ }
234
+
235
+ /* The whole screen, inside the safe areas. While the on-screen keyboard
236
+ is up, the visual viewport above it, without the bottom safe area,
237
+ which the keyboard covers (set inline). */
238
+ .hp-dialog[data-form='fullscreen'] {
239
+ inset: 0;
240
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
241
+ env(safe-area-inset-left);
242
+ }
243
+
244
+ /* Title row: the caps title, then the close button at the end. A long
245
+ title wraps (200 percent zoom) and the button stays on its first line:
246
+ the title's padding centres that line on the button, so a single line
247
+ sits where the row centred it before. The title's clip stays: it keeps
248
+ the first glyph's antialiasing inside the text edge, as before. */
249
+ .hp-dialog-head {
250
+ flex: none;
251
+ display: grid;
252
+ grid-template-columns: minmax(0, 1fr) auto;
253
+ align-items: start;
254
+ gap: var(--space-2);
255
+ }
256
+ .hp-dialog-head[data-form='centred'] {
257
+ padding: var(--space-4) var(--space-4) 0 var(--space-6);
258
+ }
259
+ /* The shell header's height, so the form reads as its own view. */
260
+ .hp-dialog-head[data-form='fullscreen'] {
261
+ min-height: var(--header-height);
262
+ padding: 0 var(--space-1) 0 var(--gutter);
263
+ }
264
+ .hp-dialog-title {
265
+ overflow: hidden;
266
+ padding-block: calc((var(--control-height) - 1lh) / 2);
267
+ overflow-wrap: anywhere;
268
+ }
269
+ .hp-dialog-head[data-form='fullscreen'] .hp-dialog-title {
270
+ padding-block: calc((var(--header-height) - 1lh) / 2);
271
+ }
272
+ .hp-dialog-head[data-form='fullscreen'] .hp-dialog-close {
273
+ margin-block: calc((var(--header-height) - var(--control-height)) / 2);
274
+ }
275
+
276
+ .hp-dialog-close {
277
+ display: grid;
278
+ place-items: center;
279
+ /* 36px on fine pointers, 44px on coarse. */
280
+ width: var(--control-height);
281
+ height: var(--control-height);
282
+ padding: 0;
283
+ border: 0;
284
+ background: transparent;
285
+ color: var(--text);
286
+ cursor: pointer;
287
+ -webkit-tap-highlight-color: transparent;
288
+ }
289
+ .hp-dialog-close svg {
290
+ width: var(--space-4);
291
+ height: var(--space-4);
292
+ }
293
+ .hp-dialog-close:active {
294
+ background: var(--surface-raised);
295
+ }
296
+ @media (hover: hover) and (pointer: fine) {
297
+ .hp-dialog-close {
298
+ transition: background-color var(--dur-hover) var(--settle);
299
+ }
300
+ .hp-dialog-close:hover {
301
+ background: var(--surface-hover);
302
+ }
303
+ .hp-dialog-close:active {
304
+ background: var(--surface-raised);
305
+ transition-duration: var(--dur-press);
306
+ }
307
+ }
308
+
309
+ /* ---- Body and actions, in every form -------------------------------- */
310
+
311
+ /* The top padding keeps a first field's focus contour (4px out, 2px
312
+ wide) inside the scrolling box. The scroll padding keeps a focused
313
+ field's caret 8px clear of the action row when the browser reveals it,
314
+ as when a TextArea grows while someone types. */
315
+ .hp-dialog-body {
316
+ display: grid;
317
+ align-content: start;
318
+ gap: var(--space-4);
319
+ min-width: 0;
320
+ padding: var(--space-2) var(--gutter);
321
+ scroll-padding-block: var(--space-2);
322
+ font-size: var(--type-body);
323
+ line-height: 24px;
324
+ }
325
+ /* The sheet's body scrolls itself; these scroll inside the panel. */
326
+ .hp-dialog-body[data-form='centred'],
327
+ .hp-dialog-body[data-form='fullscreen'] {
328
+ flex: 1 1 auto;
329
+ min-height: 0;
330
+ overflow-y: auto;
331
+ overscroll-behavior: contain;
332
+ }
333
+ .hp-dialog-body[data-form='centred'] {
334
+ padding-inline: var(--space-6);
335
+ }
336
+ /* Without an action row the body ends the panel. */
337
+ .hp-dialog-body[data-end] {
338
+ padding-bottom: var(--space-4);
339
+ }
340
+ .hp-dialog-body[data-form='centred'][data-end] {
341
+ padding-bottom: var(--space-6);
342
+ }
343
+ .hp-dialog-description {
344
+ color: var(--muted);
345
+ }
346
+
347
+ /* Actions sit at the end, primary last, 8px apart. */
348
+ .hp-dialog-actions {
349
+ flex: none;
350
+ display: flex;
351
+ flex-wrap: wrap;
352
+ justify-content: flex-end;
353
+ gap: var(--space-2);
354
+ padding: var(--space-4) var(--gutter);
355
+ }
356
+ .hp-dialog-actions[data-form='centred'] {
357
+ padding: var(--space-6);
358
+ }
359
+ </style>
@@ -0,0 +1,43 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { Dialog as BitsDialog } from 'bits-ui';
3
+ export type DialogTriggerSnippetProps = {
4
+ /** Spread onto the button that opens the dialog. */
5
+ props: Record<string, unknown>;
6
+ };
7
+ export type DialogActionsSnippetProps = {
8
+ /** Closes the dialog; focus returns to what had it before. */
9
+ close: () => void;
10
+ };
11
+ /**
12
+ * Attributes and handlers not listed here go to the dialog element (Bits
13
+ * UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
14
+ * `onEscapeKeydown` and `onInteractOutside`.
15
+ */
16
+ export type DialogProps = Omit<BitsDialog.ContentProps, 'child' | 'children' | 'forceMount' | 'ref' | 'title'> & {
17
+ open?: boolean;
18
+ /** The caps title and the dialog's accessible name. */
19
+ title: string;
20
+ /** A line in `--muted` under the title, and the accessible description. */
21
+ description?: string;
22
+ /**
23
+ * Below 600px, a full-screen dialog instead of a bottom sheet: for long
24
+ * or complex forms. While the on-screen keyboard is up, it fills the
25
+ * space above it. From 600px the dialog is centred either way. In
26
+ * every form a press outside does not close it, so typed input is not
27
+ * lost by accident; Escape and the close button still do.
28
+ */
29
+ fullscreen?: boolean;
30
+ /** The button that opens the dialog: spread `props` onto it. */
31
+ trigger?: Snippet<[DialogTriggerSnippetProps]>;
32
+ /** The body, under the title and description. */
33
+ children?: Snippet;
34
+ /** The action row at the bottom, primary action last. */
35
+ actions?: Snippet<[DialogActionsSnippetProps]>;
36
+ /** The close button's accessible name. */
37
+ closeLabel?: string;
38
+ /** The dialog element. */
39
+ ref?: HTMLElement | null;
40
+ };
41
+ declare const Dialog: import("svelte").Component<DialogProps, {}, "ref" | "open">;
42
+ type Dialog = ReturnType<typeof Dialog>;
43
+ export default Dialog;
@@ -0,0 +1,201 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { Collapsible } from 'bits-ui';
4
+
5
+ export type DisclosureTitleElement = 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
6
+
7
+ /**
8
+ * Attributes and handlers not listed here (`id`, `aria-*`, `data-*`,
9
+ * `onOpenChange`) go to the root element.
10
+ */
11
+ export type DisclosureProps = Omit<Collapsible.RootProps, 'child' | 'children' | 'open' | 'ref' | 'title'> & {
12
+ /** The trigger's text, as written. It never takes caps. */
13
+ title: string;
14
+ /** Whether the content shows. Bindable. */
15
+ open?: boolean;
16
+ /**
17
+ * Wraps the trigger in a heading of this level, so the section shows
18
+ * in a screen reader's heading list. Pick the level that fits the
19
+ * page's outline. Without it the trigger stands alone.
20
+ */
21
+ titleAs?: DisclosureTitleElement;
22
+ disabled?: boolean;
23
+ /** The root element. */
24
+ ref?: HTMLDivElement | null;
25
+ /** The content. */
26
+ children?: Snippet;
27
+ };
28
+ </script>
29
+
30
+ <script lang="ts">
31
+ import { untrack } from 'svelte';
32
+ import { Collapsible as BitsCollapsible } from 'bits-ui';
33
+ import { duration } from '../motion/transitions.js';
34
+
35
+ let {
36
+ title,
37
+ open = $bindable(false),
38
+ titleAs,
39
+ disabled = false,
40
+ ref = $bindable(null),
41
+ class: className,
42
+ children,
43
+ ...rest
44
+ }: DisclosureProps = $props();
45
+
46
+ let body = $state<HTMLDivElement>();
47
+ /** The last `open` this component saw, so only a change fades. */
48
+ let was = untrack(() => open);
49
+
50
+ // Height snaps: the content is there at its full size on the first
51
+ // frame, and its text fades in. Opacity only, so it also runs under
52
+ // reduced motion. Never on first render: `was` starts at the first
53
+ // value of `open`. The duration and the curve are read now, so a token
54
+ // retuned on <html> applies. A fade that is still running when the
55
+ // section closes is cancelled, so closing is a cut.
56
+ $effect(() => {
57
+ const now = open;
58
+ if (now === was) return;
59
+ was = now;
60
+ untrack(() => {
61
+ if (!body) return;
62
+ for (const a of body.getAnimations()) a.cancel();
63
+ if (!now) return;
64
+ const ms = duration(body, '--dur-expand');
65
+ if (ms <= 0) return;
66
+ const settle = getComputedStyle(body).getPropertyValue('--settle').trim();
67
+ body.animate(
68
+ { opacity: [0, 1] },
69
+ { duration: ms, easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out' }
70
+ );
71
+ });
72
+ });
73
+ </script>
74
+
75
+ {#snippet trigger()}
76
+ <BitsCollapsible.Trigger class="hp-disclosure-trigger hp-dense">{title}</BitsCollapsible.Trigger>
77
+ {/snippet}
78
+
79
+ <!--
80
+ One expandable section on Bits UI Collapsible. The trigger is a button
81
+ with aria-expanded and aria-controls (the WAI disclosure pattern); with
82
+ `titleAs` it sits inside a heading. Opening snaps the height and fades
83
+ the content in over --dur-expand; closing cuts. Closed content stays in
84
+ the document as hidden="until-found", so a browser that supports it finds
85
+ text in it and opens the section (Bits UI's beforematch handler). A browser
86
+ without it treats the attribute as plain hidden: the closed content is
87
+ not searchable, and the trigger is the only way in. The attribute is set
88
+ here, not by Bits UI, so closing hides the content in the same frame.
89
+ -->
90
+ <BitsCollapsible.Root bind:open bind:ref {disabled} class={['hp-disclosure', className]} {...rest}>
91
+ {#if titleAs}
92
+ <svelte:element this={titleAs} class="heading">{@render trigger()}</svelte:element>
93
+ {:else}
94
+ {@render trigger()}
95
+ {/if}
96
+ <BitsCollapsible.Content hiddenUntilFound>
97
+ {#snippet child({ props })}
98
+ <div {...props} hidden={open ? undefined : 'until-found'} class="hp-disclosure-content">
99
+ <div class="body" bind:this={body}>
100
+ {@render children?.()}
101
+ </div>
102
+ </div>
103
+ {/snippet}
104
+ </BitsCollapsible.Content>
105
+ </BitsCollapsible.Root>
106
+
107
+ <style>
108
+ /* Stacked disclosures sit 8px apart. No dividers: structure from
109
+ alignment. */
110
+ :global(.hp-disclosure + .hp-disclosure) {
111
+ margin-top: var(--space-2);
112
+ }
113
+
114
+ .heading {
115
+ margin: 0;
116
+ font: inherit;
117
+ }
118
+
119
+ /* A full-width row, title at the start, chevron at the end. The hover
120
+ tint and the focus contour reach --bleed past the text edge, as table
121
+ bands do, and the padding keeps the title on the page edge. It wraps
122
+ a long title; it never truncates. */
123
+ :global(.hp-disclosure-trigger) {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: var(--space-4);
128
+ box-sizing: border-box;
129
+ width: calc(100% + 2 * var(--bleed));
130
+ min-height: var(--control-height);
131
+ margin: 0 calc(-1 * var(--bleed));
132
+ padding: var(--space-2) var(--bleed);
133
+ border: 0;
134
+ background: transparent;
135
+ color: var(--text);
136
+ font-family: var(--sans);
137
+ font-weight: 500;
138
+ text-align: start;
139
+ overflow-wrap: anywhere;
140
+ cursor: pointer;
141
+ touch-action: manipulation;
142
+ -webkit-tap-highlight-color: transparent;
143
+ }
144
+
145
+ /* The select well's chevron: the same mask, 16px, --muted; it points up
146
+ while open, with the list's timing (--dur-pop opening, --dur-pop-exit
147
+ closing) and no turn under reduced motion. */
148
+ :global(.hp-disclosure-trigger::after) {
149
+ content: '';
150
+ flex: none;
151
+ width: 16px;
152
+ height: 16px;
153
+ background-color: var(--muted);
154
+ mask: var(--select-chevron) center / 16px 16px no-repeat;
155
+ transition: transform var(--dur-pop-exit) var(--settle);
156
+ }
157
+ :global(.hp-disclosure-trigger[aria-expanded='true']::after) {
158
+ transform: rotate(180deg);
159
+ transition-duration: var(--dur-pop);
160
+ }
161
+ @media (prefers-reduced-motion: reduce) {
162
+ :global(.hp-disclosure-trigger::after) {
163
+ transition: none;
164
+ }
165
+ }
166
+
167
+ /* Hover: fine pointers only. The tint alone barely shows (1.03:1), so the
168
+ title also underlines, as a tertiary Button's does. */
169
+ @media (hover: hover) and (pointer: fine) {
170
+ :global(.hp-disclosure-trigger) {
171
+ transition: background-color var(--dur-hover) var(--settle);
172
+ }
173
+ :global(.hp-disclosure-trigger:active:not(:disabled)) {
174
+ transition-duration: var(--dur-press);
175
+ }
176
+ :global(.hp-disclosure-trigger:hover:not(:disabled)) {
177
+ background-color: var(--surface-hover);
178
+ text-decoration: underline;
179
+ text-decoration-thickness: 1px;
180
+ text-underline-offset: 0.3em;
181
+ }
182
+ }
183
+ /* One step past the hover tint, so a press reads on a phone too. */
184
+ :global(.hp-disclosure-trigger:active:not(:disabled)) {
185
+ background-color: var(--surface-raised);
186
+ }
187
+
188
+ :global(.hp-disclosure-trigger:disabled) {
189
+ color: var(--muted);
190
+ cursor: not-allowed;
191
+ }
192
+ :global(.hp-disclosure-trigger:disabled::after) {
193
+ background-color: var(--border);
194
+ }
195
+
196
+ /* The padding sits on the inner box: closed content is hidden, and a
197
+ hidden box still shows its own padding. */
198
+ .body {
199
+ padding: var(--space-1) 0 var(--space-3);
200
+ }
201
+ </style>
@@ -0,0 +1,27 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Collapsible } from 'bits-ui';
3
+ export type DisclosureTitleElement = 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
4
+ /**
5
+ * Attributes and handlers not listed here (`id`, `aria-*`, `data-*`,
6
+ * `onOpenChange`) go to the root element.
7
+ */
8
+ export type DisclosureProps = Omit<Collapsible.RootProps, 'child' | 'children' | 'open' | 'ref' | 'title'> & {
9
+ /** The trigger's text, as written. It never takes caps. */
10
+ title: string;
11
+ /** Whether the content shows. Bindable. */
12
+ open?: boolean;
13
+ /**
14
+ * Wraps the trigger in a heading of this level, so the section shows
15
+ * in a screen reader's heading list. Pick the level that fits the
16
+ * page's outline. Without it the trigger stands alone.
17
+ */
18
+ titleAs?: DisclosureTitleElement;
19
+ disabled?: boolean;
20
+ /** The root element. */
21
+ ref?: HTMLDivElement | null;
22
+ /** The content. */
23
+ children?: Snippet;
24
+ };
25
+ declare const Disclosure: import("svelte").Component<DisclosureProps, {}, "ref" | "open">;
26
+ type Disclosure = ReturnType<typeof Disclosure>;
27
+ export default Disclosure;