@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.
- package/CHANGELOG.md +42 -18
- package/LICENSE +25 -25
- package/README.md +67 -64
- package/dist/base.css +625 -346
- package/dist/components/AppShell.svelte +698 -698
- package/dist/components/Button.svelte +174 -174
- package/dist/components/Checkbox.svelte +235 -198
- package/dist/components/Checkbox.svelte.d.ts +9 -2
- package/dist/components/CheckboxGroup.svelte +207 -99
- package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
- package/dist/components/Combobox.svelte +901 -824
- package/dist/components/Combobox.svelte.d.ts +10 -1
- package/dist/components/Dialog.svelte +359 -356
- package/dist/components/Disclosure.svelte +201 -201
- package/dist/components/EmptyState.svelte +88 -88
- package/dist/components/ErrorState.svelte +135 -135
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -114
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -219
- package/dist/components/MediaFrame.svelte +110 -110
- package/dist/components/MediaImage.svelte +256 -256
- package/dist/components/Menu.svelte +258 -258
- package/dist/components/MenuCheckboxItem.svelte +119 -119
- package/dist/components/MenuGroup.svelte +50 -50
- package/dist/components/MenuItem.svelte +94 -94
- package/dist/components/MenuRadioGroup.svelte +62 -62
- package/dist/components/MenuRadioItem.svelte +110 -110
- package/dist/components/MenuSeparator.svelte +28 -28
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +18 -2
- package/dist/components/NativeSelect.svelte.d.ts +6 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Popover.svelte +222 -222
- package/dist/components/Progress.svelte +157 -157
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -405
- package/dist/components/Select.svelte.d.ts +6 -0
- package/dist/components/Skeleton.svelte +149 -149
- package/dist/components/Spinner.svelte +118 -117
- package/dist/components/Stat.svelte +253 -253
- package/dist/components/Steps.svelte +189 -189
- package/dist/components/Switch.svelte +222 -222
- package/dist/components/Table.svelte +504 -504
- package/dist/components/Tabs.svelte +246 -246
- package/dist/components/Tag.svelte +40 -40
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -256
- package/dist/components/Timeline.svelte +156 -156
- package/dist/components/Toaster.svelte +411 -411
- package/dist/components/Tooltip.svelte +200 -200
- package/dist/components/TooltipGroup.svelte +46 -46
- package/dist/components/internal/Scrim.svelte +52 -52
- package/dist/components/internal/Sheet.svelte +263 -263
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/fonts.css +71 -71
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/tokens.css +283 -279
- 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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
.hp-dialog-body[data-
|
|
338
|
-
padding-bottom: var(--space-
|
|
339
|
-
}
|
|
340
|
-
.hp-dialog-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
padding: var(--space-
|
|
355
|
-
}
|
|
356
|
-
|
|
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>
|