@mystravil/hardpoint 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +42 -38
  2. package/LICENSE +25 -25
  3. package/README.md +67 -64
  4. package/dist/base.css +625 -625
  5. package/dist/components/AppShell.svelte +698 -698
  6. package/dist/components/Button.svelte +174 -174
  7. package/dist/components/Checkbox.svelte +235 -235
  8. package/dist/components/CheckboxGroup.svelte +207 -207
  9. package/dist/components/Combobox.svelte +901 -901
  10. package/dist/components/Dialog.svelte +359 -359
  11. package/dist/components/Disclosure.svelte +201 -201
  12. package/dist/components/EmptyState.svelte +88 -88
  13. package/dist/components/ErrorState.svelte +135 -135
  14. package/dist/components/ImageViewer.svelte +764 -764
  15. package/dist/components/KeyValueList.svelte +114 -114
  16. package/dist/components/ListDetail.svelte +218 -218
  17. package/dist/components/LoadRegion.svelte +219 -219
  18. package/dist/components/MediaFrame.svelte +110 -110
  19. package/dist/components/MediaImage.svelte +256 -256
  20. package/dist/components/Menu.svelte +258 -258
  21. package/dist/components/MenuCheckboxItem.svelte +119 -119
  22. package/dist/components/MenuGroup.svelte +50 -50
  23. package/dist/components/MenuItem.svelte +94 -94
  24. package/dist/components/MenuRadioGroup.svelte +62 -62
  25. package/dist/components/MenuRadioItem.svelte +110 -110
  26. package/dist/components/MenuSeparator.svelte +28 -28
  27. package/dist/components/Meter.svelte +283 -283
  28. package/dist/components/NativeSelect.svelte +135 -135
  29. package/dist/components/Notice.svelte +262 -262
  30. package/dist/components/Popover.svelte +222 -222
  31. package/dist/components/Progress.svelte +157 -157
  32. package/dist/components/RadioGroup.svelte +440 -440
  33. package/dist/components/Select.svelte +471 -471
  34. package/dist/components/Skeleton.svelte +149 -149
  35. package/dist/components/Spinner.svelte +118 -118
  36. package/dist/components/Stat.svelte +253 -253
  37. package/dist/components/Steps.svelte +189 -189
  38. package/dist/components/Switch.svelte +222 -222
  39. package/dist/components/Table.svelte +504 -504
  40. package/dist/components/Tabs.svelte +246 -246
  41. package/dist/components/Tag.svelte +40 -40
  42. package/dist/components/TextArea.svelte +272 -272
  43. package/dist/components/TextField.svelte +256 -256
  44. package/dist/components/Timeline.svelte +156 -156
  45. package/dist/components/Toaster.svelte +411 -411
  46. package/dist/components/Tooltip.svelte +200 -200
  47. package/dist/components/TooltipGroup.svelte +46 -46
  48. package/dist/components/internal/Scrim.svelte +52 -52
  49. package/dist/components/internal/Sheet.svelte +263 -263
  50. package/dist/fonts.css +71 -71
  51. package/dist/tokens.css +283 -283
  52. package/package.json +81 -76
