@mystravil/hardpoint 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +346 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +198 -0
  10. package/dist/components/Checkbox.svelte.d.ts +16 -0
  11. package/dist/components/CheckboxGroup.svelte +99 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
  13. package/dist/components/Combobox.svelte +824 -0
  14. package/dist/components/Combobox.svelte.d.ts +63 -0
  15. package/dist/components/Dialog.svelte +356 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/KeyValueList.svelte +114 -0
  24. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  25. package/dist/components/LoadRegion.svelte +219 -0
  26. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  27. package/dist/components/MediaFrame.svelte +110 -0
  28. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  29. package/dist/components/MediaImage.svelte +256 -0
  30. package/dist/components/MediaImage.svelte.d.ts +16 -0
  31. package/dist/components/Menu.svelte +258 -0
  32. package/dist/components/Menu.svelte.d.ts +36 -0
  33. package/dist/components/MenuCheckboxItem.svelte +119 -0
  34. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  35. package/dist/components/MenuGroup.svelte +50 -0
  36. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  37. package/dist/components/MenuItem.svelte +94 -0
  38. package/dist/components/MenuItem.svelte.d.ts +19 -0
  39. package/dist/components/MenuRadioGroup.svelte +62 -0
  40. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  41. package/dist/components/MenuRadioItem.svelte +110 -0
  42. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  43. package/dist/components/MenuSeparator.svelte +28 -0
  44. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  45. package/dist/components/NativeSelect.svelte +119 -0
  46. package/dist/components/NativeSelect.svelte.d.ts +46 -0
  47. package/dist/components/Pagination.svelte +132 -0
  48. package/dist/components/Pagination.svelte.d.ts +18 -0
  49. package/dist/components/Popover.svelte +222 -0
  50. package/dist/components/Popover.svelte.d.ts +35 -0
  51. package/dist/components/Progress.svelte +157 -0
  52. package/dist/components/Progress.svelte.d.ts +34 -0
  53. package/dist/components/Select.svelte +405 -0
  54. package/dist/components/Select.svelte.d.ts +42 -0
  55. package/dist/components/Skeleton.svelte +149 -0
  56. package/dist/components/Skeleton.svelte.d.ts +24 -0
  57. package/dist/components/Spinner.svelte +117 -0
  58. package/dist/components/Spinner.svelte.d.ts +13 -0
  59. package/dist/components/Stat.svelte +253 -0
  60. package/dist/components/Stat.svelte.d.ts +46 -0
  61. package/dist/components/Steps.svelte +189 -0
  62. package/dist/components/Steps.svelte.d.ts +31 -0
  63. package/dist/components/Switch.svelte +222 -0
  64. package/dist/components/Switch.svelte.d.ts +18 -0
  65. package/dist/components/Table.svelte +504 -0
  66. package/dist/components/Table.svelte.d.ts +92 -0
  67. package/dist/components/Tabs.svelte +246 -0
  68. package/dist/components/Tabs.svelte.d.ts +44 -0
  69. package/dist/components/Tag.svelte +40 -0
  70. package/dist/components/Tag.svelte.d.ts +15 -0
  71. package/dist/components/TextField.svelte +256 -0
  72. package/dist/components/TextField.svelte.d.ts +32 -0
  73. package/dist/components/Timeline.svelte +156 -0
  74. package/dist/components/Timeline.svelte.d.ts +30 -0
  75. package/dist/components/Toaster.svelte +411 -0
  76. package/dist/components/Toaster.svelte.d.ts +11 -0
  77. package/dist/components/Tooltip.svelte +200 -0
  78. package/dist/components/Tooltip.svelte.d.ts +38 -0
  79. package/dist/components/TooltipGroup.svelte +46 -0
  80. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  81. package/dist/components/internal/Scrim.svelte +52 -0
  82. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  83. package/dist/components/internal/Sheet.svelte +263 -0
  84. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  85. package/dist/components/internal/fade-in.d.ts +10 -0
  86. package/dist/components/internal/fade-in.js +20 -0
  87. package/dist/components/internal/resolve.d.ts +8 -0
  88. package/dist/components/internal/resolve.js +247 -0
  89. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  90. package/dist/components/internal/swipe-dismiss.js +220 -0
  91. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  92. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  93. package/dist/components/toast.svelte.d.ts +57 -0
  94. package/dist/components/toast.svelte.js +84 -0
  95. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  96. package/dist/fonts/OFL.txt +93 -0
  97. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  98. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  99. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  100. package/dist/fonts.css +71 -0
  101. package/dist/index.d.ts +39 -0
  102. package/dist/index.js +39 -0
  103. package/dist/motion/Motion.svelte +15 -0
  104. package/dist/motion/Motion.svelte.d.ts +9 -0
  105. package/dist/motion/context.d.ts +18 -0
  106. package/dist/motion/context.js +26 -0
  107. package/dist/motion/index.d.ts +3 -0
  108. package/dist/motion/index.js +3 -0
  109. package/dist/motion/transitions.d.ts +31 -0
  110. package/dist/motion/transitions.js +112 -0
  111. package/dist/tokens.css +279 -0
  112. package/package.json +76 -6
