@ultimat3/ui 1.0.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/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +45 -0
- package/src/a11y.ts +187 -0
- package/src/components/Alert.module.scss +70 -0
- package/src/components/Alert.tsx +49 -0
- package/src/components/Avatar.module.scss +66 -0
- package/src/components/Avatar.tsx +56 -0
- package/src/components/Badge.module.scss +94 -0
- package/src/components/Badge.tsx +34 -0
- package/src/components/Breadcrumb.module.scss +45 -0
- package/src/components/Breadcrumb.tsx +48 -0
- package/src/components/Button.module.scss +146 -0
- package/src/components/Button.tsx +66 -0
- package/src/components/Card.module.scss +69 -0
- package/src/components/Card.tsx +40 -0
- package/src/components/Checkbox.module.scss +90 -0
- package/src/components/Checkbox.tsx +57 -0
- package/src/components/Container.module.scss +31 -0
- package/src/components/Container.tsx +29 -0
- package/src/components/DataTable.module.scss +33 -0
- package/src/components/DataTable.tsx +149 -0
- package/src/components/DateTime.module.scss +9 -0
- package/src/components/DateTime.tsx +45 -0
- package/src/components/Dialog.module.scss +83 -0
- package/src/components/Dialog.tsx +75 -0
- package/src/components/Divider.module.scss +39 -0
- package/src/components/Divider.tsx +38 -0
- package/src/components/Drawer.module.scss +89 -0
- package/src/components/Drawer.tsx +76 -0
- package/src/components/EmptyState.module.scss +34 -0
- package/src/components/EmptyState.tsx +35 -0
- package/src/components/ErrorState.module.scss +77 -0
- package/src/components/ErrorState.tsx +84 -0
- package/src/components/Field.module.scss +41 -0
- package/src/components/Field.tsx +86 -0
- package/src/components/Form.module.scss +21 -0
- package/src/components/Form.tsx +51 -0
- package/src/components/Grid.module.scss +9 -0
- package/src/components/Grid.tsx +40 -0
- package/src/components/IconButton.module.scss +99 -0
- package/src/components/IconButton.tsx +53 -0
- package/src/components/Image.module.scss +14 -0
- package/src/components/Image.tsx +59 -0
- package/src/components/Input.module.scss +55 -0
- package/src/components/Input.tsx +84 -0
- package/src/components/Link.module.scss +42 -0
- package/src/components/Link.tsx +46 -0
- package/src/components/LocaleSwitcher.module.scss +28 -0
- package/src/components/LocaleSwitcher.tsx +61 -0
- package/src/components/Menu.module.scss +63 -0
- package/src/components/Menu.tsx +115 -0
- package/src/components/Money.module.scss +11 -0
- package/src/components/Money.tsx +49 -0
- package/src/components/Pagination.module.scss +15 -0
- package/src/components/Pagination.tsx +77 -0
- package/src/components/Popover.module.scss +59 -0
- package/src/components/Popover.tsx +76 -0
- package/src/components/Radio.module.scss +87 -0
- package/src/components/Radio.tsx +64 -0
- package/src/components/RelativeTime.module.scss +7 -0
- package/src/components/RelativeTime.tsx +49 -0
- package/src/components/Select.module.scss +51 -0
- package/src/components/Select.tsx +63 -0
- package/src/components/Skeleton.module.scss +41 -0
- package/src/components/Skeleton.tsx +34 -0
- package/src/components/Spinner.module.scss +33 -0
- package/src/components/Spinner.tsx +35 -0
- package/src/components/Stack.module.scss +21 -0
- package/src/components/Stack.tsx +49 -0
- package/src/components/Switch.module.scss +66 -0
- package/src/components/Switch.tsx +44 -0
- package/src/components/Table.module.scss +77 -0
- package/src/components/Table.tsx +42 -0
- package/src/components/Tabs.module.scss +71 -0
- package/src/components/Tabs.tsx +88 -0
- package/src/components/Text.module.scss +37 -0
- package/src/components/Text.tsx +57 -0
- package/src/components/Textarea.module.scss +15 -0
- package/src/components/Textarea.tsx +48 -0
- package/src/components/ThemeToggle.module.scss +23 -0
- package/src/components/ThemeToggle.tsx +93 -0
- package/src/components/Toast.module.scss +92 -0
- package/src/components/Toast.tsx +74 -0
- package/src/components/Tooltip.module.scss +41 -0
- package/src/components/Tooltip.tsx +32 -0
- package/src/components/date-time-view.ts +68 -0
- package/src/components/image-source.ts +150 -0
- package/src/components/money-view.ts +50 -0
- package/src/components/relative-time-view.ts +45 -0
- package/src/components/sort-state.ts +28 -0
- package/src/components/variants.ts +17 -0
- package/src/cx.ts +42 -0
- package/src/errors.ts +72 -0
- package/src/global.scss +5 -0
- package/src/i18n-keys.ts +34 -0
- package/src/index.ts +199 -0
- package/src/scss.d.ts +7 -0
- package/src/theme/context.ts +65 -0
- package/src/theme/inline-script.ts +44 -0
- package/src/theme/provider.tsx +56 -0
- package/src/theme/solid-adapter.ts +51 -0
- package/src/theme/theme.ts +116 -0
- package/src/tokens/_breakpoints.scss +10 -0
- package/src/tokens/_colors.scss +95 -0
- package/src/tokens/_index.scss +13 -0
- package/src/tokens/_mixins.scss +186 -0
- package/src/tokens/_motion.scss +36 -0
- package/src/tokens/_radius.scss +23 -0
- package/src/tokens/_shadow.scss +49 -0
- package/src/tokens/_space.scss +28 -0
- package/src/tokens/_typography.scss +83 -0
- package/src/tokens/_z.scss +27 -0
- package/src/tokens/reset.scss +144 -0
- package/src/tokens/theme.scss +19 -0
- package/src/tokens/tokens.ts +222 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.container {
|
|
4
|
+
--container-gutter: #{t.space(4)};
|
|
5
|
+
inline-size: min(100% - (var(--container-gutter) * 2), var(--container-max));
|
|
6
|
+
margin-inline: auto;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.size-prose {
|
|
10
|
+
--container-max: 68ch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.size-sm {
|
|
14
|
+
--container-max: 40rem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.size-md {
|
|
18
|
+
--container-max: 52rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.size-lg {
|
|
22
|
+
--container-max: 72rem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.size-xl {
|
|
26
|
+
--container-max: 90rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.size-full {
|
|
30
|
+
--container-max: 100%;
|
|
31
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Centred measure with a gutter. `margin-inline: auto` and `min()` mean one
|
|
2
|
+
// declaration covers every viewport and both writing directions.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './Container.module.scss';
|
|
7
|
+
import type { SpaceStep } from './variants';
|
|
8
|
+
|
|
9
|
+
export type ContainerSize = 'prose' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
10
|
+
|
|
11
|
+
export interface ContainerProps {
|
|
12
|
+
children: JSX.Element;
|
|
13
|
+
size?: ContainerSize | undefined;
|
|
14
|
+
gutter?: SpaceStep | undefined;
|
|
15
|
+
as?: 'div' | 'main' | 'section' | 'article' | 'header' | 'footer' | undefined;
|
|
16
|
+
class?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Container(props: ContainerProps): JSX.Element {
|
|
20
|
+
const Tag = props.as ?? 'div';
|
|
21
|
+
return (
|
|
22
|
+
<Tag
|
|
23
|
+
class={cx(styles['container'], styles[`size-${props.size ?? 'lg'}`], props.class)}
|
|
24
|
+
style={{ '--container-gutter': `var(--space-${props.gutter ?? 4})` }}
|
|
25
|
+
>
|
|
26
|
+
{props.children}
|
|
27
|
+
</Tag>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.wrap {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: t.space(3);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Numbers align to the trailing edge in both writing directions.
|
|
10
|
+
.numeric {
|
|
11
|
+
text-align: end;
|
|
12
|
+
font-variant-numeric: tabular-nums;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.sortButton {
|
|
16
|
+
@include t.focus-ring;
|
|
17
|
+
display: inline-flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: t.space(1);
|
|
20
|
+
border-radius: t.radius(sm);
|
|
21
|
+
color: inherit;
|
|
22
|
+
font: inherit;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
|
|
25
|
+
&:hover {
|
|
26
|
+
color: t.role('accent');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.indicator {
|
|
31
|
+
color: t.role('fg-muted');
|
|
32
|
+
font-size: 0.75em;
|
|
33
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Data-driven table: sortable headers, cursor pagination, and the four states a
|
|
2
|
+
// real list always has (loading, error, empty, data). The error state renders an
|
|
3
|
+
// UltimateError with the same code/cause/fix strings the terminal prints.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { ariaBool } from '../a11y';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
9
|
+
import { useUi } from '../theme/context';
|
|
10
|
+
import styles from './DataTable.module.scss';
|
|
11
|
+
import { EmptyState } from './EmptyState';
|
|
12
|
+
import { ErrorState } from './ErrorState';
|
|
13
|
+
import { Pagination } from './Pagination';
|
|
14
|
+
import { Skeleton } from './Skeleton';
|
|
15
|
+
import { ariaSortFor, nextSortState, type SortState } from './sort-state';
|
|
16
|
+
import { Table } from './Table';
|
|
17
|
+
|
|
18
|
+
export interface Column<Row> {
|
|
19
|
+
/** Stable identifier; also the sort key sent to the query. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Already-translated header text. */
|
|
22
|
+
header: string;
|
|
23
|
+
cell: (row: Row) => JSX.Element;
|
|
24
|
+
sortable?: boolean | undefined;
|
|
25
|
+
/** Right-aligns in LTR and left-aligns in RTL via `text-align: end`. */
|
|
26
|
+
numeric?: boolean | undefined;
|
|
27
|
+
width?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DataTableProps<Row> {
|
|
31
|
+
caption: string;
|
|
32
|
+
columns: readonly Column<Row>[];
|
|
33
|
+
rows: readonly Row[];
|
|
34
|
+
rowKey: (row: Row) => string;
|
|
35
|
+
sort?: SortState | undefined;
|
|
36
|
+
onSortChange?: (sort: SortState | undefined) => void;
|
|
37
|
+
loading?: boolean | undefined;
|
|
38
|
+
/** An UltimateError (or anything shaped like one) from the query. */
|
|
39
|
+
error?: unknown | undefined;
|
|
40
|
+
onRetry?: (() => void) | undefined;
|
|
41
|
+
emptyTitle?: string | undefined;
|
|
42
|
+
emptyDescription?: string | undefined;
|
|
43
|
+
/** Opaque cursors from the query result; absent means no further page. */
|
|
44
|
+
nextCursor?: string | undefined;
|
|
45
|
+
prevCursor?: string | undefined;
|
|
46
|
+
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
47
|
+
stickyHeader?: boolean | undefined;
|
|
48
|
+
density?: 'comfortable' | 'compact' | undefined;
|
|
49
|
+
/** Placeholder row count while loading. Match the usual page size. */
|
|
50
|
+
skeletonRows?: number | undefined;
|
|
51
|
+
class?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
55
|
+
const ui = useUi();
|
|
56
|
+
|
|
57
|
+
const sortLabel = (key: string): string =>
|
|
58
|
+
ariaSortFor(props.sort, key) === 'ascending'
|
|
59
|
+
? ui.t(UI_KEYS.sortDescending)
|
|
60
|
+
: ui.t(UI_KEYS.sortAscending);
|
|
61
|
+
|
|
62
|
+
const body = (): JSX.Element => {
|
|
63
|
+
if (props.loading === true) {
|
|
64
|
+
return Array.from({ length: props.skeletonRows ?? 5 }, () => (
|
|
65
|
+
<tr>
|
|
66
|
+
{props.columns.map(() => (
|
|
67
|
+
<td>
|
|
68
|
+
<Skeleton height="1.1em" />
|
|
69
|
+
</td>
|
|
70
|
+
))}
|
|
71
|
+
</tr>
|
|
72
|
+
));
|
|
73
|
+
}
|
|
74
|
+
return props.rows.map((row) => (
|
|
75
|
+
<tr data-row={props.rowKey(row)}>
|
|
76
|
+
{props.columns.map((column) => (
|
|
77
|
+
<td class={column.numeric === true ? styles['numeric'] : undefined}>
|
|
78
|
+
{column.cell(row)}
|
|
79
|
+
</td>
|
|
80
|
+
))}
|
|
81
|
+
</tr>
|
|
82
|
+
));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (props.error !== undefined && props.error !== null) {
|
|
86
|
+
return <ErrorState error={props.error} onRetry={props.onRetry} class={props.class} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (props.loading !== true && props.rows.length === 0) {
|
|
90
|
+
return (
|
|
91
|
+
<EmptyState
|
|
92
|
+
title={props.emptyTitle}
|
|
93
|
+
description={props.emptyDescription}
|
|
94
|
+
class={props.class}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div class={cx(styles['wrap'], props.class)}>
|
|
101
|
+
<Table
|
|
102
|
+
caption={props.caption}
|
|
103
|
+
stickyHeader={props.stickyHeader !== false}
|
|
104
|
+
density={props.density ?? 'comfortable'}
|
|
105
|
+
>
|
|
106
|
+
<thead>
|
|
107
|
+
<tr>
|
|
108
|
+
{props.columns.map((column) => (
|
|
109
|
+
<th
|
|
110
|
+
scope="col"
|
|
111
|
+
style={column.width === undefined ? undefined : { 'inline-size': column.width }}
|
|
112
|
+
aria-sort={ariaSortFor(props.sort, column.key)}
|
|
113
|
+
class={column.numeric === true ? styles['numeric'] : undefined}
|
|
114
|
+
>
|
|
115
|
+
{column.sortable === true ? (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class={styles['sortButton']}
|
|
119
|
+
aria-label={`${column.header}: ${sortLabel(column.key)}`}
|
|
120
|
+
onClick={() => props.onSortChange?.(nextSortState(props.sort, column.key))}
|
|
121
|
+
>
|
|
122
|
+
{column.header}
|
|
123
|
+
<span aria-hidden="true" class={styles['indicator']}>
|
|
124
|
+
{ariaSortFor(props.sort, column.key) === 'ascending'
|
|
125
|
+
? '▲'
|
|
126
|
+
: ariaSortFor(props.sort, column.key) === 'descending'
|
|
127
|
+
? '▼'
|
|
128
|
+
: '↕'}
|
|
129
|
+
</span>
|
|
130
|
+
</button>
|
|
131
|
+
) : (
|
|
132
|
+
column.header
|
|
133
|
+
)}
|
|
134
|
+
</th>
|
|
135
|
+
))}
|
|
136
|
+
</tr>
|
|
137
|
+
</thead>
|
|
138
|
+
<tbody aria-busy={ariaBool(props.loading === true)}>{body()}</tbody>
|
|
139
|
+
</Table>
|
|
140
|
+
{props.nextCursor === undefined && props.prevCursor === undefined ? null : (
|
|
141
|
+
<Pagination
|
|
142
|
+
nextCursor={props.nextCursor}
|
|
143
|
+
prevCursor={props.prevCursor}
|
|
144
|
+
onCursor={props.onCursor}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Renders <time datetime="<ISO instant>"> with text formatted in the context
|
|
2
|
+
// time zone. The attribute is always the UTC instant so crawlers, tests, and
|
|
3
|
+
// other machines read the same value regardless of the viewer's zone.
|
|
4
|
+
|
|
5
|
+
import type { TimeZone } from '@ultimat3/time';
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { useUi } from '../theme/context';
|
|
9
|
+
import styles from './DateTime.module.scss';
|
|
10
|
+
import {
|
|
11
|
+
type DateStyle,
|
|
12
|
+
type DateTimeFormatter,
|
|
13
|
+
dateTimeView,
|
|
14
|
+
type TimeInput,
|
|
15
|
+
} from './date-time-view';
|
|
16
|
+
|
|
17
|
+
export interface DateTimeProps {
|
|
18
|
+
value: TimeInput;
|
|
19
|
+
/** Overrides the context zone. Use for "in the venue's local time" displays. */
|
|
20
|
+
timeZone?: TimeZone | undefined;
|
|
21
|
+
locale?: string | undefined;
|
|
22
|
+
dateStyle?: DateStyle | undefined;
|
|
23
|
+
timeStyle?: DateStyle | undefined;
|
|
24
|
+
format?: DateTimeFormatter | undefined;
|
|
25
|
+
class?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function DateTime(props: DateTimeProps): JSX.Element {
|
|
29
|
+
const ui = useUi();
|
|
30
|
+
const view = (): { dateTime: string; text: string } =>
|
|
31
|
+
dateTimeView({
|
|
32
|
+
value: props.value,
|
|
33
|
+
locale: props.locale ?? ui.locale,
|
|
34
|
+
timeZone: props.timeZone ?? ui.timeZone,
|
|
35
|
+
...(props.dateStyle === undefined ? {} : { dateStyle: props.dateStyle }),
|
|
36
|
+
...(props.timeStyle === undefined ? {} : { timeStyle: props.timeStyle }),
|
|
37
|
+
...(props.format === undefined ? {} : { format: props.format }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<time class={cx(styles['dateTime'], props.class)} datetime={view().dateTime}>
|
|
42
|
+
{view().text}
|
|
43
|
+
</time>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
@keyframes ultimate-dialog-in {
|
|
4
|
+
from {
|
|
5
|
+
opacity: 0;
|
|
6
|
+
translate: 0 8px;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.dialog {
|
|
11
|
+
inline-size: min(100% - #{t.space(8)}, var(--dialog-max, 32rem));
|
|
12
|
+
max-block-size: min(88dvh, 60rem);
|
|
13
|
+
padding: 0;
|
|
14
|
+
border: 1px solid t.role('line');
|
|
15
|
+
border-radius: t.radius(lg);
|
|
16
|
+
background: t.role('surface-raised');
|
|
17
|
+
color: t.role('fg');
|
|
18
|
+
box-shadow: t.shadow(xl);
|
|
19
|
+
overflow: clip;
|
|
20
|
+
|
|
21
|
+
&[open] {
|
|
22
|
+
animation: ultimate-dialog-in t.duration(base) t.easing(out);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&::backdrop {
|
|
26
|
+
background: t.role('scrim', 0.45);
|
|
27
|
+
backdrop-filter: blur(2px);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.size-sm {
|
|
32
|
+
--dialog-max: 24rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.size-md {
|
|
36
|
+
--dialog-max: 32rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.size-lg {
|
|
40
|
+
--dialog-max: 48rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.size-full {
|
|
44
|
+
--dialog-max: 100rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.panel {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
max-block-size: inherit;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.header {
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
gap: t.space(3);
|
|
57
|
+
padding-block: t.space(3);
|
|
58
|
+
padding-inline: t.space(5);
|
|
59
|
+
border-block-end: 1px solid t.role('line');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.title {
|
|
63
|
+
flex: 1;
|
|
64
|
+
min-inline-size: 0;
|
|
65
|
+
font-size: t.text(lg);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.body {
|
|
69
|
+
@include t.custom-scrollbar;
|
|
70
|
+
padding: t.space(5);
|
|
71
|
+
overflow-y: auto;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.footer {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
gap: t.space(3);
|
|
78
|
+
justify-content: flex-end;
|
|
79
|
+
padding-block: t.space(3);
|
|
80
|
+
padding-inline: t.space(5);
|
|
81
|
+
border-block-start: 1px solid t.role('line');
|
|
82
|
+
background: t.role('bg-soft');
|
|
83
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Modal built on native <dialog>: the platform gives us the top layer, the
|
|
2
|
+
// backdrop, inert background content, and Escape-to-close for free. We add the
|
|
3
|
+
// labelled heading, the close affordance, and body scroll locking.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { useId } from '../a11y';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
9
|
+
import { useUi } from '../theme/context';
|
|
10
|
+
import { solid } from '../theme/solid-adapter';
|
|
11
|
+
import styles from './Dialog.module.scss';
|
|
12
|
+
import { IconButton } from './IconButton';
|
|
13
|
+
|
|
14
|
+
export interface DialogProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** Already-translated title. Becomes the dialog's accessible name. */
|
|
17
|
+
title: string;
|
|
18
|
+
children: JSX.Element;
|
|
19
|
+
footer?: JSX.Element | undefined;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
size?: 'sm' | 'md' | 'lg' | 'full' | undefined;
|
|
22
|
+
/** Clicking the backdrop closes. Turn off for destructive confirmations. */
|
|
23
|
+
dismissOnBackdrop?: boolean | undefined;
|
|
24
|
+
class?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Dialog(props: DialogProps): JSX.Element {
|
|
28
|
+
const ui = useUi();
|
|
29
|
+
const rt = solid();
|
|
30
|
+
const titleId = useId('dialog-title');
|
|
31
|
+
let element: HTMLDialogElement | undefined;
|
|
32
|
+
|
|
33
|
+
rt.createEffect(() => {
|
|
34
|
+
const dialog = element;
|
|
35
|
+
if (dialog === undefined) return;
|
|
36
|
+
if (props.open && !dialog.open) dialog.showModal();
|
|
37
|
+
if (!props.open && dialog.open) dialog.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
// The backdrop's keyboard equivalent is Esc, which <dialog> raises as `cancel`, and
|
|
42
|
+
// `onCancel` below closes on it.
|
|
43
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: Esc closes through onCancel
|
|
44
|
+
<dialog
|
|
45
|
+
ref={(el: HTMLDialogElement) => {
|
|
46
|
+
element = el;
|
|
47
|
+
}}
|
|
48
|
+
class={cx(styles['dialog'], styles[`size-${props.size ?? 'md'}`], props.class)}
|
|
49
|
+
aria-labelledby={titleId}
|
|
50
|
+
onCancel={(event) => {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
props.onClose();
|
|
53
|
+
}}
|
|
54
|
+
onClick={(event) => {
|
|
55
|
+
// A click on the backdrop lands on the <dialog> itself, never a child.
|
|
56
|
+
if (props.dismissOnBackdrop !== false && event.target === element) props.onClose();
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<div class={styles['panel']}>
|
|
60
|
+
<header class={styles['header']}>
|
|
61
|
+
<h2 class={styles['title']} id={titleId}>
|
|
62
|
+
{props.title}
|
|
63
|
+
</h2>
|
|
64
|
+
<IconButton label={ui.t(UI_KEYS.close)} size="sm" onClick={() => props.onClose()}>
|
|
65
|
+
<span aria-hidden="true">×</span>
|
|
66
|
+
</IconButton>
|
|
67
|
+
</header>
|
|
68
|
+
<div class={styles['body']}>{props.children}</div>
|
|
69
|
+
{props.footer === undefined ? null : (
|
|
70
|
+
<footer class={styles['footer']}>{props.footer}</footer>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
</dialog>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.divider {
|
|
4
|
+
border: 0;
|
|
5
|
+
background: t.role('line');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.horizontal {
|
|
9
|
+
inline-size: 100%;
|
|
10
|
+
block-size: 1px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.vertical {
|
|
14
|
+
inline-size: 1px;
|
|
15
|
+
block-size: 100%;
|
|
16
|
+
align-self: stretch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.labelled {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: t.space(3);
|
|
23
|
+
color: t.role('fg-muted');
|
|
24
|
+
font-size: t.text(xs);
|
|
25
|
+
text-transform: uppercase;
|
|
26
|
+
letter-spacing: var(--tracking-wide);
|
|
27
|
+
|
|
28
|
+
&::before,
|
|
29
|
+
&::after {
|
|
30
|
+
flex: 1;
|
|
31
|
+
block-size: 1px;
|
|
32
|
+
background: t.role('line');
|
|
33
|
+
content: '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.label {
|
|
38
|
+
@include t.truncate;
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A rule. Always an explicit `role="separator"` with an orientation; pass `label`
|
|
2
|
+
// to render a captioned separator, which is what a settings group actually needs.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './Divider.module.scss';
|
|
7
|
+
|
|
8
|
+
export interface DividerProps {
|
|
9
|
+
orientation?: 'horizontal' | 'vertical' | undefined;
|
|
10
|
+
/** Visible, already-translated caption rendered inside the rule. */
|
|
11
|
+
label?: string | undefined;
|
|
12
|
+
class?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Divider(props: DividerProps): JSX.Element {
|
|
16
|
+
const orientation = (): 'horizontal' | 'vertical' => props.orientation ?? 'horizontal';
|
|
17
|
+
|
|
18
|
+
return props.label === undefined ? (
|
|
19
|
+
<hr
|
|
20
|
+
aria-orientation={orientation()}
|
|
21
|
+
class={cx(styles['divider'], styles[orientation()], props.class)}
|
|
22
|
+
/>
|
|
23
|
+
) : (
|
|
24
|
+
// A captioned separator is the structural (non-focusable) flavour of the role, which
|
|
25
|
+
// ARIA does not give a value and does not put in the tab order. `<hr>` is void, so the
|
|
26
|
+
// caption has to live in a container that carries the role itself.
|
|
27
|
+
// biome-ignore lint/a11y/useSemanticElements: <hr> cannot hold the caption
|
|
28
|
+
// biome-ignore lint/a11y/useFocusableInteractive: a captioned rule is not a splitter
|
|
29
|
+
<div
|
|
30
|
+
// biome-ignore lint/a11y/useAriaPropsForRole: aria-valuenow is the splitter flavour's
|
|
31
|
+
role="separator"
|
|
32
|
+
aria-orientation={orientation()}
|
|
33
|
+
class={cx(styles['labelled'], props.class)}
|
|
34
|
+
>
|
|
35
|
+
<span class={styles['label']}>{props.label}</span>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.drawer {
|
|
4
|
+
position: fixed;
|
|
5
|
+
max-inline-size: 100%;
|
|
6
|
+
max-block-size: 100%;
|
|
7
|
+
padding: 0;
|
|
8
|
+
border: 0;
|
|
9
|
+
background: t.role('surface-raised');
|
|
10
|
+
color: t.role('fg');
|
|
11
|
+
box-shadow: t.shadow(xl);
|
|
12
|
+
|
|
13
|
+
&::backdrop {
|
|
14
|
+
background: t.role('scrim', 0.45);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.side-inline-start {
|
|
19
|
+
inset-block: 0;
|
|
20
|
+
inset-inline-start: 0;
|
|
21
|
+
inset-inline-end: auto;
|
|
22
|
+
inline-size: var(--drawer-size, 22rem);
|
|
23
|
+
block-size: 100dvh;
|
|
24
|
+
border-inline-end: 1px solid t.role('line');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.side-inline-end {
|
|
28
|
+
inset-block: 0;
|
|
29
|
+
inset-inline-end: 0;
|
|
30
|
+
inset-inline-start: auto;
|
|
31
|
+
inline-size: var(--drawer-size, 22rem);
|
|
32
|
+
block-size: 100dvh;
|
|
33
|
+
border-inline-start: 1px solid t.role('line');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.side-block-start {
|
|
37
|
+
inset-inline: 0;
|
|
38
|
+
inset-block-start: 0;
|
|
39
|
+
inset-block-end: auto;
|
|
40
|
+
inline-size: 100vw;
|
|
41
|
+
block-size: var(--drawer-size, 22rem);
|
|
42
|
+
border-block-end: 1px solid t.role('line');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.side-block-end {
|
|
46
|
+
inset-inline: 0;
|
|
47
|
+
inset-block-end: 0;
|
|
48
|
+
inset-block-start: auto;
|
|
49
|
+
inline-size: 100vw;
|
|
50
|
+
block-size: var(--drawer-size, 22rem);
|
|
51
|
+
border-block-start: 1px solid t.role('line');
|
|
52
|
+
border-start-start-radius: t.radius(lg);
|
|
53
|
+
border-start-end-radius: t.radius(lg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.panel {
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
block-size: 100%;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.header {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: t.space(3);
|
|
66
|
+
padding-block: t.space(3);
|
|
67
|
+
padding-inline: t.space(4);
|
|
68
|
+
border-block-end: 1px solid t.role('line');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.title {
|
|
72
|
+
flex: 1;
|
|
73
|
+
min-inline-size: 0;
|
|
74
|
+
font-size: t.text(lg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.body {
|
|
78
|
+
@include t.custom-scrollbar;
|
|
79
|
+
flex: 1;
|
|
80
|
+
padding: t.space(4);
|
|
81
|
+
overflow-y: auto;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.footer {
|
|
85
|
+
padding-block: t.space(3);
|
|
86
|
+
padding-inline: t.space(4);
|
|
87
|
+
border-block-start: 1px solid t.role('line');
|
|
88
|
+
background: t.role('bg-soft');
|
|
89
|
+
}
|