@@ -1,114 +1,114 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import type { HTMLAttributes } from 'svelte/elements';
4
-
5
- export type KeyValueItem = {
6
- /** The caps label in the first column. A fixed string the app defines. */
7
- key: string;
8
- /** The value in Geist Mono. */
9
- value: string | number;
10
- };
11
-
12
- /** Items may carry extra fields (an `href`, a status) that the `value` snippet reads. */
13
- export type KeyValueListProps<T extends KeyValueItem = KeyValueItem> = Omit<
14
- HTMLAttributes<HTMLDListElement>,
15
- 'children'
16
- > & {
17
- items: T[];
18
- /**
19
- * The label column's width from 600px up: any CSS width. Defaults to
20
- * `9rem`. Below 600px the label stacks over the value.
21
- */
22
- keyWidth?: string;
23
- /** Renders one value, for a link or a status. Default: the text. */
24
- value?: Snippet<[T]>;
25
- };
26
- </script>
27
-
28
- <script lang="ts" generics="T extends KeyValueItem = KeyValueItem">
29
- import { onMount, untrack } from 'svelte';
30
- import { useMotion } from '../motion/index.js';
31
-
32
- let { items, keyWidth, value, class: className, ...rest }: KeyValueListProps<T> = $props();
33
-
34
- // Expressive stagger (motion rule 7), as in Table: only the items of the
35
- // first render enter, by their index. Any change to `items` (a new array
36
- // or a changed length) ends the entrance, and so does its last animation.
37
- const motion = useMotion();
38
- const firstItems = untrack(() => items);
39
- const firstCount = untrack(() => items.length);
40
- let entering = $state.raw(motion.expressive);
41
- let root: HTMLDListElement;
42
-
43
- $effect.pre(() => {
44
- if (items !== firstItems || items.length !== firstCount) entering = false;
45
- });
46
-
47
- onMount(() => {
48
- if (!entering) return;
49
- const running = root
50
- .getAnimations({ subtree: true })
51
- .filter((a) => a instanceof CSSAnimation && a.animationName === 'hp-enter');
52
- Promise.allSettled(running.map((a) => a.finished)).then(() => {
53
- entering = false;
54
- });
55
- });
56
- </script>
57
-
58
- <!--
59
- A caps label column and a Geist Mono value column, without separators
60
- (BRIEF, visual rule 6; da3-53). Compact widths stack each label over its
61
- value. Extra attributes and `class` go to the <dl>.
62
- -->
63
- <dl
64
- {...rest}
65
- bind:this={root}
66
- class={['hp-key-value-list', className]}
67
- style:--key-value-list-key-width={keyWidth}
68
- >
69
- {#each items as item, i}
70
- <div class={[entering && 'hp-enter']} style:--i={entering ? i : undefined}>
71
- <dt class="hp-label">{item.key}</dt>
72
- <dd>{#if value}{@render value(item)}{:else}{item.value}{/if}</dd>
73
- </div>
74
- {/each}
75
- </dl>
76
-
77
- <style>
78
- .hp-key-value-list {
79
- display: grid;
80
- gap: var(--space-3);
81
- }
82
- div {
83
- display: grid;
84
- /* matches the approved specimen */
85
- gap: 2px;
86
- }
87
- dt {
88
- color: var(--muted);
89
- }
90
- /* 16px on touch, the dense 14px on fine pointers. A long identifier or
91
- path breaks anywhere rather than widen the page. */
92
- dd {
93
- font-family: var(--mono);
94
- font-size: var(--type-body);
95
- line-height: 24px;
96
- overflow-wrap: anywhere;
97
- }
98
- @media (pointer: fine) {
99
- dd {
100
- font-size: var(--type-dense);
101
- line-height: 20px;
102
- }
103
- }
104
- @media (min-width: 600px) {
105
- .hp-key-value-list {
106
- gap: var(--space-2);
107
- }
108
- div {
109
- grid-template-columns: var(--key-value-list-key-width, 9rem) minmax(0, 1fr);
110
- align-items: baseline;
111
- gap: var(--space-4);
112
- }
113
- }
114
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ export type KeyValueItem = {
6
+ /** The caps label in the first column. A fixed string the app defines. */
7
+ key: string;
8
+ /** The value in Geist Mono. */
9
+ value: string | number;
10
+ };
11
+
12
+ /** Items may carry extra fields (an `href`, a status) that the `value` snippet reads. */
13
+ export type KeyValueListProps<T extends KeyValueItem = KeyValueItem> = Omit<
14
+ HTMLAttributes<HTMLDListElement>,
15
+ 'children'
16
+ > & {
17
+ items: T[];
18
+ /**
19
+ * The label column's width from 600px up: any CSS width. Defaults to
20
+ * `9rem`. Below 600px the label stacks over the value.
21
+ */
22
+ keyWidth?: string;
23
+ /** Renders one value, for a link or a status. Default: the text. */
24
+ value?: Snippet<[T]>;
25
+ };
26
+ </script>
27
+
28
+ <script lang="ts" generics="T extends KeyValueItem = KeyValueItem">
29
+ import { onMount, untrack } from 'svelte';
30
+ import { useMotion } from '../motion/index.js';
31
+
32
+ let { items, keyWidth, value, class: className, ...rest }: KeyValueListProps<T> = $props();
33
+
34
+ // Expressive stagger (motion rule 7), as in Table: only the items of the
35
+ // first render enter, by their index. Any change to `items` (a new array
36
+ // or a changed length) ends the entrance, and so does its last animation.
37
+ const motion = useMotion();
38
+ const firstItems = untrack(() => items);
39
+ const firstCount = untrack(() => items.length);
40
+ let entering = $state.raw(motion.expressive);
41
+ let root: HTMLDListElement;
42
+
43
+ $effect.pre(() => {
44
+ if (items !== firstItems || items.length !== firstCount) entering = false;
45
+ });
46
+
47
+ onMount(() => {
48
+ if (!entering) return;
49
+ const running = root
50
+ .getAnimations({ subtree: true })
51
+ .filter((a) => a instanceof CSSAnimation && a.animationName === 'hp-enter');
52
+ Promise.allSettled(running.map((a) => a.finished)).then(() => {
53
+ entering = false;
54
+ });
55
+ });
56
+ </script>
57
+
58
+ <!--
59
+ A caps label column and a Geist Mono value column, without separators
60
+ (BRIEF, visual rule 6; da3-53). Compact widths stack each label over its
61
+ value. Extra attributes and `class` go to the <dl>.
62
+ -->
63
+ <dl
64
+ {...rest}
65
+ bind:this={root}
66
+ class={['hp-key-value-list', className]}
67
+ style:--key-value-list-key-width={keyWidth}
68
+ >
69
+ {#each items as item, i}
70
+ <div class={[entering && 'hp-enter']} style:--i={entering ? i : undefined}>
71
+ <dt class="hp-label">{item.key}</dt>
72
+ <dd>{#if value}{@render value(item)}{:else}{item.value}{/if}</dd>
73
+ </div>
74
+ {/each}
75
+ </dl>
76
+
77
+ <style>
78
+ .hp-key-value-list {
79
+ display: grid;
80
+ gap: var(--space-3);
81
+ }
82
+ div {
83
+ display: grid;
84
+ /* matches the approved specimen */
85
+ gap: 2px;
86
+ }
87
+ dt {
88
+ color: var(--muted);
89
+ }
90
+ /* 16px on touch, the dense 14px on fine pointers. A long identifier or
91
+ path breaks anywhere rather than widen the page. */
92
+ dd {
93
+ font-family: var(--mono);
94
+ font-size: var(--type-body);
95
+ line-height: 24px;
96
+ overflow-wrap: anywhere;
97
+ }
98
+ @media (pointer: fine) {
99
+ dd {
100
+ font-size: var(--type-dense);
101
+ line-height: 20px;
102
+ }
103
+ }
104
+ @media (min-width: 600px) {
105
+ .hp-key-value-list {
106
+ gap: var(--space-2);
107
+ }
108
+ div {
109
+ grid-template-columns: var(--key-value-list-key-width, 9rem) minmax(0, 1fr);
110
+ align-items: baseline;
111
+ gap: var(--space-4);
112
+ }
113
+ }
114
+ </style>
@@ -1,218 +1,218 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import type { HTMLAttributes } from 'svelte/elements';
4
-
5
- /** From which width the panes sit side by side: 600px, 900px or 1200px. */
6
- export type ListDetailBreakpoint = 'medium' | 'expanded' | 'large';
7
-
8
- /** What the `list` and `detail` snippets receive. */
9
- export type ListDetailSnippetProps = {
10
- /**
11
- * True below `splitFrom`, where one pane shows at a time. Show the
12
- * detail's back control only then.
13
- */
14
- stacked: boolean;
15
- };
16
-
17
- export type ListDetailProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
18
- /** The list pane's content: the items, and any filter over them. */
19
- list: Snippet<[ListDetailSnippetProps]>;
20
- /**
21
- * The detail pane's content: the open item. Side by side with nothing
22
- * open, render an empty state here.
23
- */
24
- detail: Snippet<[ListDetailSnippetProps]>;
25
- /**
26
- * Which pane shows while stacked: the detail when true, the list when
27
- * false (default). Side by side both show and this changes nothing.
28
- * Bindable.
29
- */
30
- showDetail?: boolean;
31
- /**
32
- * From which width the panes sit side by side; narrower, one shows at
33
- * a time. Default `expanded` (900px).
34
- */
35
- splitFrom?: ListDetailBreakpoint;
36
- /** The root element. */
37
- ref?: HTMLDivElement | null;
38
- };
39
- </script>
40
-
41
- <script lang="ts">
42
- import { untrack } from 'svelte';
43
- import { MediaQuery } from 'svelte/reactivity';
44
-
45
- let {
46
- list,
47
- detail,
48
- showDetail = $bindable(false),
49
- splitFrom = 'expanded',
50
- ref = $bindable(null),
51
- class: className,
52
- ...rest
53
- }: ListDetailProps = $props();
54
-
55
- // The CSS below lays the panes out from the same widths before any script
56
- // runs; this query only tells the snippets and the focus rules.
57
- const widths: Record<ListDetailBreakpoint, number> = { medium: 600, expanded: 900, large: 1200 };
58
- const split = $derived(new MediaQuery(`min-width: ${widths[splitFrom]}px`, true));
59
- const stacked = $derived(!split.current);
60
- /** The one pane on screen while stacked; null side by side. */
61
- const alone = $derived(stacked ? (showDetail ? 'detail' : 'list') : null);
62
-
63
- let listPane = $state<HTMLDivElement | null>(null);
64
- let detailPane = $state<HTMLDivElement | null>(null);
65
-
66
- // ---- The list keeps its place ------------------------------------------
67
-
68
- // A pane that is not shown is display: none, which drops its scroll box.
69
- // Some browsers then reset its offset, so the list remembers the offset it
70
- // had while it showed and puts it back when it shows again.
71
- let listTop = 0;
72
- function onListScroll() {
73
- if (listPane && listPane.getClientRects().length > 0) listTop = listPane.scrollTop;
74
- }
75
- $effect(() => {
76
- const showsList = alone !== 'detail';
77
- untrack(() => {
78
- if (showsList && listPane && listPane.scrollTop !== listTop) listPane.scrollTop = listTop;
79
- });
80
- });
81
-
82
- // ---- Focus follows the pane that shows ---------------------------------
83
-
84
- // When the stacked view switches panes, the app's own update runs first:
85
- // the check waits a task, past the app's effects and an `await tick()`,
86
- // so an app that focuses a title, an input or the item that was open
87
- // keeps that focus. Only if focus is then outside the pane on screen
88
- // does the pane itself take it. When the window narrows to one pane,
89
- // focus moves only if the pane that hid held it. Side by side the app
90
- // owns focus. A new switch before the check cancels it.
91
- let before: 'list' | 'detail' | null | undefined;
92
- $effect(() => {
93
- const now = alone;
94
- return untrack(() => {
95
- const was = before;
96
- before = now;
97
- if (was === undefined || now === null || was === now) return;
98
- const shown = now === 'list' ? listPane : detailPane;
99
- const hidden = now === 'list' ? detailPane : listPane;
100
- const timer = setTimeout(() => {
101
- if (!shown) return;
102
- const active = document.activeElement;
103
- if (active && shown.contains(active)) return;
104
- const lost = !active || active === document.body || !!hidden?.contains(active);
105
- if (was === null && !lost) return;
106
- shown.focus({ preventScroll: true });
107
- });
108
- return () => clearTimeout(timer);
109
- });
110
- });
111
- </script>
112
-
113
- <!--
114
- The list-detail layout: a list of items beside the open item. From
115
- `splitFrom` (900px by default) the list is a column of
116
- `--list-detail-width` at the left and the detail fills the rest; below
117
- it the two are separate screens and `showDetail` picks the one that
118
- shows. The other pane stays mounted, out of the layout, the
119
- accessibility tree and the tab order, so its state and the list's scroll
120
- position survive. Switching cuts, as page changes do (motion rule 1).
121
-
122
- The root fills the height its container gives it, or
123
- `--list-detail-height`; each pane scrolls inside itself. The panes pad
124
- their content by `--gutter`, and the root keeps the side safe areas, so
125
- the layout is built to reach the window's edges under the app's header.
126
- No frame and no divider: the gutters between the panes group them.
127
-
128
- Extra attributes and `class` go to the root, `div.hp-list-detail`, which
129
- carries `data-split-from`, `data-stacked` (below `splitFrom`) and
130
- `data-show-detail`. The panes are `div.hp-list-detail-list` and
131
- `div.hp-list-detail-detail`, each with `tabindex="-1"`.
132
- -->
133
- <div
134
- {...rest}
135
- bind:this={ref}
136
- class={['hp-list-detail', className]}
137
- data-split-from={splitFrom}
138
- data-stacked={stacked || undefined}
139
- data-show-detail={showDetail || undefined}
140
- >
141
- <div bind:this={listPane} class="pane hp-list-detail-list" tabindex="-1" onscroll={onListScroll}>
142
- {@render list({ stacked })}
143
- </div>
144
- <div bind:this={detailPane} class="pane hp-list-detail-detail" tabindex="-1">
145
- {@render detail({ stacked })}
146
- </div>
147
- </div>
148
-
149
- <style>
150
- /* A row of two panes. The side safe areas sit outside them, so a tint
151
- that reaches into a gutter stops short of a notch. min-height: 0 lets
152
- the root shrink as a flex or grid item. */
153
- .hp-list-detail {
154
- display: flex;
155
- box-sizing: border-box;
156
- height: var(--list-detail-height, 100%);
157
- min-height: 0;
158
- padding: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left);
159
- }
160
-
161
- /* Each pane is a scroller of its own. The page edges come from
162
- --gutter, as everywhere; the bottom safe area keeps the last item
163
- clear of the home indicator. A stable scrollbar gutter keeps the
164
- content still when filtering or a new item changes whether a pane
165
- overflows. */
166
- .pane {
167
- flex: 1 1 0;
168
- min-width: 0;
169
- box-sizing: border-box;
170
- padding: var(--space-4) var(--gutter) calc(var(--space-4) + env(safe-area-inset-bottom));
171
- overflow-y: auto;
172
- overscroll-behavior: contain;
173
- scrollbar-gutter: stable;
174
- }
175
- /* Focus lands on a pane only from the switch above, as <main> takes it
176
- from a skip link: no contour, since the pane is not a control. */
177
- .pane:focus {
178
- outline: none;
179
- }
180
-
181
- /* ---- Stacked: one pane at a time --------------------------------- */
182
-
183
- .hp-list-detail[data-show-detail] > .hp-list-detail-list,
184
- .hp-list-detail:not([data-show-detail]) > .hp-list-detail-detail {
185
- display: none;
186
- }
187
-
188
- /* ---- Side by side: from splitFrom -------------------------------- */
189
-
190
- /* Later and as specific as the rules above, so both panes show. */
191
- @media (min-width: 600px) {
192
- .hp-list-detail[data-split-from='medium'] > .pane {
193
- display: block;
194
- }
195
- .hp-list-detail[data-split-from='medium'] > .hp-list-detail-list {
196
- flex: none;
197
- width: var(--list-detail-width);
198
- }
199
- }
200
- @media (min-width: 900px) {
201
- .hp-list-detail[data-split-from='expanded'] > .pane {
202
- display: block;
203
- }
204
- .hp-list-detail[data-split-from='expanded'] > .hp-list-detail-list {
205
- flex: none;
206
- width: var(--list-detail-width);
207
- }
208
- }
209
- @media (min-width: 1200px) {
210
- .hp-list-detail[data-split-from='large'] > .pane {
211
- display: block;
212
- }
213
- .hp-list-detail[data-split-from='large'] > .hp-list-detail-list {
214
- flex: none;
215
- width: var(--list-detail-width);
216
- }
217
- }
218
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ /** From which width the panes sit side by side: 600px, 900px or 1200px. */
6
+ export type ListDetailBreakpoint = 'medium' | 'expanded' | 'large';
7
+
8
+ /** What the `list` and `detail` snippets receive. */
9
+ export type ListDetailSnippetProps = {
10
+ /**
11
+ * True below `splitFrom`, where one pane shows at a time. Show the
12
+ * detail's back control only then.
13
+ */
14
+ stacked: boolean;
15
+ };
16
+
17
+ export type ListDetailProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
18
+ /** The list pane's content: the items, and any filter over them. */
19
+ list: Snippet<[ListDetailSnippetProps]>;
20
+ /**
21
+ * The detail pane's content: the open item. Side by side with nothing
22
+ * open, render an empty state here.
23
+ */
24
+ detail: Snippet<[ListDetailSnippetProps]>;
25
+ /**
26
+ * Which pane shows while stacked: the detail when true, the list when
27
+ * false (default). Side by side both show and this changes nothing.
28
+ * Bindable.
29
+ */
30
+ showDetail?: boolean;
31
+ /**
32
+ * From which width the panes sit side by side; narrower, one shows at
33
+ * a time. Default `expanded` (900px).
34
+ */
35
+ splitFrom?: ListDetailBreakpoint;
36
+ /** The root element. */
37
+ ref?: HTMLDivElement | null;
38
+ };
39
+ </script>
40
+
41
+ <script lang="ts">
42
+ import { untrack } from 'svelte';
43
+ import { MediaQuery } from 'svelte/reactivity';
44
+
45
+ let {
46
+ list,
47
+ detail,
48
+ showDetail = $bindable(false),
49
+ splitFrom = 'expanded',
50
+ ref = $bindable(null),
51
+ class: className,
52
+ ...rest
53
+ }: ListDetailProps = $props();
54
+
55
+ // The CSS below lays the panes out from the same widths before any script
56
+ // runs; this query only tells the snippets and the focus rules.
57
+ const widths: Record<ListDetailBreakpoint, number> = { medium: 600, expanded: 900, large: 1200 };
58
+ const split = $derived(new MediaQuery(`min-width: ${widths[splitFrom]}px`, true));
59
+ const stacked = $derived(!split.current);
60
+ /** The one pane on screen while stacked; null side by side. */
61
+ const alone = $derived(stacked ? (showDetail ? 'detail' : 'list') : null);
62
+
63
+ let listPane = $state<HTMLDivElement | null>(null);
64
+ let detailPane = $state<HTMLDivElement | null>(null);
65
+
66
+ // ---- The list keeps its place ------------------------------------------
67
+
68
+ // A pane that is not shown is display: none, which drops its scroll box.
69
+ // Some browsers then reset its offset, so the list remembers the offset it
70
+ // had while it showed and puts it back when it shows again.
71
+ let listTop = 0;
72
+ function onListScroll() {
73
+ if (listPane && listPane.getClientRects().length > 0) listTop = listPane.scrollTop;
74
+ }
75
+ $effect(() => {
76
+ const showsList = alone !== 'detail';
77
+ untrack(() => {
78
+ if (showsList && listPane && listPane.scrollTop !== listTop) listPane.scrollTop = listTop;
79
+ });
80
+ });
81
+
82
+ // ---- Focus follows the pane that shows ---------------------------------
83
+
84
+ // When the stacked view switches panes, the app's own update runs first:
85
+ // the check waits a task, past the app's effects and an `await tick()`,
86
+ // so an app that focuses a title, an input or the item that was open
87
+ // keeps that focus. Only if focus is then outside the pane on screen
88
+ // does the pane itself take it. When the window narrows to one pane,
89
+ // focus moves only if the pane that hid held it. Side by side the app
90
+ // owns focus. A new switch before the check cancels it.
91
+ let before: 'list' | 'detail' | null | undefined;
92
+ $effect(() => {
93
+ const now = alone;
94
+ return untrack(() => {
95
+ const was = before;
96
+ before = now;
97
+ if (was === undefined || now === null || was === now) return;
98
+ const shown = now === 'list' ? listPane : detailPane;
99
+ const hidden = now === 'list' ? detailPane : listPane;
100
+ const timer = setTimeout(() => {
101
+ if (!shown) return;
102
+ const active = document.activeElement;
103
+ if (active && shown.contains(active)) return;
104
+ const lost = !active || active === document.body || !!hidden?.contains(active);
105
+ if (was === null && !lost) return;
106
+ shown.focus({ preventScroll: true });
107
+ });
108
+ return () => clearTimeout(timer);
109
+ });
110
+ });
111
+ </script>
112
+
113
+ <!--
114
+ The list-detail layout: a list of items beside the open item. From
115
+ `splitFrom` (900px by default) the list is a column of
116
+ `--list-detail-width` at the left and the detail fills the rest; below
117
+ it the two are separate screens and `showDetail` picks the one that
118
+ shows. The other pane stays mounted, out of the layout, the
119
+ accessibility tree and the tab order, so its state and the list's scroll
120
+ position survive. Switching cuts, as page changes do (motion rule 1).
121
+
122
+ The root fills the height its container gives it, or
123
+ `--list-detail-height`; each pane scrolls inside itself. The panes pad
124
+ their content by `--gutter`, and the root keeps the side safe areas, so
125
+ the layout is built to reach the window's edges under the app's header.
126
+ No frame and no divider: the gutters between the panes group them.
127
+
128
+ Extra attributes and `class` go to the root, `div.hp-list-detail`, which
129
+ carries `data-split-from`, `data-stacked` (below `splitFrom`) and
130
+ `data-show-detail`. The panes are `div.hp-list-detail-list` and
131
+ `div.hp-list-detail-detail`, each with `tabindex="-1"`.
132
+ -->
133
+ <div
134
+ {...rest}
135
+ bind:this={ref}
136
+ class={['hp-list-detail', className]}
137
+ data-split-from={splitFrom}
138
+ data-stacked={stacked || undefined}
139
+ data-show-detail={showDetail || undefined}
140
+ >
141
+ <div bind:this={listPane} class="pane hp-list-detail-list" tabindex="-1" onscroll={onListScroll}>
142
+ {@render list({ stacked })}
143
+ </div>
144
+ <div bind:this={detailPane} class="pane hp-list-detail-detail" tabindex="-1">
145
+ {@render detail({ stacked })}
146
+ </div>
147
+ </div>
148
+
149
+ <style>
150
+ /* A row of two panes. The side safe areas sit outside them, so a tint
151
+ that reaches into a gutter stops short of a notch. min-height: 0 lets
152
+ the root shrink as a flex or grid item. */
153
+ .hp-list-detail {
154
+ display: flex;
155
+ box-sizing: border-box;
156
+ height: var(--list-detail-height, 100%);
157
+ min-height: 0;
158
+ padding: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left);
159
+ }
160
+
161
+ /* Each pane is a scroller of its own. The page edges come from
162
+ --gutter, as everywhere; the bottom safe area keeps the last item
163
+ clear of the home indicator. A stable scrollbar gutter keeps the
164
+ content still when filtering or a new item changes whether a pane
165
+ overflows. */
166
+ .pane {
167
+ flex: 1 1 0;
168
+ min-width: 0;
169
+ box-sizing: border-box;
170
+ padding: var(--space-4) var(--gutter) calc(var(--space-4) + env(safe-area-inset-bottom));
171
+ overflow-y: auto;
172
+ overscroll-behavior: contain;
173
+ scrollbar-gutter: stable;
174
+ }
175
+ /* Focus lands on a pane only from the switch above, as <main> takes it
176
+ from a skip link: no contour, since the pane is not a control. */
177
+ .pane:focus {
178
+ outline: none;
179
+ }
180
+
181
+ /* ---- Stacked: one pane at a time --------------------------------- */
182
+
183
+ .hp-list-detail[data-show-detail] > .hp-list-detail-list,
184
+ .hp-list-detail:not([data-show-detail]) > .hp-list-detail-detail {
185
+ display: none;
186
+ }
187
+
188
+ /* ---- Side by side: from splitFrom -------------------------------- */
189
+
190
+ /* Later and as specific as the rules above, so both panes show. */
191
+ @media (min-width: 600px) {
192
+ .hp-list-detail[data-split-from='medium'] > .pane {
193
+ display: block;
194
+ }
195
+ .hp-list-detail[data-split-from='medium'] > .hp-list-detail-list {
196
+ flex: none;
197
+ width: var(--list-detail-width);
198
+ }
199
+ }
200
+ @media (min-width: 900px) {
201
+ .hp-list-detail[data-split-from='expanded'] > .pane {
202
+ display: block;
203
+ }
204
+ .hp-list-detail[data-split-from='expanded'] > .hp-list-detail-list {
205
+ flex: none;
206
+ width: var(--list-detail-width);
207
+ }
208
+ }
209
+ @media (min-width: 1200px) {
210
+ .hp-list-detail[data-split-from='large'] > .pane {
211
+ display: block;
212
+ }
213
+ .hp-list-detail[data-split-from='large'] > .hp-list-detail-list {
214
+ flex: none;
215
+ width: var(--list-detail-width);
216
+ }
217
+ }
218
+ </style>