@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,94 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.badge {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: t.space(1);
|
|
7
|
+
border: 1px solid transparent;
|
|
8
|
+
border-radius: t.radius(pill);
|
|
9
|
+
font-weight: t.weight(medium);
|
|
10
|
+
line-height: t.leading(tight);
|
|
11
|
+
white-space: nowrap;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.dot {
|
|
15
|
+
inline-size: 0.4rem;
|
|
16
|
+
block-size: 0.4rem;
|
|
17
|
+
border-radius: t.radius(full);
|
|
18
|
+
background: currentcolor;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.size-sm {
|
|
22
|
+
@include t.padding-inline(t.space(2));
|
|
23
|
+
padding-block: 0.0625rem;
|
|
24
|
+
font-size: t.text(xs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.size-md {
|
|
28
|
+
@include t.padding-inline(t.space(3));
|
|
29
|
+
padding-block: t.space(1);
|
|
30
|
+
font-size: t.text(xs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.size-lg {
|
|
34
|
+
@include t.padding-inline(t.space(4));
|
|
35
|
+
padding-block: t.space(1);
|
|
36
|
+
font-size: t.text(sm);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.tone-neutral {
|
|
40
|
+
--badge: #{t.role('fg-muted')};
|
|
41
|
+
--badge-soft: #{t.role('bg-soft')};
|
|
42
|
+
--badge-fg: #{t.role('fg-strong')};
|
|
43
|
+
--badge-on: #{t.role('bg')};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tone-accent {
|
|
47
|
+
--badge: #{t.role('accent')};
|
|
48
|
+
--badge-soft: #{t.role('accent', 0.14)};
|
|
49
|
+
--badge-fg: #{t.role('accent-strong')};
|
|
50
|
+
--badge-on: #{t.role('accent-fg')};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.tone-success {
|
|
54
|
+
--badge: #{t.role('success')};
|
|
55
|
+
--badge-soft: #{t.role('success-soft')};
|
|
56
|
+
--badge-fg: #{t.role('success')};
|
|
57
|
+
--badge-on: #{t.role('success-fg')};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.tone-warning {
|
|
61
|
+
--badge: #{t.role('warning')};
|
|
62
|
+
--badge-soft: #{t.role('warning-soft')};
|
|
63
|
+
--badge-fg: #{t.role('warning')};
|
|
64
|
+
--badge-on: #{t.role('warning-fg')};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.tone-danger {
|
|
68
|
+
--badge: #{t.role('danger')};
|
|
69
|
+
--badge-soft: #{t.role('danger-soft')};
|
|
70
|
+
--badge-fg: #{t.role('danger')};
|
|
71
|
+
--badge-on: #{t.role('danger-fg')};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.tone-info {
|
|
75
|
+
--badge: #{t.role('info')};
|
|
76
|
+
--badge-soft: #{t.role('info-soft')};
|
|
77
|
+
--badge-fg: #{t.role('info')};
|
|
78
|
+
--badge-on: #{t.role('info-fg')};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.variant-soft {
|
|
82
|
+
background: var(--badge-soft);
|
|
83
|
+
color: var(--badge-fg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.variant-solid {
|
|
87
|
+
background: var(--badge);
|
|
88
|
+
color: var(--badge-on);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.variant-outline {
|
|
92
|
+
border-color: var(--badge);
|
|
93
|
+
color: var(--badge-fg);
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Small status label. Tone maps straight onto the status colour roles so the
|
|
2
|
+
// same component is legible on both themes with no per-theme override.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './Badge.module.scss';
|
|
7
|
+
import type { Size, Tone } from './variants';
|
|
8
|
+
|
|
9
|
+
export interface BadgeProps {
|
|
10
|
+
children: JSX.Element;
|
|
11
|
+
tone?: Tone | undefined;
|
|
12
|
+
size?: Size | undefined;
|
|
13
|
+
variant?: 'soft' | 'solid' | 'outline' | undefined;
|
|
14
|
+
/** Renders a leading dot; pair with a tone that carries the meaning. */
|
|
15
|
+
dot?: boolean | undefined;
|
|
16
|
+
class?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Badge(props: BadgeProps): JSX.Element {
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
class={cx(
|
|
23
|
+
styles['badge'],
|
|
24
|
+
styles[`tone-${props.tone ?? 'neutral'}`],
|
|
25
|
+
styles[`variant-${props.variant ?? 'soft'}`],
|
|
26
|
+
styles[`size-${props.size ?? 'md'}`],
|
|
27
|
+
props.class,
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
{props.dot === true ? <span aria-hidden="true" class={styles['dot']} /> : null}
|
|
31
|
+
{props.children}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.breadcrumb {
|
|
4
|
+
font-size: t.text(sm);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.list {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-wrap: wrap;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: t.space(2);
|
|
12
|
+
list-style: none;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.item {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: t.space(2);
|
|
19
|
+
|
|
20
|
+
// The chevron is generated content, so it is never read out and it mirrors
|
|
21
|
+
// with the writing direction.
|
|
22
|
+
&:not(:first-child)::before {
|
|
23
|
+
color: t.role('fg-muted');
|
|
24
|
+
content: '/';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.link {
|
|
29
|
+
@include t.focus-ring;
|
|
30
|
+
border-radius: t.radius(sm);
|
|
31
|
+
color: t.role('fg-muted');
|
|
32
|
+
text-decoration: none;
|
|
33
|
+
|
|
34
|
+
&:hover {
|
|
35
|
+
color: t.role('accent');
|
|
36
|
+
text-decoration: underline;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.current {
|
|
41
|
+
@include t.truncate;
|
|
42
|
+
max-inline-size: 20rem;
|
|
43
|
+
color: t.role('fg-strong');
|
|
44
|
+
font-weight: t.weight(medium);
|
|
45
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Ancestor trail. The last item is the current page: rendered as text, never a
|
|
2
|
+
// link, and marked `aria-current="page"`. Separators are decorative CSS.
|
|
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 './Breadcrumb.module.scss';
|
|
9
|
+
|
|
10
|
+
export interface BreadcrumbItem {
|
|
11
|
+
/** Already-translated label. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Omit on the final item — the current page is not a link. */
|
|
14
|
+
href?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BreadcrumbProps {
|
|
18
|
+
items: readonly BreadcrumbItem[];
|
|
19
|
+
/** Overrides the translated `ui.breadcrumb` landmark name. */
|
|
20
|
+
label?: string | undefined;
|
|
21
|
+
class?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Breadcrumb(props: BreadcrumbProps): JSX.Element {
|
|
25
|
+
const ui = useUi();
|
|
26
|
+
return (
|
|
27
|
+
<nav
|
|
28
|
+
class={cx(styles['breadcrumb'], props.class)}
|
|
29
|
+
aria-label={props.label ?? ui.t(UI_KEYS.breadcrumb)}
|
|
30
|
+
>
|
|
31
|
+
<ol class={styles['list']}>
|
|
32
|
+
{props.items.map((item, index) => (
|
|
33
|
+
<li class={styles['item']}>
|
|
34
|
+
{item.href === undefined || index === props.items.length - 1 ? (
|
|
35
|
+
<span class={styles['current']} aria-current="page">
|
|
36
|
+
{item.label}
|
|
37
|
+
</span>
|
|
38
|
+
) : (
|
|
39
|
+
<a class={styles['link']} href={item.href}>
|
|
40
|
+
{item.label}
|
|
41
|
+
</a>
|
|
42
|
+
)}
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
</ol>
|
|
46
|
+
</nav>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.button {
|
|
4
|
+
@include t.focus-ring;
|
|
5
|
+
@include t.transition(background, border-color, color, box-shadow, transform);
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
gap: t.space(2);
|
|
10
|
+
border: 1px solid transparent;
|
|
11
|
+
border-radius: t.radius(md);
|
|
12
|
+
font-weight: t.weight(semibold);
|
|
13
|
+
line-height: t.leading(tight);
|
|
14
|
+
text-decoration: none;
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
cursor: pointer;
|
|
17
|
+
|
|
18
|
+
&:active:not(:disabled) {
|
|
19
|
+
transform: translateY(1px);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&:disabled,
|
|
23
|
+
&[aria-disabled='true'] {
|
|
24
|
+
opacity: 0.55;
|
|
25
|
+
cursor: not-allowed;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.full {
|
|
30
|
+
inline-size: 100%;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.label {
|
|
34
|
+
@include t.truncate;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.spinner {
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- sizes -------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
.size-sm {
|
|
44
|
+
@include t.padding-inline(t.space(3));
|
|
45
|
+
padding-block: t.space(1);
|
|
46
|
+
font-size: t.text(sm);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.size-md {
|
|
50
|
+
@include t.padding-inline(t.space(4));
|
|
51
|
+
padding-block: t.space(2);
|
|
52
|
+
font-size: t.text(md);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.size-lg {
|
|
56
|
+
@include t.padding-inline(t.space(6));
|
|
57
|
+
padding-block: t.space(3);
|
|
58
|
+
font-size: t.text(lg);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- variants x tones --------------------------------------------------------
|
|
62
|
+
// `--tone-*` is set by the tone class, so each variant is written once.
|
|
63
|
+
|
|
64
|
+
.tone-neutral {
|
|
65
|
+
--tone: #{t.role('fg-strong')};
|
|
66
|
+
--tone-strong: #{t.role('fg')};
|
|
67
|
+
--tone-fg: #{t.role('bg')};
|
|
68
|
+
--tone-soft: #{t.role('bg-soft')};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.tone-accent {
|
|
72
|
+
--tone: #{t.role('accent')};
|
|
73
|
+
--tone-strong: #{t.role('accent-strong')};
|
|
74
|
+
--tone-fg: #{t.role('accent-fg')};
|
|
75
|
+
--tone-soft: #{t.role('accent', 0.12)};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.tone-success {
|
|
79
|
+
--tone: #{t.role('success')};
|
|
80
|
+
--tone-strong: #{t.role('success')};
|
|
81
|
+
--tone-fg: #{t.role('success-fg')};
|
|
82
|
+
--tone-soft: #{t.role('success-soft')};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.tone-warning {
|
|
86
|
+
--tone: #{t.role('warning')};
|
|
87
|
+
--tone-strong: #{t.role('warning')};
|
|
88
|
+
--tone-fg: #{t.role('warning-fg')};
|
|
89
|
+
--tone-soft: #{t.role('warning-soft')};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.tone-danger {
|
|
93
|
+
--tone: #{t.role('danger')};
|
|
94
|
+
--tone-strong: #{t.role('danger')};
|
|
95
|
+
--tone-fg: #{t.role('danger-fg')};
|
|
96
|
+
--tone-soft: #{t.role('danger-soft')};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.tone-info {
|
|
100
|
+
--tone: #{t.role('info')};
|
|
101
|
+
--tone-strong: #{t.role('info')};
|
|
102
|
+
--tone-fg: #{t.role('info-fg')};
|
|
103
|
+
--tone-soft: #{t.role('info-soft')};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.variant-primary {
|
|
107
|
+
background: var(--tone);
|
|
108
|
+
color: var(--tone-fg);
|
|
109
|
+
|
|
110
|
+
&:hover:not(:disabled, [aria-disabled='true']) {
|
|
111
|
+
background: var(--tone-strong);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.variant-secondary {
|
|
116
|
+
border-color: t.role('line');
|
|
117
|
+
background: t.role('surface-raised');
|
|
118
|
+
color: t.role('fg-strong');
|
|
119
|
+
|
|
120
|
+
&:hover:not(:disabled, [aria-disabled='true']) {
|
|
121
|
+
border-color: var(--tone);
|
|
122
|
+
background: var(--tone-soft);
|
|
123
|
+
color: var(--tone);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.variant-ghost {
|
|
128
|
+
background: transparent;
|
|
129
|
+
color: var(--tone);
|
|
130
|
+
|
|
131
|
+
&:hover:not(:disabled, [aria-disabled='true']) {
|
|
132
|
+
background: var(--tone-soft);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.variant-link {
|
|
137
|
+
@include t.padding-inline(0);
|
|
138
|
+
background: transparent;
|
|
139
|
+
color: var(--tone);
|
|
140
|
+
text-decoration: underline;
|
|
141
|
+
text-underline-offset: 0.2em;
|
|
142
|
+
|
|
143
|
+
&:hover:not(:disabled, [aria-disabled='true']) {
|
|
144
|
+
color: var(--tone-strong);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one button. Variants and tones are token-driven, so dark mode and RTL
|
|
2
|
+
// need no extra rules; `loading` keeps the label mounted to avoid a layout jump.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { ariaBool } from '../a11y';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './Button.module.scss';
|
|
8
|
+
import { Spinner } from './Spinner';
|
|
9
|
+
import type { ButtonVariant, Size, Tone } from './variants';
|
|
10
|
+
|
|
11
|
+
export interface ButtonProps {
|
|
12
|
+
/** Visible label. Never hardcoded inside the component. */
|
|
13
|
+
children: JSX.Element;
|
|
14
|
+
variant?: ButtonVariant | undefined;
|
|
15
|
+
tone?: Tone | undefined;
|
|
16
|
+
size?: Size | undefined;
|
|
17
|
+
type?: 'button' | 'submit' | 'reset' | undefined;
|
|
18
|
+
disabled?: boolean | undefined;
|
|
19
|
+
/** Blocks interaction and announces progress via `aria-busy`. */
|
|
20
|
+
loading?: boolean | undefined;
|
|
21
|
+
fullWidth?: boolean | undefined;
|
|
22
|
+
iconStart?: JSX.Element | undefined;
|
|
23
|
+
iconEnd?: JSX.Element | undefined;
|
|
24
|
+
id?: string | undefined;
|
|
25
|
+
class?: string | undefined;
|
|
26
|
+
'aria-label'?: string | undefined;
|
|
27
|
+
'aria-controls'?: string | undefined;
|
|
28
|
+
'aria-expanded'?: boolean | undefined;
|
|
29
|
+
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Button(props: ButtonProps): JSX.Element {
|
|
33
|
+
const inert = (): boolean => props.disabled === true || props.loading === true;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
id={props.id}
|
|
38
|
+
type={props.type ?? 'button'}
|
|
39
|
+
class={cx(
|
|
40
|
+
styles['button'],
|
|
41
|
+
styles[`variant-${props.variant ?? 'primary'}`],
|
|
42
|
+
styles[`tone-${props.tone ?? 'accent'}`],
|
|
43
|
+
styles[`size-${props.size ?? 'md'}`],
|
|
44
|
+
props.fullWidth === true && styles['full'],
|
|
45
|
+
props.class,
|
|
46
|
+
)}
|
|
47
|
+
disabled={inert()}
|
|
48
|
+
aria-disabled={ariaBool(inert())}
|
|
49
|
+
aria-busy={ariaBool(props.loading === true)}
|
|
50
|
+
aria-label={props['aria-label']}
|
|
51
|
+
aria-controls={props['aria-controls']}
|
|
52
|
+
aria-expanded={ariaBool(props['aria-expanded'])}
|
|
53
|
+
onClick={props.onClick}
|
|
54
|
+
>
|
|
55
|
+
{props.loading === true ? (
|
|
56
|
+
<span class={styles['spinner']}>
|
|
57
|
+
<Spinner size="sm" />
|
|
58
|
+
</span>
|
|
59
|
+
) : (
|
|
60
|
+
props.iconStart
|
|
61
|
+
)}
|
|
62
|
+
<span class={styles['label']}>{props.children}</span>
|
|
63
|
+
{props.iconEnd}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.card {
|
|
4
|
+
@include t.container(card);
|
|
5
|
+
@include t.transition(box-shadow, border-color, transform);
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
border: 1px solid t.role('line');
|
|
9
|
+
border-radius: t.radius(lg);
|
|
10
|
+
background: t.role('surface');
|
|
11
|
+
color: t.role('fg');
|
|
12
|
+
overflow: clip;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.interactive {
|
|
16
|
+
cursor: pointer;
|
|
17
|
+
|
|
18
|
+
&:hover {
|
|
19
|
+
border-color: t.role('accent', 0.4);
|
|
20
|
+
transform: translateY(-2px);
|
|
21
|
+
box-shadow: t.shadow(md);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&:focus-within {
|
|
25
|
+
border-color: t.role('accent');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.header,
|
|
30
|
+
.footer {
|
|
31
|
+
@include t.padding-inline(var(--card-padding));
|
|
32
|
+
padding-block: t.space(3);
|
|
33
|
+
background: t.role('bg-soft');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.header {
|
|
37
|
+
border-block-end: 1px solid t.role('line');
|
|
38
|
+
color: t.role('fg-strong');
|
|
39
|
+
font-weight: t.weight(semibold);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.footer {
|
|
43
|
+
border-block-start: 1px solid t.role('line');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.body {
|
|
47
|
+
padding: var(--card-padding, #{t.space(5)});
|
|
48
|
+
flex: 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.elevation-flat {
|
|
52
|
+
box-shadow: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.elevation-xs {
|
|
56
|
+
box-shadow: t.shadow(xs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.elevation-sm {
|
|
60
|
+
box-shadow: t.shadow(sm);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.elevation-md {
|
|
64
|
+
box-shadow: t.shadow(md);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.elevation-lg {
|
|
68
|
+
box-shadow: t.shadow(lg);
|
|
69
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Surface container. Elevation is a token rung, and because the shadow tokens
|
|
2
|
+
// are themed the same `elevation` reads correctly on light and dark.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './Card.module.scss';
|
|
7
|
+
import type { SpaceStep } from './variants';
|
|
8
|
+
|
|
9
|
+
export type Elevation = 'flat' | 'xs' | 'sm' | 'md' | 'lg';
|
|
10
|
+
|
|
11
|
+
export interface CardProps {
|
|
12
|
+
children: JSX.Element;
|
|
13
|
+
header?: JSX.Element | undefined;
|
|
14
|
+
footer?: JSX.Element | undefined;
|
|
15
|
+
elevation?: Elevation | undefined;
|
|
16
|
+
padding?: SpaceStep | undefined;
|
|
17
|
+
/** Adds hover affordance. Only use when the whole card is a link/button. */
|
|
18
|
+
interactive?: boolean | undefined;
|
|
19
|
+
as?: 'div' | 'article' | 'section' | 'li' | undefined;
|
|
20
|
+
class?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Card(props: CardProps): JSX.Element {
|
|
24
|
+
const Tag = props.as ?? 'div';
|
|
25
|
+
return (
|
|
26
|
+
<Tag
|
|
27
|
+
class={cx(
|
|
28
|
+
styles['card'],
|
|
29
|
+
styles[`elevation-${props.elevation ?? 'sm'}`],
|
|
30
|
+
props.interactive === true && styles['interactive'],
|
|
31
|
+
props.class,
|
|
32
|
+
)}
|
|
33
|
+
style={{ '--card-padding': `var(--space-${props.padding ?? 5})` }}
|
|
34
|
+
>
|
|
35
|
+
{props.header === undefined ? null : <div class={styles['header']}>{props.header}</div>}
|
|
36
|
+
<div class={styles['body']}>{props.children}</div>
|
|
37
|
+
{props.footer === undefined ? null : <div class={styles['footer']}>{props.footer}</div>}
|
|
38
|
+
</Tag>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.row {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: flex-start;
|
|
6
|
+
gap: t.space(2);
|
|
7
|
+
cursor: pointer;
|
|
8
|
+
|
|
9
|
+
&:has(:disabled) {
|
|
10
|
+
color: t.role('fg-muted');
|
|
11
|
+
cursor: not-allowed;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The real control stays in the a11y tree and drives the visual box via :checked.
|
|
16
|
+
.input {
|
|
17
|
+
@include t.visually-hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.box {
|
|
21
|
+
@include t.transition(background, border-color, box-shadow);
|
|
22
|
+
position: relative;
|
|
23
|
+
flex: none;
|
|
24
|
+
inline-size: 1.05rem;
|
|
25
|
+
block-size: 1.05rem;
|
|
26
|
+
margin-block-start: 0.15rem;
|
|
27
|
+
border: 1px solid t.role('line');
|
|
28
|
+
border-radius: t.radius(sm);
|
|
29
|
+
background: t.role('surface-raised');
|
|
30
|
+
|
|
31
|
+
&::after {
|
|
32
|
+
position: absolute;
|
|
33
|
+
inset-inline-start: 0.28rem;
|
|
34
|
+
inset-block-start: 0.1rem;
|
|
35
|
+
inline-size: 0.3rem;
|
|
36
|
+
block-size: 0.55rem;
|
|
37
|
+
border-inline-end: 2px solid t.role('accent-fg');
|
|
38
|
+
border-block-end: 2px solid t.role('accent-fg');
|
|
39
|
+
transform: rotate(40deg) scale(0);
|
|
40
|
+
transform-origin: center;
|
|
41
|
+
content: '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.input:checked + .box {
|
|
46
|
+
border-color: t.role('accent');
|
|
47
|
+
background: t.role('accent');
|
|
48
|
+
|
|
49
|
+
&::after {
|
|
50
|
+
transform: rotate(40deg) scale(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.box[data-mixed='true'] {
|
|
55
|
+
border-color: t.role('accent');
|
|
56
|
+
background: t.role('accent');
|
|
57
|
+
|
|
58
|
+
&::after {
|
|
59
|
+
inset-inline-start: 0.2rem;
|
|
60
|
+
inset-block-start: 0.42rem;
|
|
61
|
+
inline-size: 0.55rem;
|
|
62
|
+
block-size: 0;
|
|
63
|
+
border-inline-end: 0;
|
|
64
|
+
transform: none;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.input:focus-visible + .box {
|
|
69
|
+
outline: 2px solid t.role('accent');
|
|
70
|
+
outline-offset: 2px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.input[aria-invalid='true'] + .box {
|
|
74
|
+
border-color: t.role('danger');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.text {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: 0.1rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.label {
|
|
84
|
+
font-size: t.text(sm);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.description {
|
|
88
|
+
color: t.role('fg-muted');
|
|
89
|
+
font-size: t.text(xs);
|
|
90
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Native checkbox with a token-drawn indicator. The label element wraps the
|
|
2
|
+
// input, so the whole row is a hit target without a manual `for`/`id` pairing.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { ariaBool } from '../a11y';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './Checkbox.module.scss';
|
|
8
|
+
|
|
9
|
+
export interface CheckboxProps {
|
|
10
|
+
/** Already-translated label. Required — an unlabelled checkbox is a bug. */
|
|
11
|
+
label: string;
|
|
12
|
+
id?: string | undefined;
|
|
13
|
+
name?: string | undefined;
|
|
14
|
+
value?: string | undefined;
|
|
15
|
+
checked?: boolean | undefined;
|
|
16
|
+
/** Tri-state for "some children selected". Mirrored to `aria-checked`. */
|
|
17
|
+
indeterminate?: boolean | undefined;
|
|
18
|
+
disabled?: boolean | undefined;
|
|
19
|
+
required?: boolean | undefined;
|
|
20
|
+
description?: string | undefined;
|
|
21
|
+
class?: string | undefined;
|
|
22
|
+
'aria-describedby'?: string | undefined;
|
|
23
|
+
'aria-invalid'?: boolean | undefined;
|
|
24
|
+
onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event> | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Checkbox(props: CheckboxProps): JSX.Element {
|
|
28
|
+
return (
|
|
29
|
+
<label class={cx(styles['row'], props.class)}>
|
|
30
|
+
<input
|
|
31
|
+
class={styles['input']}
|
|
32
|
+
type="checkbox"
|
|
33
|
+
id={props.id}
|
|
34
|
+
name={props.name}
|
|
35
|
+
value={props.value}
|
|
36
|
+
checked={props.checked === true}
|
|
37
|
+
disabled={props.disabled === true}
|
|
38
|
+
required={props.required === true}
|
|
39
|
+
aria-checked={props.indeterminate === true ? 'mixed' : ariaBool(props.checked === true)}
|
|
40
|
+
aria-describedby={props['aria-describedby']}
|
|
41
|
+
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
42
|
+
onChange={props.onChange}
|
|
43
|
+
/>
|
|
44
|
+
<span
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
class={styles['box']}
|
|
47
|
+
data-mixed={props.indeterminate === true ? 'true' : undefined}
|
|
48
|
+
/>
|
|
49
|
+
<span class={styles['text']}>
|
|
50
|
+
<span class={styles['label']}>{props.label}</span>
|
|
51
|
+
{props.description === undefined ? null : (
|
|
52
|
+
<span class={styles['description']}>{props.description}</span>
|
|
53
|
+
)}
|
|
54
|
+
</span>
|
|
55
|
+
</label>
|
|
56
|
+
);
|
|
57
|
+
}
|