@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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/package.json +45 -0
  4. package/src/a11y.ts +187 -0
  5. package/src/components/Alert.module.scss +70 -0
  6. package/src/components/Alert.tsx +49 -0
  7. package/src/components/Avatar.module.scss +66 -0
  8. package/src/components/Avatar.tsx +56 -0
  9. package/src/components/Badge.module.scss +94 -0
  10. package/src/components/Badge.tsx +34 -0
  11. package/src/components/Breadcrumb.module.scss +45 -0
  12. package/src/components/Breadcrumb.tsx +48 -0
  13. package/src/components/Button.module.scss +146 -0
  14. package/src/components/Button.tsx +66 -0
  15. package/src/components/Card.module.scss +69 -0
  16. package/src/components/Card.tsx +40 -0
  17. package/src/components/Checkbox.module.scss +90 -0
  18. package/src/components/Checkbox.tsx +57 -0
  19. package/src/components/Container.module.scss +31 -0
  20. package/src/components/Container.tsx +29 -0
  21. package/src/components/DataTable.module.scss +33 -0
  22. package/src/components/DataTable.tsx +149 -0
  23. package/src/components/DateTime.module.scss +9 -0
  24. package/src/components/DateTime.tsx +45 -0
  25. package/src/components/Dialog.module.scss +83 -0
  26. package/src/components/Dialog.tsx +75 -0
  27. package/src/components/Divider.module.scss +39 -0
  28. package/src/components/Divider.tsx +38 -0
  29. package/src/components/Drawer.module.scss +89 -0
  30. package/src/components/Drawer.tsx +76 -0
  31. package/src/components/EmptyState.module.scss +34 -0
  32. package/src/components/EmptyState.tsx +35 -0
  33. package/src/components/ErrorState.module.scss +77 -0
  34. package/src/components/ErrorState.tsx +84 -0
  35. package/src/components/Field.module.scss +41 -0
  36. package/src/components/Field.tsx +86 -0
  37. package/src/components/Form.module.scss +21 -0
  38. package/src/components/Form.tsx +51 -0
  39. package/src/components/Grid.module.scss +9 -0
  40. package/src/components/Grid.tsx +40 -0
  41. package/src/components/IconButton.module.scss +99 -0
  42. package/src/components/IconButton.tsx +53 -0
  43. package/src/components/Image.module.scss +14 -0
  44. package/src/components/Image.tsx +59 -0
  45. package/src/components/Input.module.scss +55 -0
  46. package/src/components/Input.tsx +84 -0
  47. package/src/components/Link.module.scss +42 -0
  48. package/src/components/Link.tsx +46 -0
  49. package/src/components/LocaleSwitcher.module.scss +28 -0
  50. package/src/components/LocaleSwitcher.tsx +61 -0
  51. package/src/components/Menu.module.scss +63 -0
  52. package/src/components/Menu.tsx +115 -0
  53. package/src/components/Money.module.scss +11 -0
  54. package/src/components/Money.tsx +49 -0
  55. package/src/components/Pagination.module.scss +15 -0
  56. package/src/components/Pagination.tsx +77 -0
  57. package/src/components/Popover.module.scss +59 -0
  58. package/src/components/Popover.tsx +76 -0
  59. package/src/components/Radio.module.scss +87 -0
  60. package/src/components/Radio.tsx +64 -0
  61. package/src/components/RelativeTime.module.scss +7 -0
  62. package/src/components/RelativeTime.tsx +49 -0
  63. package/src/components/Select.module.scss +51 -0
  64. package/src/components/Select.tsx +63 -0
  65. package/src/components/Skeleton.module.scss +41 -0
  66. package/src/components/Skeleton.tsx +34 -0
  67. package/src/components/Spinner.module.scss +33 -0
  68. package/src/components/Spinner.tsx +35 -0
  69. package/src/components/Stack.module.scss +21 -0
  70. package/src/components/Stack.tsx +49 -0
  71. package/src/components/Switch.module.scss +66 -0
  72. package/src/components/Switch.tsx +44 -0
  73. package/src/components/Table.module.scss +77 -0
  74. package/src/components/Table.tsx +42 -0
  75. package/src/components/Tabs.module.scss +71 -0
  76. package/src/components/Tabs.tsx +88 -0
  77. package/src/components/Text.module.scss +37 -0
  78. package/src/components/Text.tsx +57 -0
  79. package/src/components/Textarea.module.scss +15 -0
  80. package/src/components/Textarea.tsx +48 -0
  81. package/src/components/ThemeToggle.module.scss +23 -0
  82. package/src/components/ThemeToggle.tsx +93 -0
  83. package/src/components/Toast.module.scss +92 -0
  84. package/src/components/Toast.tsx +74 -0
  85. package/src/components/Tooltip.module.scss +41 -0
  86. package/src/components/Tooltip.tsx +32 -0
  87. package/src/components/date-time-view.ts +68 -0
  88. package/src/components/image-source.ts +150 -0
  89. package/src/components/money-view.ts +50 -0
  90. package/src/components/relative-time-view.ts +45 -0
  91. package/src/components/sort-state.ts +28 -0
  92. package/src/components/variants.ts +17 -0
  93. package/src/cx.ts +42 -0
  94. package/src/errors.ts +72 -0
  95. package/src/global.scss +5 -0
  96. package/src/i18n-keys.ts +34 -0
  97. package/src/index.ts +199 -0
  98. package/src/scss.d.ts +7 -0
  99. package/src/theme/context.ts +65 -0
  100. package/src/theme/inline-script.ts +44 -0
  101. package/src/theme/provider.tsx +56 -0
  102. package/src/theme/solid-adapter.ts +51 -0
  103. package/src/theme/theme.ts +116 -0
  104. package/src/tokens/_breakpoints.scss +10 -0
  105. package/src/tokens/_colors.scss +95 -0
  106. package/src/tokens/_index.scss +13 -0
  107. package/src/tokens/_mixins.scss +186 -0
  108. package/src/tokens/_motion.scss +36 -0
  109. package/src/tokens/_radius.scss +23 -0
  110. package/src/tokens/_shadow.scss +49 -0
  111. package/src/tokens/_space.scss +28 -0
  112. package/src/tokens/_typography.scss +83 -0
  113. package/src/tokens/_z.scss +27 -0
  114. package/src/tokens/reset.scss +144 -0
  115. package/src/tokens/theme.scss +19 -0
  116. 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,9 @@
1
+ @use '../tokens' as t;
2
+
3
+ .dateTime {
4
+ color: inherit;
5
+ font-variant-numeric: tabular-nums;
6
+ white-space: nowrap;
7
+ // Underlining a <time> would imply a link; the value carries the meaning.
8
+ text-decoration: none;
9
+ }
@@ -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
+ }