@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,55 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { Dialog as BitsDialog } from 'bits-ui';
|
|
3
|
+
export type ImageViewerTriggerSnippetProps = {
|
|
4
|
+
/**
|
|
5
|
+
* Spread onto the button that opens the viewer, usually around a
|
|
6
|
+
* thumbnail, so the image opens from the keyboard too.
|
|
7
|
+
*/
|
|
8
|
+
props: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Attributes and handlers not listed here go to the viewer element (Bits
|
|
12
|
+
* UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
|
|
13
|
+
* `onEscapeKeydown` and `onInteractOutside`.
|
|
14
|
+
*/
|
|
15
|
+
export type ImageViewerProps = Omit<BitsDialog.ContentProps, 'child' | 'children' | 'forceMount' | 'ref' | 'title'> & {
|
|
16
|
+
open?: boolean;
|
|
17
|
+
/** The image's URL. A different URL while open starts a new load at scale 1. */
|
|
18
|
+
src: string;
|
|
19
|
+
/**
|
|
20
|
+
* The text alternative, required. It also names the viewer when there
|
|
21
|
+
* is no `title`, so it should describe the image, never be empty.
|
|
22
|
+
*/
|
|
23
|
+
alt: string;
|
|
24
|
+
/**
|
|
25
|
+
* A line in the top bar, shown as written (a file name, a caption) on
|
|
26
|
+
* one line, cut with an ellipsis. It names the viewer when set.
|
|
27
|
+
*/
|
|
28
|
+
title?: string;
|
|
29
|
+
/** The button that opens the viewer: spread `props` onto it. */
|
|
30
|
+
trigger?: Snippet<[ImageViewerTriggerSnippetProps]>;
|
|
31
|
+
/**
|
|
32
|
+
* Shows the Download button, which saves `src` under this file name
|
|
33
|
+
* through a link with `download`. A `data:` URL becomes a `blob:` URL
|
|
34
|
+
* first. Browsers ignore `download` on a link to another origin, so an
|
|
35
|
+
* image from another origin needs `ondownload`.
|
|
36
|
+
*/
|
|
37
|
+
downloadName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Shows the Download button and replaces the built-in save, for an app
|
|
40
|
+
* with its own save picker or confirmation. While its promise is
|
|
41
|
+
* pending, another press does nothing. Errors are the app's to catch.
|
|
42
|
+
*/
|
|
43
|
+
ondownload?: () => void | Promise<void>;
|
|
44
|
+
/** The close button's accessible name. */
|
|
45
|
+
closeLabel?: string;
|
|
46
|
+
/** The Download button's label. */
|
|
47
|
+
downloadLabel?: string;
|
|
48
|
+
/** The spinner's label while the image loads. */
|
|
49
|
+
loadingLabel?: string;
|
|
50
|
+
/** The viewer element. */
|
|
51
|
+
ref?: HTMLElement | null;
|
|
52
|
+
};
|
|
53
|
+
declare const ImageViewer: import("svelte").Component<ImageViewerProps, {}, "ref" | "open">;
|
|
54
|
+
type ImageViewer = ReturnType<typeof ImageViewer>;
|
|
55
|
+
export default ImageViewer;
|
|
@@ -0,0 +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>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
export type KeyValueItem = {
|
|
4
|
+
/** The caps label in the first column. A fixed string the app defines. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** The value in Geist Mono. */
|
|
7
|
+
value: string | number;
|
|
8
|
+
};
|
|
9
|
+
/** Items may carry extra fields (an `href`, a status) that the `value` snippet reads. */
|
|
10
|
+
export type KeyValueListProps<T extends KeyValueItem = KeyValueItem> = Omit<HTMLAttributes<HTMLDListElement>, 'children'> & {
|
|
11
|
+
items: T[];
|
|
12
|
+
/**
|
|
13
|
+
* The label column's width from 600px up: any CSS width. Defaults to
|
|
14
|
+
* `9rem`. Below 600px the label stacks over the value.
|
|
15
|
+
*/
|
|
16
|
+
keyWidth?: string;
|
|
17
|
+
/** Renders one value, for a link or a status. Default: the text. */
|
|
18
|
+
value?: Snippet<[T]>;
|
|
19
|
+
};
|
|
20
|
+
declare function $$render<T extends KeyValueItem = KeyValueItem>(): {
|
|
21
|
+
props: KeyValueListProps<T>;
|
|
22
|
+
exports: {};
|
|
23
|
+
bindings: "";
|
|
24
|
+
slots: {};
|
|
25
|
+
events: {};
|
|
26
|
+
};
|
|
27
|
+
declare class __sveltets_Render<T extends KeyValueItem = KeyValueItem> {
|
|
28
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
29
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
30
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
31
|
+
bindings(): "";
|
|
32
|
+
exports(): {};
|
|
33
|
+
}
|
|
34
|
+
interface $$IsomorphicComponent {
|
|
35
|
+
new <T extends KeyValueItem = KeyValueItem>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
36
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
37
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
38
|
+
<T extends KeyValueItem = KeyValueItem>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
39
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
40
|
+
}
|
|
41
|
+
declare const KeyValueList: $$IsomorphicComponent;
|
|
42
|
+
type KeyValueList<T extends KeyValueItem = KeyValueItem> = InstanceType<typeof KeyValueList<T>>;
|
|
43
|
+
export default KeyValueList;
|
|
@@ -0,0 +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>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
/** From which width the panes sit side by side: 600px, 900px or 1200px. */
|
|
4
|
+
export type ListDetailBreakpoint = 'medium' | 'expanded' | 'large';
|
|
5
|
+
/** What the `list` and `detail` snippets receive. */
|
|
6
|
+
export type ListDetailSnippetProps = {
|
|
7
|
+
/**
|
|
8
|
+
* True below `splitFrom`, where one pane shows at a time. Show the
|
|
9
|
+
* detail's back control only then.
|
|
10
|
+
*/
|
|
11
|
+
stacked: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type ListDetailProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
14
|
+
/** The list pane's content: the items, and any filter over them. */
|
|
15
|
+
list: Snippet<[ListDetailSnippetProps]>;
|
|
16
|
+
/**
|
|
17
|
+
* The detail pane's content: the open item. Side by side with nothing
|
|
18
|
+
* open, render an empty state here.
|
|
19
|
+
*/
|
|
20
|
+
detail: Snippet<[ListDetailSnippetProps]>;
|
|
21
|
+
/**
|
|
22
|
+
* Which pane shows while stacked: the detail when true, the list when
|
|
23
|
+
* false (default). Side by side both show and this changes nothing.
|
|
24
|
+
* Bindable.
|
|
25
|
+
*/
|
|
26
|
+
showDetail?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* From which width the panes sit side by side; narrower, one shows at
|
|
29
|
+
* a time. Default `expanded` (900px).
|
|
30
|
+
*/
|
|
31
|
+
splitFrom?: ListDetailBreakpoint;
|
|
32
|
+
/** The root element. */
|
|
33
|
+
ref?: HTMLDivElement | null;
|
|
34
|
+
};
|
|
35
|
+
declare const ListDetail: import("svelte").Component<ListDetailProps, {}, "ref" | "showDetail">;
|
|
36
|
+
type ListDetail = ReturnType<typeof ListDetail>;
|
|
37
|
+
export default ListDetail;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
|
|
5
|
+
export type LoadRegionProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'placeholder'> & {
|
|
6
|
+
/** True while the content loads: the region shows `placeholder` and is `aria-busy`. */
|
|
7
|
+
loading: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The text of the status line, such as `Loading tasks`, then `5 tasks`.
|
|
10
|
+
* The line is visually hidden, stays mounted outside the region and is
|
|
11
|
+
* a `role="status"` region, so each change is announced. Leave it empty
|
|
12
|
+
* while an error state announces itself.
|
|
13
|
+
*/
|
|
14
|
+
status: string;
|
|
15
|
+
/**
|
|
16
|
+
* The skeleton layout shown while loading, rendered inside an
|
|
17
|
+
* `aria-hidden`, `inert` wrapper. Give it the content's own layout
|
|
18
|
+
* container: during a reveal it lies under the content in a box of its
|
|
19
|
+
* own, so a grid or gap set on the region would not reach it.
|
|
20
|
+
*/
|
|
21
|
+
placeholder: Snippet;
|
|
22
|
+
/** The content, and also any empty or error state. */
|
|
23
|
+
children: Snippet;
|
|
24
|
+
/** The region element. A page moves focus here when the content replaces the focused control. */
|
|
25
|
+
ref?: HTMLDivElement | null;
|
|
26
|
+
};
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<script lang="ts">
|
|
30
|
+
import { untrack } from 'svelte';
|
|
31
|
+
import { duration } from '../motion/transitions.js';
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
loading,
|
|
35
|
+
status,
|
|
36
|
+
placeholder,
|
|
37
|
+
children,
|
|
38
|
+
ref = $bindable(null),
|
|
39
|
+
class: className,
|
|
40
|
+
...rest
|
|
41
|
+
}: LoadRegionProps = $props();
|
|
42
|
+
|
|
43
|
+
// ---- Reveal (BRIEF, motion rule 2) -------------------------------------
|
|
44
|
+
|
|
45
|
+
// When a load ends after its skeletons became visible and --dur-reveal is
|
|
46
|
+
// more than 0ms, the content crossfades in over the skeletons for
|
|
47
|
+
// --dur-reveal on --settle. The placeholder stays mounted under the
|
|
48
|
+
// content, unchanged. The content lies on top on an opaque backdrop (the
|
|
49
|
+
// colour behind the region) and fades in from 0, so every pixel moves in
|
|
50
|
+
// a straight line from the placeholder's to the content's: a bar turns
|
|
51
|
+
// into its text, and a part drawn the same in both layers (a tag, a
|
|
52
|
+
// label, a table's band) does not change at all. Then the placeholder
|
|
53
|
+
// goes. At 0ms, for a load that ends within --skeleton-delay, on the
|
|
54
|
+
// first render and for any change that is not a load ending, the content
|
|
55
|
+
// replaces the placeholder in one frame. Opacity only: the skeletons
|
|
56
|
+
// mirror the layout, so nothing moves. Quiet motion, so it runs in both
|
|
57
|
+
// tiers; it is only a fade, so reduced motion keeps it.
|
|
58
|
+
|
|
59
|
+
type Phase = 'loading' | 'reveal' | 'content';
|
|
60
|
+
let phase = $state<Phase>(untrack(() => (loading ? 'loading' : 'content')));
|
|
61
|
+
// Counts loads. A new load remounts the placeholder, so its skeletons
|
|
62
|
+
// wait out --skeleton-delay again, also when it cuts a reveal short.
|
|
63
|
+
let load = $state(0);
|
|
64
|
+
|
|
65
|
+
/** When the running load began, or null between loads. */
|
|
66
|
+
let began: number | null = untrack(() => (loading ? performance.now() : null));
|
|
67
|
+
/** --skeleton-delay when the load began, as the skeletons read it; null until read. */
|
|
68
|
+
let delay: number | null = null;
|
|
69
|
+
/** The running reveal's timing, read from the region when the load ended. */
|
|
70
|
+
let timing: KeyframeAnimationOptions = {};
|
|
71
|
+
|
|
72
|
+
// Before the DOM update, so a reveal keeps the placeholder mounted (its
|
|
73
|
+
// skeletons stay visible) while the content mounts on top. Only `loading`
|
|
74
|
+
// is tracked.
|
|
75
|
+
$effect.pre(() => {
|
|
76
|
+
const busy = loading;
|
|
77
|
+
untrack(() => {
|
|
78
|
+
const node = ref;
|
|
79
|
+
if (busy) {
|
|
80
|
+
if (phase !== 'loading') {
|
|
81
|
+
began = performance.now();
|
|
82
|
+
delay = node ? duration(node, '--skeleton-delay') : null;
|
|
83
|
+
load += 1;
|
|
84
|
+
phase = 'loading';
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (phase !== 'loading') return;
|
|
89
|
+
const elapsed = began === null ? -1 : performance.now() - began;
|
|
90
|
+
began = null;
|
|
91
|
+
// Read now, so a token retuned during the load applies.
|
|
92
|
+
const ms = node ? duration(node, '--dur-reveal') : 0;
|
|
93
|
+
const wait = delay ?? (node ? duration(node, '--skeleton-delay') : 0);
|
|
94
|
+
if (!node || ms <= 0 || elapsed < wait) {
|
|
95
|
+
phase = 'content';
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const settle = getComputedStyle(node).getPropertyValue('--settle').trim();
|
|
99
|
+
timing = {
|
|
100
|
+
duration: ms,
|
|
101
|
+
easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out'
|
|
102
|
+
};
|
|
103
|
+
phase = 'reveal';
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** The solid colour behind `node`: the first background colour up the tree that is not transparent. */
|
|
108
|
+
function backdrop(node: Element): string {
|
|
109
|
+
for (let el: Element | null = node; el; el = el.parentElement) {
|
|
110
|
+
const colour = getComputedStyle(el).backgroundColor;
|
|
111
|
+
// Transparent is `rgba(…, 0)`, or `… / 0)` in the newer colour syntaxes.
|
|
112
|
+
if (colour !== 'transparent' && !/^rgba\(.*,\s*0\)$|\/\s*0\)$/.test(colour)) return colour;
|
|
113
|
+
}
|
|
114
|
+
// Nothing paints: the browser's canvas shows through.
|
|
115
|
+
return 'Canvas';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// After the DOM update, in the same flush, so the content's first frame
|
|
119
|
+
// is already at opacity 0. The cleanup cancels the fade when a new load
|
|
120
|
+
// starts or the region goes away.
|
|
121
|
+
$effect(() => {
|
|
122
|
+
if (phase !== 'reveal') return;
|
|
123
|
+
return untrack(() => {
|
|
124
|
+
const node = ref;
|
|
125
|
+
const content = node?.querySelector<HTMLElement>(':scope > .hp-load-region-content');
|
|
126
|
+
if (!node || !content) {
|
|
127
|
+
phase = 'content';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
content.style.backgroundColor = backdrop(node);
|
|
131
|
+
const fade = content.animate([{ opacity: 0 }, { opacity: 1 }], timing);
|
|
132
|
+
fade.onfinish = () => (phase = 'content');
|
|
133
|
+
return () => {
|
|
134
|
+
fade.cancel();
|
|
135
|
+
content.style.removeProperty('background-color');
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<!--
|
|
142
|
+
A region that loads (BRIEF, motion rule 2): skeleton blocks while the
|
|
143
|
+
content loads, then the content. It owns the loading pattern of the
|
|
144
|
+
states docs: a visually hidden `role="status"` line before the region
|
|
145
|
+
that stays mounted, `aria-busy` on the region while `loading`, and the
|
|
146
|
+
`placeholder` in an `aria-hidden`, `inert` wrapper. When a load that
|
|
147
|
+
showed skeletons ends and `--dur-reveal` is more than 0ms, the content
|
|
148
|
+
crossfades in over the skeletons; see the script. Both layers are
|
|
149
|
+
`display: contents` at rest, so they add no box. During a reveal the
|
|
150
|
+
region is `position: relative`, the placeholder lies under the content
|
|
151
|
+
(`position: absolute; inset: 0`) and the content is a block on the
|
|
152
|
+
colour behind the region, so keep the region's own layout inside the
|
|
153
|
+
snippets and give their roots no outer margin. Focus is the page's job:
|
|
154
|
+
when the content replaces the focused control, the page moves focus to
|
|
155
|
+
`ref` or to the control needed next. Extra attributes and `class` go to
|
|
156
|
+
the region, `div.hp-load-region` (`tabindex="-1"`), which carries
|
|
157
|
+
`data-revealing` during a reveal.
|
|
158
|
+
-->
|
|
159
|
+
<p class="hp-visually-hidden" role="status">{status}</p>
|
|
160
|
+
<div
|
|
161
|
+
{...rest}
|
|
162
|
+
bind:this={ref}
|
|
163
|
+
class={['hp-load-region', className]}
|
|
164
|
+
tabindex="-1"
|
|
165
|
+
aria-busy={loading}
|
|
166
|
+
data-revealing={phase === 'reveal' ? '' : undefined}
|
|
167
|
+
>
|
|
168
|
+
{#key load}
|
|
169
|
+
{#if phase !== 'content'}
|
|
170
|
+
<div class="hp-load-region-placeholder" aria-hidden="true" inert>
|
|
171
|
+
{@render placeholder()}
|
|
172
|
+
</div>
|
|
173
|
+
{/if}
|
|
174
|
+
{/key}
|
|
175
|
+
{#if !loading}
|
|
176
|
+
<div class="hp-load-region-content">
|
|
177
|
+
{@render children()}
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<style>
|
|
183
|
+
/* Focus lands here only from the page's own code, when the content
|
|
184
|
+
replaces the focused control, as <main> takes it from the skip link:
|
|
185
|
+
no contour, since the region is not a control. */
|
|
186
|
+
.hp-load-region:focus {
|
|
187
|
+
outline: none;
|
|
188
|
+
}
|
|
189
|
+
/* At rest neither layer adds a box. */
|
|
190
|
+
.hp-load-region-placeholder,
|
|
191
|
+
.hp-load-region-content {
|
|
192
|
+
display: contents;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ---- Reveal: the content over the placeholder ------------------------ */
|
|
196
|
+
|
|
197
|
+
.hp-load-region[data-revealing] {
|
|
198
|
+
position: relative;
|
|
199
|
+
}
|
|
200
|
+
/* Under the content, over the content's area. Its own stacking context
|
|
201
|
+
keeps a sticky table band (z-index) under the content too. Taller
|
|
202
|
+
placeholder rows are cut at the region's bottom edge; a band's bleed
|
|
203
|
+
into the gutter stays. */
|
|
204
|
+
.hp-load-region[data-revealing] > .hp-load-region-placeholder {
|
|
205
|
+
display: block;
|
|
206
|
+
position: absolute;
|
|
207
|
+
inset: 0;
|
|
208
|
+
isolation: isolate;
|
|
209
|
+
overflow-x: visible;
|
|
210
|
+
overflow-y: clip;
|
|
211
|
+
}
|
|
212
|
+
/* A box for the fade, painted after the placeholder. The script gives it
|
|
213
|
+
the colour behind the region, so it covers the placeholder as it fades
|
|
214
|
+
in. */
|
|
215
|
+
.hp-load-region[data-revealing] > .hp-load-region-content {
|
|
216
|
+
display: block;
|
|
217
|
+
position: relative;
|
|
218
|
+
}
|
|
219
|
+
</style>
|