@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,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
+ }