@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,76 @@
|
|
|
1
|
+
// Edge-anchored panel, also a native <dialog> so it inherits the top layer and
|
|
2
|
+
// inert background. `side` is logical (`inline-start`/`inline-end`), so a drawer
|
|
3
|
+
// pinned to the start edge lands on the right in an RTL locale automatically.
|
|
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 './Drawer.module.scss';
|
|
12
|
+
import { IconButton } from './IconButton';
|
|
13
|
+
|
|
14
|
+
export type DrawerSide = 'inline-start' | 'inline-end' | 'block-start' | 'block-end';
|
|
15
|
+
|
|
16
|
+
export interface DrawerProps {
|
|
17
|
+
open: boolean;
|
|
18
|
+
/** Already-translated title. Becomes the drawer's accessible name. */
|
|
19
|
+
title: string;
|
|
20
|
+
children: JSX.Element;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
side?: DrawerSide | undefined;
|
|
23
|
+
size?: string | undefined;
|
|
24
|
+
footer?: JSX.Element | undefined;
|
|
25
|
+
class?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Drawer(props: DrawerProps): JSX.Element {
|
|
29
|
+
const ui = useUi();
|
|
30
|
+
const rt = solid();
|
|
31
|
+
const titleId = useId('drawer-title');
|
|
32
|
+
let element: HTMLDialogElement | undefined;
|
|
33
|
+
|
|
34
|
+
rt.createEffect(() => {
|
|
35
|
+
const dialog = element;
|
|
36
|
+
if (dialog === undefined) return;
|
|
37
|
+
if (props.open && !dialog.open) dialog.showModal();
|
|
38
|
+
if (!props.open && dialog.open) dialog.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
// The backdrop's keyboard equivalent is Esc, which <dialog> raises as `cancel`, and
|
|
43
|
+
// `onCancel` below closes on it.
|
|
44
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: Esc closes through onCancel
|
|
45
|
+
<dialog
|
|
46
|
+
ref={(el: HTMLDialogElement) => {
|
|
47
|
+
element = el;
|
|
48
|
+
}}
|
|
49
|
+
class={cx(styles['drawer'], styles[`side-${props.side ?? 'inline-end'}`], props.class)}
|
|
50
|
+
style={{ '--drawer-size': props.size ?? '22rem' }}
|
|
51
|
+
aria-labelledby={titleId}
|
|
52
|
+
onCancel={(event) => {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
props.onClose();
|
|
55
|
+
}}
|
|
56
|
+
onClick={(event) => {
|
|
57
|
+
if (event.target === element) props.onClose();
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<div class={styles['panel']}>
|
|
61
|
+
<header class={styles['header']}>
|
|
62
|
+
<h2 class={styles['title']} id={titleId}>
|
|
63
|
+
{props.title}
|
|
64
|
+
</h2>
|
|
65
|
+
<IconButton label={ui.t(UI_KEYS.close)} size="sm" onClick={() => props.onClose()}>
|
|
66
|
+
<span aria-hidden="true">×</span>
|
|
67
|
+
</IconButton>
|
|
68
|
+
</header>
|
|
69
|
+
<div class={styles['body']}>{props.children}</div>
|
|
70
|
+
{props.footer === undefined ? null : (
|
|
71
|
+
<footer class={styles['footer']}>{props.footer}</footer>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</dialog>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.empty {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: t.space(2);
|
|
8
|
+
padding-block: t.space(12);
|
|
9
|
+
padding-inline: t.space(6);
|
|
10
|
+
border: 1px dashed t.role('line');
|
|
11
|
+
border-radius: t.radius(lg);
|
|
12
|
+
color: t.role('fg-muted');
|
|
13
|
+
text-align: center;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.icon {
|
|
17
|
+
color: t.role('fg-muted');
|
|
18
|
+
font-size: t.text(2xl);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.title {
|
|
22
|
+
color: t.role('fg-strong');
|
|
23
|
+
font-size: t.text(lg);
|
|
24
|
+
font-weight: t.weight(semibold);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.description {
|
|
28
|
+
max-inline-size: 44ch;
|
|
29
|
+
font-size: t.text(sm);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.action {
|
|
33
|
+
margin-block-start: t.space(2);
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The "nothing here yet" surface. Title and description arrive as already
|
|
2
|
+
// translated strings; the action slot takes a Button so there is one CTA shape.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
7
|
+
import { useUi } from '../theme/context';
|
|
8
|
+
import styles from './EmptyState.module.scss';
|
|
9
|
+
|
|
10
|
+
export interface EmptyStateProps {
|
|
11
|
+
/** Already-translated. Falls back to the `ui.empty` catalog key. */
|
|
12
|
+
title?: string | undefined;
|
|
13
|
+
description?: string | undefined;
|
|
14
|
+
icon?: JSX.Element | undefined;
|
|
15
|
+
action?: JSX.Element | undefined;
|
|
16
|
+
class?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function EmptyState(props: EmptyStateProps): JSX.Element {
|
|
20
|
+
const ui = useUi();
|
|
21
|
+
return (
|
|
22
|
+
<div class={cx(styles['empty'], props.class)}>
|
|
23
|
+
{props.icon === undefined ? null : (
|
|
24
|
+
<span aria-hidden="true" class={styles['icon']}>
|
|
25
|
+
{props.icon}
|
|
26
|
+
</span>
|
|
27
|
+
)}
|
|
28
|
+
<p class={styles['title']}>{props.title ?? ui.t(UI_KEYS.empty)}</p>
|
|
29
|
+
{props.description === undefined ? null : (
|
|
30
|
+
<p class={styles['description']}>{props.description}</p>
|
|
31
|
+
)}
|
|
32
|
+
{props.action === undefined ? null : <div class={styles['action']}>{props.action}</div>}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.error {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: t.space(3);
|
|
7
|
+
padding: t.space(5);
|
|
8
|
+
border: 1px solid t.role('danger', 0.4);
|
|
9
|
+
border-radius: t.radius(lg);
|
|
10
|
+
background: t.role('danger-soft');
|
|
11
|
+
color: t.role('fg');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.head {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-wrap: wrap;
|
|
17
|
+
align-items: baseline;
|
|
18
|
+
gap: t.space(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.code {
|
|
22
|
+
padding-block: 0.1rem;
|
|
23
|
+
padding-inline: t.space(2);
|
|
24
|
+
border-radius: t.radius(sm);
|
|
25
|
+
background: t.role('danger');
|
|
26
|
+
color: t.role('danger-fg');
|
|
27
|
+
font-family: var(--font-mono);
|
|
28
|
+
font-size: t.text(xs);
|
|
29
|
+
font-weight: t.weight(semibold);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.title {
|
|
33
|
+
color: t.role('fg-strong');
|
|
34
|
+
font-weight: t.weight(semibold);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.detail {
|
|
38
|
+
display: grid;
|
|
39
|
+
grid-template-columns: auto 1fr;
|
|
40
|
+
gap: t.space(1) t.space(3);
|
|
41
|
+
font-size: t.text(sm);
|
|
42
|
+
|
|
43
|
+
> :global(dt) {
|
|
44
|
+
color: t.role('fg-muted');
|
|
45
|
+
font-weight: t.weight(medium);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
> :global(dd) {
|
|
49
|
+
min-inline-size: 0;
|
|
50
|
+
overflow-wrap: anywhere;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.fix {
|
|
55
|
+
display: inline-block;
|
|
56
|
+
padding-block: 0.1rem;
|
|
57
|
+
padding-inline: t.space(2);
|
|
58
|
+
border: 1px solid t.role('line');
|
|
59
|
+
border-radius: t.radius(sm);
|
|
60
|
+
background: t.role('surface-raised');
|
|
61
|
+
font-family: var(--font-mono);
|
|
62
|
+
font-size: t.text(xs);
|
|
63
|
+
user-select: all;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.actions {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-wrap: wrap;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: t.space(3);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.docs {
|
|
74
|
+
color: t.role('fg-muted');
|
|
75
|
+
font-size: t.text(xs);
|
|
76
|
+
text-decoration: underline;
|
|
77
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Renders an UltimateError with the same three strings the terminal prints:
|
|
2
|
+
// code, cause, fix. Identical text in the CLI, the overlay, and `--json` is the
|
|
3
|
+
// whole point of the error contract — this component must not paraphrase.
|
|
4
|
+
|
|
5
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
9
|
+
import { useUi } from '../theme/context';
|
|
10
|
+
import { Button } from './Button';
|
|
11
|
+
import styles from './ErrorState.module.scss';
|
|
12
|
+
|
|
13
|
+
export interface ErrorStateProps {
|
|
14
|
+
/** An UltimateError, or any thrown value; unknown values get X_INTERNAL text. */
|
|
15
|
+
error: unknown;
|
|
16
|
+
onRetry?: (() => void) | undefined;
|
|
17
|
+
/** Already-translated; falls back to the ui.* catalog keys. */
|
|
18
|
+
retryLabel?: string | undefined;
|
|
19
|
+
/** Link the code to its docs page. On by default. */
|
|
20
|
+
showDocs?: boolean | undefined;
|
|
21
|
+
class?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ErrorParts {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly cause: string;
|
|
28
|
+
readonly fix: string;
|
|
29
|
+
readonly docs: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function errorParts(error: unknown): ErrorParts {
|
|
33
|
+
if (isUltimateError(error)) {
|
|
34
|
+
return {
|
|
35
|
+
code: error.code,
|
|
36
|
+
title: error.title,
|
|
37
|
+
cause: error.cause,
|
|
38
|
+
fix: error.fix,
|
|
39
|
+
docs: error.docs,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return {
|
|
44
|
+
code: 'X_INTERNAL',
|
|
45
|
+
title: 'unexpected internal framework error',
|
|
46
|
+
cause: message,
|
|
47
|
+
fix: 'x logs --json | tail -50',
|
|
48
|
+
docs: 'https://ultimate.dev/errors/X_INTERNAL',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ErrorState(props: ErrorStateProps): JSX.Element {
|
|
53
|
+
const ui = useUi();
|
|
54
|
+
const parts = (): ErrorParts => errorParts(props.error);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div class={cx(styles['error'], props.class)} role="alert">
|
|
58
|
+
<p class={styles['head']}>
|
|
59
|
+
<code class={styles['code']}>{parts().code}</code>
|
|
60
|
+
<span class={styles['title']}>{parts().title}</span>
|
|
61
|
+
</p>
|
|
62
|
+
<dl class={styles['detail']}>
|
|
63
|
+
<dt>{ui.t(UI_KEYS.errorCause)}</dt>
|
|
64
|
+
<dd>{parts().cause}</dd>
|
|
65
|
+
<dt>{ui.t(UI_KEYS.errorFix)}</dt>
|
|
66
|
+
<dd>
|
|
67
|
+
<code class={styles['fix']}>{parts().fix}</code>
|
|
68
|
+
</dd>
|
|
69
|
+
</dl>
|
|
70
|
+
<div class={styles['actions']}>
|
|
71
|
+
{props.onRetry === undefined ? null : (
|
|
72
|
+
<Button size="sm" variant="secondary" tone="danger" onClick={() => props.onRetry?.()}>
|
|
73
|
+
{props.retryLabel ?? ui.t(UI_KEYS.retry)}
|
|
74
|
+
</Button>
|
|
75
|
+
)}
|
|
76
|
+
{props.showDocs === false ? null : (
|
|
77
|
+
<a class={styles['docs']} href={parts().docs} target="_blank" rel="noopener noreferrer">
|
|
78
|
+
{parts().code}
|
|
79
|
+
</a>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.field {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: t.space(1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.label {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: baseline;
|
|
12
|
+
gap: t.space(1);
|
|
13
|
+
color: t.role('fg-strong');
|
|
14
|
+
font-size: t.text(sm);
|
|
15
|
+
font-weight: t.weight(medium);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.required {
|
|
19
|
+
color: t.role('danger');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.optional {
|
|
23
|
+
color: t.role('fg-muted');
|
|
24
|
+
font-weight: t.weight(normal);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.hint {
|
|
28
|
+
color: t.role('fg-muted');
|
|
29
|
+
font-size: t.text(xs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.error {
|
|
33
|
+
color: t.role('danger');
|
|
34
|
+
font-size: t.text(xs);
|
|
35
|
+
font-weight: t.weight(medium);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `:has()` lets the whole field react to an invalid control with no extra class.
|
|
39
|
+
.field:has([aria-invalid='true']) .label {
|
|
40
|
+
color: t.role('danger');
|
|
41
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Label + hint + error, wired for a11y. Field owns the ids and hands them to the
|
|
2
|
+
// control through a render prop, so `aria-describedby` and `aria-invalid` can
|
|
3
|
+
// never drift out of sync with what is actually rendered.
|
|
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 styles from './Field.module.scss';
|
|
11
|
+
|
|
12
|
+
/** Spread straight onto Input/Textarea/Select/Checkbox. */
|
|
13
|
+
export interface FieldControl {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly 'aria-describedby': string | undefined;
|
|
16
|
+
readonly 'aria-invalid': boolean;
|
|
17
|
+
readonly required: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FieldProps {
|
|
21
|
+
/** Already-translated label. Rendered as a real <label for>. */
|
|
22
|
+
label: string;
|
|
23
|
+
children: (control: FieldControl) => JSX.Element;
|
|
24
|
+
hint?: string | undefined;
|
|
25
|
+
/** Present means invalid; the string is announced with the control. */
|
|
26
|
+
error?: string | undefined;
|
|
27
|
+
required?: boolean | undefined;
|
|
28
|
+
/** Appends a translated "(optional)" marker instead of marking required. */
|
|
29
|
+
markOptional?: boolean | undefined;
|
|
30
|
+
class?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Field(props: FieldProps): JSX.Element {
|
|
34
|
+
const ui = useUi();
|
|
35
|
+
const id = useId('field');
|
|
36
|
+
const hintId = `${id}-hint`;
|
|
37
|
+
const errorId = `${id}-error`;
|
|
38
|
+
|
|
39
|
+
const describedBy = (): string | undefined => {
|
|
40
|
+
const ids = [
|
|
41
|
+
props.hint === undefined ? null : hintId,
|
|
42
|
+
props.error === undefined ? null : errorId,
|
|
43
|
+
].filter((value): value is string => value !== null);
|
|
44
|
+
return ids.length === 0 ? undefined : ids.join(' ');
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const control = (): FieldControl => ({
|
|
48
|
+
id,
|
|
49
|
+
'aria-describedby': describedBy(),
|
|
50
|
+
'aria-invalid': props.error !== undefined,
|
|
51
|
+
required: props.required === true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
class={cx(
|
|
57
|
+
styles['field'],
|
|
58
|
+
props.error === undefined ? undefined : styles['invalid'],
|
|
59
|
+
props.class,
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
<label class={styles['label']} for={id}>
|
|
63
|
+
{props.label}
|
|
64
|
+
{props.required === true ? (
|
|
65
|
+
<span class={styles['required']} title={ui.t(UI_KEYS.required)}>
|
|
66
|
+
*
|
|
67
|
+
</span>
|
|
68
|
+
) : null}
|
|
69
|
+
{props.markOptional === true ? (
|
|
70
|
+
<span class={styles['optional']}>{ui.t(UI_KEYS.optional)}</span>
|
|
71
|
+
) : null}
|
|
72
|
+
</label>
|
|
73
|
+
{props.hint === undefined ? null : (
|
|
74
|
+
<p class={styles['hint']} id={hintId}>
|
|
75
|
+
{props.hint}
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
{props.children(control())}
|
|
79
|
+
{props.error === undefined ? null : (
|
|
80
|
+
<p class={styles['error']} id={errorId} role="alert">
|
|
81
|
+
{props.error}
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.form {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--form-gap, #{t.space(5)});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.summary:focus {
|
|
10
|
+
outline: 2px solid t.role('danger');
|
|
11
|
+
outline-offset: 4px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.actions {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-wrap: wrap;
|
|
17
|
+
gap: t.space(3);
|
|
18
|
+
align-items: center;
|
|
19
|
+
// Trailing edge in both writing directions.
|
|
20
|
+
justify-content: flex-end;
|
|
21
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Form shell. Owns the one thing every form needs and always forgets: a
|
|
2
|
+
// top-of-form error summary that is focusable and announced on submit.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { useId } from '../a11y';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import { Alert } from './Alert';
|
|
8
|
+
import styles from './Form.module.scss';
|
|
9
|
+
import type { SpaceStep } from './variants';
|
|
10
|
+
|
|
11
|
+
export interface FormProps {
|
|
12
|
+
children: JSX.Element;
|
|
13
|
+
/** Already-translated summary shown above the fields when submit fails. */
|
|
14
|
+
error?: string | undefined;
|
|
15
|
+
/** Already-translated heading for the error summary region. */
|
|
16
|
+
errorTitle?: string | undefined;
|
|
17
|
+
actions?: JSX.Element | undefined;
|
|
18
|
+
gap?: SpaceStep | undefined;
|
|
19
|
+
method?: 'get' | 'post' | undefined;
|
|
20
|
+
action?: string | undefined;
|
|
21
|
+
novalidate?: boolean | undefined;
|
|
22
|
+
class?: string | undefined;
|
|
23
|
+
'aria-label'?: string | undefined;
|
|
24
|
+
onSubmit?: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent> | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Form(props: FormProps): JSX.Element {
|
|
28
|
+
const summaryId = useId('form-error');
|
|
29
|
+
return (
|
|
30
|
+
<form
|
|
31
|
+
class={cx(styles['form'], props.class)}
|
|
32
|
+
style={{ '--form-gap': `var(--space-${props.gap ?? 5})` }}
|
|
33
|
+
method={props.method ?? 'post'}
|
|
34
|
+
action={props.action}
|
|
35
|
+
novalidate={props.novalidate === true}
|
|
36
|
+
aria-label={props['aria-label']}
|
|
37
|
+
aria-describedby={props.error === undefined ? undefined : summaryId}
|
|
38
|
+
onSubmit={props.onSubmit}
|
|
39
|
+
>
|
|
40
|
+
{props.error === undefined ? null : (
|
|
41
|
+
<div id={summaryId} tabindex="-1" class={styles['summary']}>
|
|
42
|
+
<Alert tone="danger" title={props.errorTitle}>
|
|
43
|
+
{props.error}
|
|
44
|
+
</Alert>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
{props.children}
|
|
48
|
+
{props.actions === undefined ? null : <div class={styles['actions']}>{props.actions}</div>}
|
|
49
|
+
</form>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Grid layout primitive. The default is an intrinsic responsive grid
|
|
2
|
+
// (`auto-fit` + `minmax`) so most layouts need no breakpoint at all.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './Grid.module.scss';
|
|
7
|
+
import type { SpaceStep } from './variants';
|
|
8
|
+
|
|
9
|
+
export interface GridProps {
|
|
10
|
+
children: JSX.Element;
|
|
11
|
+
/** Fixed track count. Omit for the intrinsic `auto-fit` behaviour. */
|
|
12
|
+
columns?: number | undefined;
|
|
13
|
+
/** Minimum track width for the intrinsic grid. */
|
|
14
|
+
minColumn?: string | undefined;
|
|
15
|
+
gap?: SpaceStep | undefined;
|
|
16
|
+
rowGap?: SpaceStep | undefined;
|
|
17
|
+
as?: 'div' | 'ul' | 'ol' | 'section' | undefined;
|
|
18
|
+
class?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Grid(props: GridProps): JSX.Element {
|
|
22
|
+
const Tag = props.as ?? 'div';
|
|
23
|
+
const tracks = (): string =>
|
|
24
|
+
props.columns === undefined
|
|
25
|
+
? `repeat(auto-fit, minmax(${props.minColumn ?? '16rem'}, 1fr))`
|
|
26
|
+
: `repeat(${props.columns}, minmax(0, 1fr))`;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Tag
|
|
30
|
+
class={cx(styles['grid'], props.class)}
|
|
31
|
+
style={{
|
|
32
|
+
'--grid-tracks': tracks(),
|
|
33
|
+
'--grid-gap': `var(--space-${props.gap ?? 4})`,
|
|
34
|
+
'--grid-row-gap': `var(--space-${props.rowGap ?? props.gap ?? 4})`,
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{props.children}
|
|
38
|
+
</Tag>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.iconButton {
|
|
4
|
+
@include t.focus-ring;
|
|
5
|
+
@include t.transition(background, color, border-color);
|
|
6
|
+
display: inline-grid;
|
|
7
|
+
place-items: center;
|
|
8
|
+
border: 1px solid transparent;
|
|
9
|
+
border-radius: t.radius(md);
|
|
10
|
+
color: t.role('fg-muted');
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
|
|
13
|
+
&:disabled {
|
|
14
|
+
opacity: 0.55;
|
|
15
|
+
cursor: not-allowed;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.round {
|
|
20
|
+
border-radius: t.radius(full);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.glyph {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
// Icons inherit the text colour so a tone change needs no icon variant.
|
|
26
|
+
color: currentcolor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.size-sm {
|
|
30
|
+
inline-size: 1.75rem;
|
|
31
|
+
block-size: 1.75rem;
|
|
32
|
+
font-size: t.text(sm);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.size-md {
|
|
36
|
+
inline-size: 2.25rem;
|
|
37
|
+
block-size: 2.25rem;
|
|
38
|
+
font-size: t.text(md);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.size-lg {
|
|
42
|
+
inline-size: 2.75rem;
|
|
43
|
+
block-size: 2.75rem;
|
|
44
|
+
font-size: t.text(lg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.tone-neutral {
|
|
48
|
+
--tone: #{t.role('fg-strong')};
|
|
49
|
+
--tone-soft: #{t.role('bg-soft')};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.tone-accent {
|
|
53
|
+
--tone: #{t.role('accent')};
|
|
54
|
+
--tone-soft: #{t.role('accent', 0.12)};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.tone-success {
|
|
58
|
+
--tone: #{t.role('success')};
|
|
59
|
+
--tone-soft: #{t.role('success-soft')};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.tone-warning {
|
|
63
|
+
--tone: #{t.role('warning')};
|
|
64
|
+
--tone-soft: #{t.role('warning-soft')};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.tone-danger {
|
|
68
|
+
--tone: #{t.role('danger')};
|
|
69
|
+
--tone-soft: #{t.role('danger-soft')};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.tone-info {
|
|
73
|
+
--tone: #{t.role('info')};
|
|
74
|
+
--tone-soft: #{t.role('info-soft')};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.variant-primary {
|
|
78
|
+
background: var(--tone);
|
|
79
|
+
color: t.role('accent-fg');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.variant-secondary {
|
|
83
|
+
border-color: t.role('line');
|
|
84
|
+
background: t.role('surface-raised');
|
|
85
|
+
|
|
86
|
+
&:hover:not(:disabled) {
|
|
87
|
+
color: var(--tone);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.variant-ghost,
|
|
92
|
+
.variant-link {
|
|
93
|
+
background: transparent;
|
|
94
|
+
|
|
95
|
+
&:hover:not(:disabled) {
|
|
96
|
+
background: var(--tone-soft);
|
|
97
|
+
color: var(--tone);
|
|
98
|
+
}
|
|
99
|
+
}
|