@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +625 -0
- package/dist/components/AppShell.svelte +698 -0
- package/dist/components/AppShell.svelte.d.ts +77 -0
- package/dist/components/Button.svelte +174 -0
- package/dist/components/Button.svelte.d.ts +20 -0
- package/dist/components/Checkbox.svelte +235 -0
- package/dist/components/Checkbox.svelte.d.ts +23 -0
- package/dist/components/CheckboxGroup.svelte +207 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
- package/dist/components/Combobox.svelte +901 -0
- package/dist/components/Combobox.svelte.d.ts +72 -0
- package/dist/components/Dialog.svelte +359 -0
- package/dist/components/Dialog.svelte.d.ts +43 -0
- package/dist/components/Disclosure.svelte +201 -0
- package/dist/components/Disclosure.svelte.d.ts +27 -0
- package/dist/components/EmptyState.svelte +88 -0
- package/dist/components/EmptyState.svelte.d.ts +22 -0
- package/dist/components/ErrorState.svelte +135 -0
- package/dist/components/ErrorState.svelte.d.ts +26 -0
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -0
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -0
- package/dist/components/LoadRegion.svelte.d.ts +27 -0
- package/dist/components/MediaFrame.svelte +110 -0
- package/dist/components/MediaFrame.svelte.d.ts +20 -0
- package/dist/components/MediaImage.svelte +256 -0
- package/dist/components/MediaImage.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +258 -0
- package/dist/components/Menu.svelte.d.ts +36 -0
- package/dist/components/MenuCheckboxItem.svelte +119 -0
- package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
- package/dist/components/MenuGroup.svelte +50 -0
- package/dist/components/MenuGroup.svelte.d.ts +15 -0
- package/dist/components/MenuItem.svelte +94 -0
- package/dist/components/MenuItem.svelte.d.ts +19 -0
- package/dist/components/MenuRadioGroup.svelte +62 -0
- package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
- package/dist/components/MenuRadioItem.svelte +110 -0
- package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
- package/dist/components/MenuSeparator.svelte +28 -0
- package/dist/components/MenuSeparator.svelte.d.ts +6 -0
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +135 -0
- package/dist/components/NativeSelect.svelte.d.ts +52 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Pagination.svelte +132 -0
- package/dist/components/Pagination.svelte.d.ts +18 -0
- package/dist/components/Popover.svelte +222 -0
- package/dist/components/Popover.svelte.d.ts +35 -0
- package/dist/components/Progress.svelte +157 -0
- package/dist/components/Progress.svelte.d.ts +34 -0
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -0
- package/dist/components/Select.svelte.d.ts +48 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +118 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +253 -0
- package/dist/components/Stat.svelte.d.ts +46 -0
- package/dist/components/Steps.svelte +189 -0
- package/dist/components/Steps.svelte.d.ts +31 -0
- package/dist/components/Switch.svelte +222 -0
- package/dist/components/Switch.svelte.d.ts +18 -0
- package/dist/components/Table.svelte +504 -0
- package/dist/components/Table.svelte.d.ts +92 -0
- package/dist/components/Tabs.svelte +246 -0
- package/dist/components/Tabs.svelte.d.ts +44 -0
- package/dist/components/Tag.svelte +40 -0
- package/dist/components/Tag.svelte.d.ts +15 -0
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -0
- package/dist/components/TextField.svelte.d.ts +32 -0
- package/dist/components/Timeline.svelte +156 -0
- package/dist/components/Timeline.svelte.d.ts +30 -0
- package/dist/components/Toaster.svelte +411 -0
- package/dist/components/Toaster.svelte.d.ts +11 -0
- package/dist/components/Tooltip.svelte +200 -0
- package/dist/components/Tooltip.svelte.d.ts +38 -0
- package/dist/components/TooltipGroup.svelte +46 -0
- package/dist/components/TooltipGroup.svelte.d.ts +29 -0
- package/dist/components/internal/Scrim.svelte +52 -0
- package/dist/components/internal/Scrim.svelte.d.ts +15 -0
- package/dist/components/internal/Sheet.svelte +263 -0
- package/dist/components/internal/Sheet.svelte.d.ts +36 -0
- package/dist/components/internal/fade-in.d.ts +10 -0
- package/dist/components/internal/fade-in.js +20 -0
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/components/internal/resolve.d.ts +8 -0
- package/dist/components/internal/resolve.js +247 -0
- package/dist/components/internal/swipe-dismiss.d.ts +47 -0
- package/dist/components/internal/swipe-dismiss.js +220 -0
- package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
- package/dist/components/internal/visual-viewport.svelte.js +79 -0
- package/dist/components/toast.svelte.d.ts +57 -0
- package/dist/components/toast.svelte.js +84 -0
- package/dist/fonts/OFL-geist-pixel.txt +92 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/fonts/geist-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-pixel-square.woff2 +0 -0
- package/dist/fonts.css +71 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +45 -0
- package/dist/motion/Motion.svelte +15 -0
- package/dist/motion/Motion.svelte.d.ts +9 -0
- package/dist/motion/context.d.ts +18 -0
- package/dist/motion/context.js +26 -0
- package/dist/motion/index.d.ts +3 -0
- package/dist/motion/index.js +3 -0
- package/dist/motion/transitions.d.ts +31 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/tokens.css +283 -0
- 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;
|