@@ -0,0 +1,200 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { Tooltip as BitsTooltip } from 'bits-ui';
4
+
5
+ export type TooltipTriggerSnippetProps = {
6
+ /**
7
+ * Spread onto the control the tooltip belongs to. Its own handlers may
8
+ * come before or after. A control with its own description writes
9
+ * `aria-describedby={[props['aria-describedby'], ownId].filter(Boolean).join(' ')}`
10
+ * after the spread, so it keeps both.
11
+ */
12
+ props: Record<string, unknown>;
13
+ };
14
+
15
+ /**
16
+ * Attributes and handlers not listed here go to the tooltip element (Bits
17
+ * UI `Tooltip.Content`), among them `side` (default `top`), `align`
18
+ * (default `center`), `sideOffset` (default 8) and `onEscapeKeydown`.
19
+ */
20
+ export type TooltipProps = Omit<BitsTooltip.ContentProps, 'child' | 'children' | 'forceMount' | 'ref'> & {
21
+ open?: boolean;
22
+ /**
23
+ * The tooltip, as written. It never opens on touch, so it is never the
24
+ * only path to its text: the control shows the same text, or carries it
25
+ * in `aria-label`.
26
+ */
27
+ text: string;
28
+ /** The control the tooltip belongs to: spread `props` onto it. */
29
+ trigger: Snippet<[TooltipTriggerSnippetProps]>;
30
+ /**
31
+ * Milliseconds a pointer rests on the trigger before the tooltip opens
32
+ * (default 700, Bits UI's, or the TooltipGroup's). Keyboard focus opens
33
+ * it at once.
34
+ */
35
+ delayDuration?: number;
36
+ /** The tooltip element. */
37
+ ref?: HTMLElement | null;
38
+ };
39
+
40
+ // Bits UI keeps one tooltip open at a time per provider, and each Tooltip
41
+ // outside a TooltipGroup has its own provider, so the rule spans them here:
42
+ // the close function of the open tooltip, if any.
43
+ let closeOpen: (() => void) | null = null;
44
+ </script>
45
+
46
+ <script lang="ts">
47
+ import { createAttachmentKey } from 'svelte/attachments';
48
+ import { pop } from '../motion/index.js';
49
+ import { inTooltipGroup } from './TooltipGroup.svelte';
50
+
51
+ let {
52
+ open = $bindable(false),
53
+ text,
54
+ trigger,
55
+ delayDuration,
56
+ ref = $bindable(null),
57
+ class: className,
58
+ ...rest
59
+ }: TooltipProps = $props();
60
+
61
+ const listen = createAttachmentKey();
62
+ const capture = createAttachmentKey();
63
+
64
+ // Inside a TooltipGroup the group's provider is shared, so the next
65
+ // tooltip in the row skips the delay; outside one, the tooltip has its own.
66
+ const grouped = inTooltipGroup();
67
+
68
+ // Opening this tooltip closes any other one, for example one a resting
69
+ // pointer opened when keyboard focus opens this.
70
+ const close = () => {
71
+ open = false;
72
+ };
73
+ $effect(() => {
74
+ if (!open) return;
75
+ if (closeOpen !== close) closeOpen?.();
76
+ closeOpen = close;
77
+ return () => {
78
+ if (closeOpen === close) closeOpen = null;
79
+ };
80
+ });
81
+
82
+ // The control, kept to compare its name with the text.
83
+ let control = $state<Element | null>(null);
84
+ const captureControl = (node: Element) => {
85
+ control = node;
86
+ };
87
+
88
+ // The usual tooltip repeats the control's name: an icon button's
89
+ // aria-label, or the whole of a shortened name. Describing the control by
90
+ // the same text would make screen readers read it twice.
91
+ function repeatsName(node: Element | null): boolean {
92
+ if (!node) return false;
93
+ const name = node.getAttribute('aria-label') ?? node.textContent ?? '';
94
+ return name.trim() === text.trim();
95
+ }
96
+
97
+ // A tooltip's control has its own action, so the app passes its own
98
+ // onclick beside the spread. Bits UI's handlers move into an attachment,
99
+ // so the spread order cannot drop either set: the control's onclick still
100
+ // runs, and the tooltip still closes on a press and opens on hover and
101
+ // focus. Bits UI's `disabled: false` and `tabindex: 0` are left out, so
102
+ // they never undo a disabled control (a disabled link Button keeps
103
+ // tabindex -1); the control is focusable on its own.
104
+ function triggerProps(props: Record<string, unknown>): Record<string, unknown> {
105
+ const { disabled: _disabled, tabindex: _tabindex, 'aria-describedby': describedBy, ...attrs } = props;
106
+ const listeners: [string, EventListener][] = [];
107
+ for (const [key, value] of Object.entries(attrs)) {
108
+ if (key.startsWith('on') && typeof value === 'function') {
109
+ listeners.push([key.slice(2), value as EventListener]);
110
+ delete attrs[key];
111
+ }
112
+ }
113
+ if (describedBy && !repeatsName(control)) attrs['aria-describedby'] = describedBy;
114
+ return {
115
+ ...attrs,
116
+ [capture]: captureControl,
117
+ [listen]: (node: Element) => {
118
+ for (const [type, fn] of listeners) node.addEventListener(type, fn);
119
+ return () => {
120
+ for (const [type, fn] of listeners) node.removeEventListener(type, fn);
121
+ };
122
+ }
123
+ };
124
+ }
125
+ </script>
126
+
127
+ <!--
128
+ Each tooltip outside a TooltipGroup has its own Bits UI provider, so an app
129
+ needs no shared wrapper. A resting mouse pointer opens it after the delay;
130
+ keyboard focus opens it at once, but only focus that shows the focus
131
+ contour, so focus returned to the trigger after a mouse press opens
132
+ nothing. Touch never opens it: a tap runs the control's own action.
133
+ Escape, moving the pointer away, blur and a press on the trigger close it.
134
+ While open, Bits UI points the trigger's aria-describedby at the tooltip,
135
+ unless the text repeats the control's name. The root carries the delay
136
+ and the focus rule, so they hold inside a group too.
137
+ -->
138
+ {#snippet tooltip()}
139
+ <BitsTooltip.Root bind:open {delayDuration} ignoreNonKeyboardFocus>
140
+ <BitsTooltip.Trigger>
141
+ {#snippet child({ props })}
142
+ {@render trigger({ props: triggerProps(props) })}
143
+ {/snippet}
144
+ </BitsTooltip.Trigger>
145
+
146
+ <BitsTooltip.Portal>
147
+ <BitsTooltip.Content forceMount bind:ref side="top" align="center" sideOffset={8} collisionPadding={8} {...rest}>
148
+ {#snippet child({ wrapperProps, props, open: shown })}
149
+ {#if shown}
150
+ <div {...wrapperProps}>
151
+ <div {...props} role="tooltip" class={['hp-tooltip', className]} transition:pop>{text}</div>
152
+ </div>
153
+ {/if}
154
+ {/snippet}
155
+ </BitsTooltip.Content>
156
+ </BitsTooltip.Portal>
157
+ </BitsTooltip.Root>
158
+ {/snippet}
159
+
160
+ {#if grouped}
161
+ {@render tooltip()}
162
+ {:else}
163
+ <BitsTooltip.Provider>
164
+ {@render tooltip()}
165
+ </BitsTooltip.Provider>
166
+ {/if}
167
+
168
+ <style>
169
+ /* On the tooltip layer (tokens.css), over popovers, menus and dialogs.
170
+ Bits UI copies the z-index to the positioned wrapper. A black box with
171
+ the control boundary, like the popover, as wide as its text up to
172
+ --tooltip-width (an app passes style="--tooltip-width: …" for another
173
+ cap) and never wider than the space beside the trigger. */
174
+ .hp-tooltip {
175
+ --tooltip-width: 280px;
176
+
177
+ position: relative;
178
+ z-index: var(--layer-tooltip);
179
+ box-sizing: border-box;
180
+ width: max-content;
181
+ max-width: min(var(--tooltip-width), var(--bits-tooltip-content-available-width));
182
+ padding: var(--space-1) var(--space-2);
183
+ border: 1px solid var(--border-control);
184
+ background: var(--canvas);
185
+ color: var(--text);
186
+ font-family: var(--sans);
187
+ font-size: var(--type-body);
188
+ line-height: 24px;
189
+ overflow-wrap: break-word;
190
+ }
191
+
192
+ /* 16px where a coarse pointer reads it (keyboard focus on a tablet), the
193
+ dense 14px on fine pointers. */
194
+ @media (pointer: fine) {
195
+ .hp-tooltip {
196
+ font-size: var(--type-dense);
197
+ line-height: 20px;
198
+ }
199
+ }
200
+ </style>
@@ -0,0 +1,38 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { Tooltip as BitsTooltip } from 'bits-ui';
3
+ export type TooltipTriggerSnippetProps = {
4
+ /**
5
+ * Spread onto the control the tooltip belongs to. Its own handlers may
6
+ * come before or after. A control with its own description writes
7
+ * `aria-describedby={[props['aria-describedby'], ownId].filter(Boolean).join(' ')}`
8
+ * after the spread, so it keeps both.
9
+ */
10
+ props: Record<string, unknown>;
11
+ };
12
+ /**
13
+ * Attributes and handlers not listed here go to the tooltip element (Bits
14
+ * UI `Tooltip.Content`), among them `side` (default `top`), `align`
15
+ * (default `center`), `sideOffset` (default 8) and `onEscapeKeydown`.
16
+ */
17
+ export type TooltipProps = Omit<BitsTooltip.ContentProps, 'child' | 'children' | 'forceMount' | 'ref'> & {
18
+ open?: boolean;
19
+ /**
20
+ * The tooltip, as written. It never opens on touch, so it is never the
21
+ * only path to its text: the control shows the same text, or carries it
22
+ * in `aria-label`.
23
+ */
24
+ text: string;
25
+ /** The control the tooltip belongs to: spread `props` onto it. */
26
+ trigger: Snippet<[TooltipTriggerSnippetProps]>;
27
+ /**
28
+ * Milliseconds a pointer rests on the trigger before the tooltip opens
29
+ * (default 700, Bits UI's, or the TooltipGroup's). Keyboard focus opens
30
+ * it at once.
31
+ */
32
+ delayDuration?: number;
33
+ /** The tooltip element. */
34
+ ref?: HTMLElement | null;
35
+ };
36
+ declare const Tooltip: import("svelte").Component<TooltipProps, {}, "ref" | "open">;
37
+ type Tooltip = ReturnType<typeof Tooltip>;
38
+ export default Tooltip;
@@ -0,0 +1,46 @@
1
+ <script lang="ts" module>
2
+ import { createContext, type Snippet } from 'svelte';
3
+
4
+ /**
5
+ * Wraps a row of controls whose tooltips belong together, such as a
6
+ * toolbar. The group renders no element of its own: it sets up one Bits UI
7
+ * `Tooltip.Provider` that every `Tooltip` inside it shares, so once one
8
+ * tooltip in the row has opened, the next opens at once while the pointer
9
+ * moves along the row. Lay out the controls with your own markup inside it.
10
+ */
11
+ export type TooltipGroupProps = {
12
+ /** The controls and their tooltips. */
13
+ children: Snippet;
14
+ /**
15
+ * Milliseconds a pointer rests on a control before the first tooltip
16
+ * opens (default 700, Bits UI's, as for a Tooltip outside a group). A
17
+ * Tooltip's own `delayDuration` wins over it.
18
+ */
19
+ delayDuration?: number;
20
+ /**
21
+ * Milliseconds after a tooltip in the group closes during which the next
22
+ * one opens without the delay (default 300, Bits UI's). 0 makes every
23
+ * tooltip wait the full delay.
24
+ */
25
+ skipDelayDuration?: number;
26
+ };
27
+
28
+ const [, setGroup, hasGroup] = createContext<true>();
29
+
30
+ /** A Tooltip inside a group uses the group's provider instead of its own. */
31
+ export function inTooltipGroup(): boolean {
32
+ return hasGroup();
33
+ }
34
+ </script>
35
+
36
+ <script lang="ts">
37
+ import { Tooltip } from 'bits-ui';
38
+
39
+ let { children, delayDuration, skipDelayDuration }: TooltipGroupProps = $props();
40
+
41
+ setGroup(true);
42
+ </script>
43
+
44
+ <Tooltip.Provider {delayDuration} {skipDelayDuration}>
45
+ {@render children()}
46
+ </Tooltip.Provider>
@@ -0,0 +1,29 @@
1
+ import { type Snippet } from 'svelte';
2
+ /**
3
+ * Wraps a row of controls whose tooltips belong together, such as a
4
+ * toolbar. The group renders no element of its own: it sets up one Bits UI
5
+ * `Tooltip.Provider` that every `Tooltip` inside it shares, so once one
6
+ * tooltip in the row has opened, the next opens at once while the pointer
7
+ * moves along the row. Lay out the controls with your own markup inside it.
8
+ */
9
+ export type TooltipGroupProps = {
10
+ /** The controls and their tooltips. */
11
+ children: Snippet;
12
+ /**
13
+ * Milliseconds a pointer rests on a control before the first tooltip
14
+ * opens (default 700, Bits UI's, as for a Tooltip outside a group). A
15
+ * Tooltip's own `delayDuration` wins over it.
16
+ */
17
+ delayDuration?: number;
18
+ /**
19
+ * Milliseconds after a tooltip in the group closes during which the next
20
+ * one opens without the delay (default 300, Bits UI's). 0 makes every
21
+ * tooltip wait the full delay.
22
+ */
23
+ skipDelayDuration?: number;
24
+ };
25
+ /** A Tooltip inside a group uses the group's provider instead of its own. */
26
+ export declare function inTooltipGroup(): boolean;
27
+ declare const TooltipGroup: import("svelte").Component<TooltipGroupProps, {}, "">;
28
+ type TooltipGroup = ReturnType<typeof TooltipGroup>;
29
+ export default TooltipGroup;
@@ -0,0 +1,52 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * The z-index token an overlay sits on (tokens.css, layer scale).
4
+ * `dialog`: dialogs, their sheets and the shell drawer. `popover`:
5
+ * popovers, menus, select lists and comboboxes, and their sheets on
6
+ * compact widths, so they open over a dialog.
7
+ */
8
+ export type OverlayLayer = 'dialog' | 'popover';
9
+
10
+ export type ScrimProps = {
11
+ /** Shown while true. Pass the Bits UI content's `open`. */
12
+ open: boolean;
13
+ layer: OverlayLayer;
14
+ };
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import { fade } from '../../motion/index.js';
19
+
20
+ let { open, layer }: ScrimProps = $props();
21
+
22
+ // A press on the scrim keeps focus where it is (on the trigger, or
23
+ // inside a dialog). The press still reaches Bits UI as an outside
24
+ // interaction, which closes the overlay.
25
+ function keepFocus(e: PointerEvent) {
26
+ e.preventDefault();
27
+ }
28
+ </script>
29
+
30
+ <!--
31
+ Dims the page behind a dialog or sheet. Render it just before the panel,
32
+ on the same layer, so the panel paints over it. It fades with the panel's
33
+ timing (--dur-sheet in, --dur-sheet-exit out).
34
+ -->
35
+ {#if open}
36
+ <div class="hp-scrim" data-layer={layer} aria-hidden="true" onpointerdown={keepFocus} transition:fade></div>
37
+ {/if}
38
+
39
+ <style>
40
+ /* While a floating list is open Bits UI sets pointer-events: none on
41
+ <body>; the scrim takes presses again, so a tap on it closes. */
42
+ .hp-scrim {
43
+ position: fixed;
44
+ inset: 0;
45
+ z-index: var(--layer-dialog);
46
+ background: var(--scrim);
47
+ pointer-events: auto;
48
+ }
49
+ .hp-scrim[data-layer='popover'] {
50
+ z-index: var(--layer-popover);
51
+ }
52
+ </style>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The z-index token an overlay sits on (tokens.css, layer scale).
3
+ * `dialog`: dialogs, their sheets and the shell drawer. `popover`:
4
+ * popovers, menus, select lists and comboboxes, and their sheets on
5
+ * compact widths, so they open over a dialog.
6
+ */
7
+ export type OverlayLayer = 'dialog' | 'popover';
8
+ export type ScrimProps = {
9
+ /** Shown while true. Pass the Bits UI content's `open`. */
10
+ open: boolean;
11
+ layer: OverlayLayer;
12
+ };
13
+ declare const Scrim: import("svelte").Component<ScrimProps, {}, "">;
14
+ type Scrim = ReturnType<typeof Scrim>;
15
+ export default Scrim;
@@ -0,0 +1,263 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { OverlayLayer } from './Scrim.svelte';
5
+
6
+ /** Attributes and handlers not listed here go to the panel. */
7
+ export type SheetProps = Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children'> & {
8
+ /**
9
+ * Shown while true. Pass the `open` that the Bits UI content's
10
+ * `child` snippet receives (render the content with `forceMount`), so
11
+ * the sheet and its scrim slide and fade out before they unmount.
12
+ */
13
+ open: boolean;
14
+ /** The title row's text, in tracked caps. */
15
+ title: string;
16
+ /** The title element's `id`, so the content can be labelled by it. */
17
+ titleId?: string;
18
+ /** The title element: `p`, or `h2` when the sheet is a dialog. */
19
+ titleTag?: 'p' | 'h2';
20
+ /** The z-index token: `dialog` for dialogs, `popover` for everything else. */
21
+ layer: OverlayLayer;
22
+ /**
23
+ * Called by the close button and by a swipe down. A tap on the scrim
24
+ * and Escape close through Bits UI.
25
+ */
26
+ onclose: () => void;
27
+ /** The close button's accessible name. */
28
+ closeLabel?: string;
29
+ /** The panel element. */
30
+ ref?: HTMLElement | null;
31
+ /** The body, under the title row. It scrolls if it does not fit. */
32
+ children: Snippet;
33
+ /** A row pinned under the body, such as a dialog's actions. */
34
+ footer?: Snippet;
35
+ };
36
+ </script>
37
+
38
+ <script lang="ts">
39
+ import { sheet } from '../../motion/index.js';
40
+ import Scrim from './Scrim.svelte';
41
+ import { swipeDismiss } from './swipe-dismiss.js';
42
+ import { followVisualViewport } from './visual-viewport.svelte.js';
43
+
44
+ let {
45
+ open,
46
+ title,
47
+ titleId,
48
+ titleTag = 'p',
49
+ layer,
50
+ onclose,
51
+ closeLabel = 'Close',
52
+ ref = $bindable(null),
53
+ children,
54
+ footer,
55
+ class: className,
56
+ ...rest
57
+ }: SheetProps = $props();
58
+
59
+ let body = $state<HTMLElement | null>(null);
60
+
61
+ // The sheet follows the visual viewport while it is open (decided
62
+ // 2026-11-10; internal/visual-viewport.svelte.ts): while the on-screen
63
+ // keyboard shortens it, the sheet's bottom edge sits on its bottom edge
64
+ // and the sheet may grow to its height less the header row's
65
+ // (--header-height and the top safe area), where it was half the screen.
66
+ // The focused field in the body stays in sight.
67
+ const viewport = followVisualViewport(() => open, () => body);
68
+
69
+ // The press that opened the sheet never acts on what the sheet now shows
70
+ // under the finger (decided 2026-11-10). On touch, Bits UI's Menu and
71
+ // Select triggers open on pointerup, and the click that follows lands on
72
+ // the sheet, which under reduced motion fades in where it ends up; a
73
+ // mouse opens them on pointerdown, and its pointerup on an item counts
74
+ // as a choice. So until a press starts inside the sheet, a pointerup or a
75
+ // pointer's click inside it stops here. A click from Enter, Space or
76
+ // element.click() has detail 0 and always passes.
77
+ function ignoreOpeningPress(node: HTMLElement) {
78
+ let pressed = false;
79
+ const press = () => (pressed = true);
80
+ const guard = (e: MouseEvent) => {
81
+ if (pressed || (e.type === 'click' && e.detail === 0)) return;
82
+ e.preventDefault();
83
+ e.stopImmediatePropagation();
84
+ };
85
+ const capture = { capture: true };
86
+ node.addEventListener('pointerdown', press, capture);
87
+ node.addEventListener('pointerup', guard, capture);
88
+ node.addEventListener('click', guard, capture);
89
+ return () => {
90
+ node.removeEventListener('pointerdown', press, capture);
91
+ node.removeEventListener('pointerup', guard, capture);
92
+ node.removeEventListener('click', guard, capture);
93
+ };
94
+ }
95
+ </script>
96
+
97
+ <!--
98
+ The bottom sheet every overlay uses on compact widths (BRIEF, Apps and
99
+ phones: Overlays): a scrim, then a panel at the bottom edge with a handle,
100
+ a caps title, a close button, the body and an optional footer. It opens at
101
+ half the screen at most, with no drag to expand; the body scrolls inside.
102
+ A swipe down dismisses it (internal/swipe-dismiss.ts). While the visual
103
+ viewport is shorter than the layout viewport (the on-screen keyboard), it
104
+ sits on top of it and may fill the space above it. The press that opened
105
+ it does not act inside it.
106
+
107
+ Render it inside the Bits UI content's `child` snippet and spread the
108
+ snippet's `props` onto it when the panel is the content element (a
109
+ dialog), or onto an element in the body when the content is a list (a
110
+ menu, a select): then pass `onInteractOutside` to the content and call
111
+ `preventDefault()` for presses inside `ref`, so the title row does not
112
+ count as outside.
113
+ -->
114
+ <Scrim {open} {layer} />
115
+ {#if open}
116
+ {@const lifted = viewport.current}
117
+ <div
118
+ bind:this={ref}
119
+ {...rest}
120
+ class={['hp-sheet', className]}
121
+ data-layer={layer}
122
+ style:bottom={lifted ? `calc(100% - ${lifted.bottom}px)` : undefined}
123
+ style:padding-bottom={lifted ? '0px' : undefined}
124
+ style:--sheet-max-height={lifted
125
+ ? `calc(${lifted.height}px - var(--header-height) - env(safe-area-inset-top))`
126
+ : undefined}
127
+ transition:sheet
128
+ {@attach ignoreOpeningPress}
129
+ {@attach swipeDismiss(() => onclose())}
130
+ >
131
+ <div class="hp-sheet-head">
132
+ <span class="hp-sheet-handle" aria-hidden="true"></span>
133
+ <svelte:element this={titleTag} id={titleId} class="hp-sheet-title">{title}</svelte:element>
134
+ <button type="button" class="hp-sheet-close" aria-label={closeLabel} onclick={() => onclose()}>
135
+ <svg
136
+ viewBox="0 0 16 16"
137
+ fill="none"
138
+ stroke="currentColor"
139
+ stroke-width="1.5"
140
+ stroke-linecap="square"
141
+ aria-hidden="true"
142
+ >
143
+ <path d="M3 3l10 10M13 3L3 13" />
144
+ </svg>
145
+ </button>
146
+ </div>
147
+ <div class="hp-sheet-body" bind:this={body}>
148
+ {@render children()}
149
+ </div>
150
+ {#if footer}
151
+ <div class="hp-sheet-footer">
152
+ {@render footer()}
153
+ </div>
154
+ {/if}
155
+ </div>
156
+ {/if}
157
+
158
+ <style>
159
+ /* While a floating list is open Bits UI sets pointer-events: none on
160
+ <body>; the panel takes presses again. */
161
+ .hp-sheet {
162
+ /* The height cap: half the screen, or the room above the on-screen
163
+ keyboard while it is up (set inline). A list that must keep its
164
+ height while it filters asks for this. */
165
+ --sheet-max-height: 50dvh;
166
+
167
+ position: fixed;
168
+ inset: auto 0 0;
169
+ z-index: var(--layer-dialog);
170
+ display: flex;
171
+ flex-direction: column;
172
+ box-sizing: border-box;
173
+ /* Opens at half the screen at most; the body scrolls inside. */
174
+ max-height: var(--sheet-max-height);
175
+ padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
176
+ border-top: 1px solid var(--border-control);
177
+ background: var(--canvas);
178
+ color: var(--text);
179
+ overscroll-behavior: contain;
180
+ pointer-events: auto;
181
+ }
182
+ .hp-sheet[data-layer='popover'] {
183
+ z-index: var(--layer-popover);
184
+ }
185
+
186
+ /* The grab area: handle, title and close button. The swipe owns touches
187
+ here; the body below keeps its scroll until it reaches the top. */
188
+ .hp-sheet-head {
189
+ flex: none;
190
+ display: grid;
191
+ grid-template-columns: minmax(0, 1fr) auto;
192
+ align-items: start;
193
+ padding: 0 var(--space-1) 0 var(--gutter);
194
+ touch-action: none;
195
+ }
196
+ .hp-sheet-handle {
197
+ grid-column: 1 / -1;
198
+ justify-self: center;
199
+ width: var(--space-8);
200
+ height: var(--space-1);
201
+ margin-top: var(--space-2);
202
+ background: var(--border-control);
203
+ }
204
+ /* Tracked caps at the compact view-title size. A long title wraps (200
205
+ percent zoom); the padding centres its first line on the 44px close
206
+ button, so a single line sits where it always did. The clip stays: it
207
+ keeps the first glyph's antialiasing inside the text edge, as before. */
208
+ .hp-sheet-title {
209
+ overflow: hidden;
210
+ padding-block: calc((44px - 1lh) / 2);
211
+ font-size: var(--type-body);
212
+ font-weight: 500;
213
+ line-height: 24px;
214
+ letter-spacing: var(--label-tracking);
215
+ text-transform: var(--label-transform);
216
+ overflow-wrap: anywhere;
217
+ }
218
+ .hp-sheet-close {
219
+ display: grid;
220
+ place-items: center;
221
+ width: 44px;
222
+ height: 44px;
223
+ padding: 0;
224
+ border: 0;
225
+ background: transparent;
226
+ color: var(--text);
227
+ cursor: pointer;
228
+ -webkit-tap-highlight-color: transparent;
229
+ }
230
+ .hp-sheet-close svg {
231
+ width: var(--space-4);
232
+ height: var(--space-4);
233
+ }
234
+ .hp-sheet-close:active {
235
+ background: var(--surface-raised);
236
+ }
237
+ @media (hover: hover) and (pointer: fine) {
238
+ .hp-sheet-close {
239
+ transition: background-color var(--dur-hover) var(--settle);
240
+ }
241
+ .hp-sheet-close:hover {
242
+ background: var(--surface-hover);
243
+ }
244
+ .hp-sheet-close:active {
245
+ background: var(--surface-raised);
246
+ transition-duration: var(--dur-press);
247
+ }
248
+ }
249
+
250
+ /* A flex column, so a list inside can take the rest of the height
251
+ (flex: 1 1 auto; min-height: 0) and scroll on its own. */
252
+ .hp-sheet-body {
253
+ display: flex;
254
+ flex-direction: column;
255
+ flex: 1 1 auto;
256
+ min-height: 0;
257
+ overflow-y: auto;
258
+ overscroll-behavior: contain;
259
+ }
260
+ .hp-sheet-footer {
261
+ flex: none;
262
+ }
263
+ </style>
@@ -0,0 +1,36 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { OverlayLayer } from './Scrim.svelte';
4
+ /** Attributes and handlers not listed here go to the panel. */
5
+ export type SheetProps = Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children'> & {
6
+ /**
7
+ * Shown while true. Pass the `open` that the Bits UI content's
8
+ * `child` snippet receives (render the content with `forceMount`), so
9
+ * the sheet and its scrim slide and fade out before they unmount.
10
+ */
11
+ open: boolean;
12
+ /** The title row's text, in tracked caps. */
13
+ title: string;
14
+ /** The title element's `id`, so the content can be labelled by it. */
15
+ titleId?: string;
16
+ /** The title element: `p`, or `h2` when the sheet is a dialog. */
17
+ titleTag?: 'p' | 'h2';
18
+ /** The z-index token: `dialog` for dialogs, `popover` for everything else. */
19
+ layer: OverlayLayer;
20
+ /**
21
+ * Called by the close button and by a swipe down. A tap on the scrim
22
+ * and Escape close through Bits UI.
23
+ */
24
+ onclose: () => void;
25
+ /** The close button's accessible name. */
26
+ closeLabel?: string;
27
+ /** The panel element. */
28
+ ref?: HTMLElement | null;
29
+ /** The body, under the title row. It scrolls if it does not fit. */
30
+ children: Snippet;
31
+ /** A row pinned under the body, such as a dialog's actions. */
32
+ footer?: Snippet;
33
+ };
34
+ declare const Sheet: import("svelte").Component<SheetProps, {}, "ref">;
35
+ type Sheet = ReturnType<typeof Sheet>;
36
+ export default Sheet;