@mystravil/hardpoint 0.1.0 → 0.2.1

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 (67) hide show
  1. package/CHANGELOG.md +42 -18
  2. package/LICENSE +25 -25
  3. package/README.md +67 -64
  4. package/dist/base.css +625 -346
  5. package/dist/components/AppShell.svelte +698 -698
  6. package/dist/components/Button.svelte +174 -174
  7. package/dist/components/Checkbox.svelte +235 -198
  8. package/dist/components/Checkbox.svelte.d.ts +9 -2
  9. package/dist/components/CheckboxGroup.svelte +207 -99
  10. package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
  11. package/dist/components/Combobox.svelte +901 -824
  12. package/dist/components/Combobox.svelte.d.ts +10 -1
  13. package/dist/components/Dialog.svelte +359 -356
  14. package/dist/components/Disclosure.svelte +201 -201
  15. package/dist/components/EmptyState.svelte +88 -88
  16. package/dist/components/ErrorState.svelte +135 -135
  17. package/dist/components/ImageViewer.svelte +764 -0
  18. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  19. package/dist/components/KeyValueList.svelte +114 -114
  20. package/dist/components/ListDetail.svelte +218 -0
  21. package/dist/components/ListDetail.svelte.d.ts +37 -0
  22. package/dist/components/LoadRegion.svelte +219 -219
  23. package/dist/components/MediaFrame.svelte +110 -110
  24. package/dist/components/MediaImage.svelte +256 -256
  25. package/dist/components/Menu.svelte +258 -258
  26. package/dist/components/MenuCheckboxItem.svelte +119 -119
  27. package/dist/components/MenuGroup.svelte +50 -50
  28. package/dist/components/MenuItem.svelte +94 -94
  29. package/dist/components/MenuRadioGroup.svelte +62 -62
  30. package/dist/components/MenuRadioItem.svelte +110 -110
  31. package/dist/components/MenuSeparator.svelte +28 -28
  32. package/dist/components/Meter.svelte +283 -0
  33. package/dist/components/Meter.svelte.d.ts +66 -0
  34. package/dist/components/NativeSelect.svelte +18 -2
  35. package/dist/components/NativeSelect.svelte.d.ts +6 -0
  36. package/dist/components/Notice.svelte +262 -0
  37. package/dist/components/Notice.svelte.d.ts +30 -0
  38. package/dist/components/Popover.svelte +222 -222
  39. package/dist/components/Progress.svelte +157 -157
  40. package/dist/components/RadioGroup.svelte +440 -0
  41. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  42. package/dist/components/Select.svelte +471 -405
  43. package/dist/components/Select.svelte.d.ts +6 -0
  44. package/dist/components/Skeleton.svelte +149 -149
  45. package/dist/components/Spinner.svelte +118 -117
  46. package/dist/components/Stat.svelte +253 -253
  47. package/dist/components/Steps.svelte +189 -189
  48. package/dist/components/Switch.svelte +222 -222
  49. package/dist/components/Table.svelte +504 -504
  50. package/dist/components/Tabs.svelte +246 -246
  51. package/dist/components/Tag.svelte +40 -40
  52. package/dist/components/TextArea.svelte +272 -0
  53. package/dist/components/TextArea.svelte.d.ts +28 -0
  54. package/dist/components/TextField.svelte +256 -256
  55. package/dist/components/Timeline.svelte +156 -156
  56. package/dist/components/Toaster.svelte +411 -411
  57. package/dist/components/Tooltip.svelte +200 -200
  58. package/dist/components/TooltipGroup.svelte +46 -46
  59. package/dist/components/internal/Scrim.svelte +52 -52
  60. package/dist/components/internal/Sheet.svelte +263 -263
  61. package/dist/components/internal/groups.d.ts +14 -0
  62. package/dist/components/internal/groups.js +23 -0
  63. package/dist/fonts.css +71 -71
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.js +6 -0
  66. package/dist/tokens.css +283 -279
  67. package/package.json +81 -76
@@ -1,356 +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. */
313
- .hp-dialog-body {
314
- display: grid;
315
- align-content: start;
316
- gap: var(--space-4);
317
- min-width: 0;
318
- padding: var(--space-2) var(--gutter);
319
- font-size: var(--type-body);
320
- line-height: 24px;
321
- }
322
- /* The sheet's body scrolls itself; these scroll inside the panel. */
323
- .hp-dialog-body[data-form='centred'],
324
- .hp-dialog-body[data-form='fullscreen'] {
325
- flex: 1 1 auto;
326
- min-height: 0;
327
- overflow-y: auto;
328
- overscroll-behavior: contain;
329
- }
330
- .hp-dialog-body[data-form='centred'] {
331
- padding-inline: var(--space-6);
332
- }
333
- /* Without an action row the body ends the panel. */
334
- .hp-dialog-body[data-end] {
335
- padding-bottom: var(--space-4);
336
- }
337
- .hp-dialog-body[data-form='centred'][data-end] {
338
- padding-bottom: var(--space-6);
339
- }
340
- .hp-dialog-description {
341
- color: var(--muted);
342
- }
343
-
344
- /* Actions sit at the end, primary last, 8px apart. */
345
- .hp-dialog-actions {
346
- flex: none;
347
- display: flex;
348
- flex-wrap: wrap;
349
- justify-content: flex-end;
350
- gap: var(--space-2);
351
- padding: var(--space-4) var(--gutter);
352
- }
353
- .hp-dialog-actions[data-form='centred'] {
354
- padding: var(--space-6);
355
- }
356
- </style>
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>