@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,35 @@
1
+ // Indeterminate progress. `role="status"` plus a translated name, because a
2
+ // spinner with no accessible name is silence to a screen reader.
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 './Spinner.module.scss';
9
+ import type { Size } from './variants';
10
+
11
+ export interface SpinnerProps {
12
+ size?: Size | undefined;
13
+ /** Overrides the translated `ui.loading` default. */
14
+ label?: string | undefined;
15
+ /** Hide from assistive tech when a parent already announces the busy state. */
16
+ decorative?: boolean | undefined;
17
+ class?: string | undefined;
18
+ }
19
+
20
+ export function Spinner(props: SpinnerProps): JSX.Element {
21
+ const ui = useUi();
22
+ const className = (): string =>
23
+ cx(styles['spinner'], styles[`size-${props.size ?? 'md'}`], props.class);
24
+
25
+ return props.decorative === true ? (
26
+ <span aria-hidden="true" class={className()} />
27
+ ) : (
28
+ <span
29
+ role="status"
30
+ aria-live="polite"
31
+ aria-label={props.label ?? ui.t(UI_KEYS.loading)}
32
+ class={className()}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,21 @@
1
+ @use '../tokens' as t;
2
+
3
+ .stack {
4
+ display: flex;
5
+ gap: var(--stack-gap, #{t.space(4)});
6
+ align-items: var(--stack-align, stretch);
7
+ justify-content: var(--stack-justify, flex-start);
8
+ list-style: none;
9
+ }
10
+
11
+ .direction-row {
12
+ flex-direction: row;
13
+ }
14
+
15
+ .direction-column {
16
+ flex-direction: column;
17
+ }
18
+
19
+ .wrap {
20
+ flex-wrap: wrap;
21
+ }
@@ -0,0 +1,49 @@
1
+ // Flex layout primitive. Gap comes from the space scale as a custom property, so
2
+ // there is no class per step, and `row` uses `flex-direction: row` — which the
3
+ // browser already mirrors under `dir="rtl"`.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import styles from './Stack.module.scss';
8
+ import type { Align, SpaceStep } from './variants';
9
+
10
+ export interface StackProps {
11
+ children: JSX.Element;
12
+ direction?: 'row' | 'column' | undefined;
13
+ gap?: SpaceStep | undefined;
14
+ align?: Align | undefined;
15
+ justify?: Align | undefined;
16
+ wrap?: boolean | undefined;
17
+ /** Renders a semantic element instead of a div. */
18
+ as?: 'div' | 'ul' | 'ol' | 'nav' | 'section' | 'header' | 'footer' | undefined;
19
+ class?: string | undefined;
20
+ }
21
+
22
+ const ALIGN: Readonly<Record<Align, string>> = {
23
+ start: 'flex-start',
24
+ center: 'center',
25
+ end: 'flex-end',
26
+ stretch: 'stretch',
27
+ between: 'space-between',
28
+ };
29
+
30
+ export function Stack(props: StackProps): JSX.Element {
31
+ const Tag = props.as ?? 'div';
32
+ return (
33
+ <Tag
34
+ class={cx(
35
+ styles['stack'],
36
+ styles[`direction-${props.direction ?? 'column'}`],
37
+ props.wrap === true && styles['wrap'],
38
+ props.class,
39
+ )}
40
+ style={{
41
+ '--stack-gap': `var(--space-${props.gap ?? 4})`,
42
+ '--stack-align': ALIGN[props.align ?? 'stretch'],
43
+ '--stack-justify': ALIGN[props.justify ?? 'start'],
44
+ }}
45
+ >
46
+ {props.children}
47
+ </Tag>
48
+ );
49
+ }
@@ -0,0 +1,66 @@
1
+ @use '../tokens' as t;
2
+
3
+ .row {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: t.space(2);
7
+ cursor: pointer;
8
+
9
+ &:has(:disabled) {
10
+ opacity: 0.6;
11
+ cursor: not-allowed;
12
+ }
13
+ }
14
+
15
+ // Order swaps with `order`, so RTL still mirrors correctly.
16
+ .label-start .text {
17
+ order: -1;
18
+ }
19
+
20
+ .input {
21
+ @include t.visually-hidden;
22
+ }
23
+
24
+ .track {
25
+ @include t.transition(background);
26
+ position: relative;
27
+ flex: none;
28
+ inline-size: 2.25rem;
29
+ block-size: 1.25rem;
30
+ border-radius: t.radius(pill);
31
+ background: t.role('line');
32
+ }
33
+
34
+ .knob {
35
+ @include t.transition(translate);
36
+ position: absolute;
37
+ inset-block-start: 0.125rem;
38
+ inset-inline-start: 0.125rem;
39
+ inline-size: 1rem;
40
+ block-size: 1rem;
41
+ border-radius: t.radius(full);
42
+ background: t.role('surface-raised');
43
+ box-shadow: t.shadow(xs);
44
+ }
45
+
46
+ .input:checked + .track {
47
+ background: t.role('accent');
48
+ }
49
+
50
+ // `translate` with a logical sign flip: RTL mirrors via the `dir` attribute.
51
+ .input:checked + .track .knob {
52
+ translate: 1rem 0;
53
+ }
54
+
55
+ [dir='rtl'] .input:checked + .track .knob {
56
+ translate: -1rem 0;
57
+ }
58
+
59
+ .input:focus-visible + .track {
60
+ outline: 2px solid t.role('accent');
61
+ outline-offset: 2px;
62
+ }
63
+
64
+ .text {
65
+ font-size: t.text(sm);
66
+ }
@@ -0,0 +1,44 @@
1
+ // Boolean toggle with immediate effect (as opposed to Checkbox, which is part of
2
+ // a form submit). `role="switch"` on a native checkbox keeps keyboard behaviour.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { ariaBool } from '../a11y';
6
+ import { cx } from '../cx';
7
+ import styles from './Switch.module.scss';
8
+
9
+ export interface SwitchProps {
10
+ /** Already-translated label. Required — the state alone is not a name. */
11
+ label: string;
12
+ checked?: boolean | undefined;
13
+ id?: string | undefined;
14
+ name?: string | undefined;
15
+ disabled?: boolean | undefined;
16
+ /** Put the label before the track, e.g. in a settings row. */
17
+ labelPosition?: 'start' | 'end' | undefined;
18
+ class?: string | undefined;
19
+ 'aria-describedby'?: string | undefined;
20
+ onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event> | undefined;
21
+ }
22
+
23
+ export function Switch(props: SwitchProps): JSX.Element {
24
+ return (
25
+ <label class={cx(styles['row'], styles[`label-${props.labelPosition ?? 'end'}`], props.class)}>
26
+ <input
27
+ class={styles['input']}
28
+ type="checkbox"
29
+ role="switch"
30
+ id={props.id}
31
+ name={props.name}
32
+ checked={props.checked === true}
33
+ disabled={props.disabled === true}
34
+ aria-checked={ariaBool(props.checked === true)}
35
+ aria-describedby={props['aria-describedby']}
36
+ onChange={props.onChange}
37
+ />
38
+ <span aria-hidden="true" class={styles['track']}>
39
+ <span class={styles['knob']} />
40
+ </span>
41
+ <span class={styles['text']}>{props.label}</span>
42
+ </label>
43
+ );
44
+ }
@@ -0,0 +1,77 @@
1
+ @use '../tokens' as t;
2
+
3
+ // Overflow is contained here, never on <body>.
4
+ .scroller {
5
+ @include t.custom-scrollbar;
6
+ max-inline-size: 100%;
7
+ overflow-x: auto;
8
+ border: 1px solid t.role('line');
9
+ border-radius: t.radius(lg);
10
+ background: t.role('surface');
11
+
12
+ &:focus-visible {
13
+ outline: 2px solid t.role('accent');
14
+ outline-offset: -2px;
15
+ }
16
+ }
17
+
18
+ .table {
19
+ inline-size: 100%;
20
+ border-collapse: separate;
21
+ border-spacing: 0;
22
+ font-size: t.text(sm);
23
+ // Cells decide their own width; the scroller handles the excess.
24
+ white-space: nowrap;
25
+
26
+ :global(th),
27
+ :global(td) {
28
+ @include t.padding-inline(t.space(4));
29
+ // Logical alignment, so numbers stay trailing in both directions.
30
+ text-align: start;
31
+ border-block-end: 1px solid t.role('line');
32
+ vertical-align: middle;
33
+ }
34
+
35
+ :global(th) {
36
+ color: t.role('fg-strong');
37
+ font-weight: t.weight(semibold);
38
+ background: t.role('bg-soft');
39
+ }
40
+
41
+ :global(tbody tr:last-child td) {
42
+ border-block-end: 0;
43
+ }
44
+ }
45
+
46
+ .caption {
47
+ @include t.padding-inline(t.space(4));
48
+ padding-block: t.space(3);
49
+ color: t.role('fg-muted');
50
+ font-size: t.text(xs);
51
+ text-align: start;
52
+ caption-side: top;
53
+ }
54
+
55
+ .srOnly {
56
+ @include t.visually-hidden;
57
+ }
58
+
59
+ .density-comfortable :global(th),
60
+ .density-comfortable :global(td) {
61
+ padding-block: t.space(3);
62
+ }
63
+
64
+ .density-compact :global(th),
65
+ .density-compact :global(td) {
66
+ padding-block: t.space(1);
67
+ }
68
+
69
+ .sticky :global(thead th) {
70
+ position: sticky;
71
+ inset-block-start: 0;
72
+ z-index: t.z(sticky);
73
+ }
74
+
75
+ .striped :global(tbody tr:nth-child(even) td) {
76
+ background: t.role('bg-soft', 0.6);
77
+ }
@@ -0,0 +1,42 @@
1
+ // Presentational table. Owns two things every hand-rolled table gets wrong:
2
+ // a sticky header, and horizontal overflow contained inside the table's own
3
+ // scroll container so the page body never scrolls sideways.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import styles from './Table.module.scss';
8
+
9
+ export interface TableProps {
10
+ /** Already-translated caption. Required: a table needs an accessible name. */
11
+ caption: string;
12
+ children: JSX.Element;
13
+ /** Hide the caption visually while keeping it for assistive tech. */
14
+ hideCaption?: boolean | undefined;
15
+ stickyHeader?: boolean | undefined;
16
+ density?: 'comfortable' | 'compact' | undefined;
17
+ /** Zebra striping. Off by default — a border is usually enough. */
18
+ striped?: boolean | undefined;
19
+ class?: string | undefined;
20
+ }
21
+
22
+ export function Table(props: TableProps): JSX.Element {
23
+ return (
24
+ // tabindex makes the scroll region keyboard-reachable, which is required
25
+ // whenever a scrollable element has no focusable children.
26
+ <section class={cx(styles['scroller'], props.class)} tabindex="0" aria-label={props.caption}>
27
+ <table
28
+ class={cx(
29
+ styles['table'],
30
+ styles[`density-${props.density ?? 'comfortable'}`],
31
+ props.stickyHeader !== false && styles['sticky'],
32
+ props.striped === true && styles['striped'],
33
+ )}
34
+ >
35
+ <caption class={props.hideCaption === true ? styles['srOnly'] : styles['caption']}>
36
+ {props.caption}
37
+ </caption>
38
+ {props.children}
39
+ </table>
40
+ </section>
41
+ );
42
+ }
@@ -0,0 +1,71 @@
1
+ @use '../tokens' as t;
2
+
3
+ .tabs {
4
+ display: flex;
5
+ gap: t.space(4);
6
+ }
7
+
8
+ .orientation-horizontal {
9
+ flex-direction: column;
10
+ }
11
+
12
+ .orientation-vertical {
13
+ flex-direction: row;
14
+
15
+ .list {
16
+ flex-direction: column;
17
+ border-block-end: 0;
18
+ border-inline-end: 1px solid t.role('line');
19
+ }
20
+
21
+ .tab[aria-selected='true'] {
22
+ box-shadow: inset -2px 0 0 0 t.role('accent');
23
+ }
24
+ }
25
+
26
+ .list {
27
+ @include t.custom-scrollbar;
28
+ display: flex;
29
+ gap: t.space(1);
30
+ border-block-end: 1px solid t.role('line');
31
+ overflow-x: auto;
32
+ }
33
+
34
+ .tab {
35
+ @include t.focus-ring(('accent'), 2px, -2px);
36
+ @include t.transition(color, box-shadow, background);
37
+ padding-block: t.space(2);
38
+ padding-inline: t.space(3);
39
+ border-start-start-radius: t.radius(sm);
40
+ border-start-end-radius: t.radius(sm);
41
+ color: t.role('fg-muted');
42
+ font-size: t.text(sm);
43
+ font-weight: t.weight(medium);
44
+ white-space: nowrap;
45
+ cursor: pointer;
46
+
47
+ &:hover:not(:disabled) {
48
+ color: t.role('fg-strong');
49
+ background: t.role('bg-soft');
50
+ }
51
+
52
+ &:disabled {
53
+ opacity: 0.5;
54
+ cursor: not-allowed;
55
+ }
56
+
57
+ &[aria-selected='true'] {
58
+ color: t.role('accent');
59
+ // Inset shadow instead of a border, so the tab does not shift on select.
60
+ box-shadow: inset 0 -2px 0 0 t.role('accent');
61
+ }
62
+ }
63
+
64
+ .panel {
65
+ min-inline-size: 0;
66
+
67
+ &:focus-visible {
68
+ outline: 2px solid t.role('accent');
69
+ outline-offset: 2px;
70
+ }
71
+ }
@@ -0,0 +1,88 @@
1
+ // Tablist with roving tabindex: one Tab in the page tab order, arrows move
2
+ // between them, Home/End jump to the ends, and arrow direction follows `dir`.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { ariaBool, createRovingTabindex, useId } from '../a11y';
6
+ import { cx } from '../cx';
7
+ import { useUi } from '../theme/context';
8
+ import styles from './Tabs.module.scss';
9
+
10
+ export interface TabItem {
11
+ id: string;
12
+ /** Already-translated tab label. */
13
+ label: string;
14
+ panel: JSX.Element;
15
+ disabled?: boolean | undefined;
16
+ }
17
+
18
+ export interface TabsProps {
19
+ items: readonly TabItem[];
20
+ /** Selected tab id. Controlled — the caller owns the state. */
21
+ value: string;
22
+ onChange: (id: string) => void;
23
+ /** Already-translated accessible name for the tablist. */
24
+ label: string;
25
+ orientation?: 'horizontal' | 'vertical' | undefined;
26
+ class?: string | undefined;
27
+ }
28
+
29
+ export function Tabs(props: TabsProps): JSX.Element {
30
+ const ui = useUi();
31
+ const base = useId('tabs');
32
+ let list: HTMLDivElement | undefined;
33
+
34
+ const onKeyDown = createRovingTabindex(
35
+ () =>
36
+ list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>('[role="tab"]')),
37
+ { orientation: props.orientation ?? 'horizontal', dir: ui.dir },
38
+ );
39
+
40
+ return (
41
+ <div
42
+ class={cx(
43
+ styles['tabs'],
44
+ styles[`orientation-${props.orientation ?? 'horizontal'}`],
45
+ props.class,
46
+ )}
47
+ >
48
+ <div
49
+ ref={(el: HTMLDivElement) => {
50
+ list = el;
51
+ }}
52
+ class={styles['list']}
53
+ role="tablist"
54
+ aria-label={props.label}
55
+ aria-orientation={props.orientation ?? 'horizontal'}
56
+ onKeyDown={onKeyDown}
57
+ >
58
+ {props.items.map((item) => (
59
+ <button
60
+ type="button"
61
+ role="tab"
62
+ id={`${base}-tab-${item.id}`}
63
+ class={styles['tab']}
64
+ aria-selected={ariaBool(props.value === item.id)}
65
+ aria-controls={`${base}-panel-${item.id}`}
66
+ tabindex={props.value === item.id ? 0 : -1}
67
+ disabled={item.disabled === true}
68
+ onClick={() => props.onChange(item.id)}
69
+ >
70
+ {item.label}
71
+ </button>
72
+ ))}
73
+ </div>
74
+ {props.items.map((item) => (
75
+ <div
76
+ role="tabpanel"
77
+ id={`${base}-panel-${item.id}`}
78
+ class={styles['panel']}
79
+ aria-labelledby={`${base}-tab-${item.id}`}
80
+ tabindex={0}
81
+ hidden={props.value !== item.id}
82
+ >
83
+ {item.panel}
84
+ </div>
85
+ ))}
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,37 @@
1
+ @use '../tokens' as t;
2
+
3
+ .text {
4
+ // Unset size and weight inherit, so <Text> is transparent inside a heading,
5
+ // a caption, or a control that already set the scale.
6
+ font-size: var(--text-scale, inherit);
7
+ font-weight: var(--text-strength, inherit);
8
+ line-height: t.leading(normal);
9
+ }
10
+
11
+ .tone-default {
12
+ color: t.role('fg');
13
+ }
14
+
15
+ .tone-muted {
16
+ color: t.role('fg-muted');
17
+ }
18
+
19
+ .tone-accent {
20
+ color: t.role('accent');
21
+ }
22
+
23
+ .tone-success {
24
+ color: t.role('success');
25
+ }
26
+
27
+ .tone-warning {
28
+ color: t.role('warning');
29
+ }
30
+
31
+ .tone-danger {
32
+ color: t.role('danger');
33
+ }
34
+
35
+ .tone-info {
36
+ color: t.role('info');
37
+ }
@@ -0,0 +1,57 @@
1
+ // Typography primitive. Tone is a foreground or status colour role, size and
2
+ // weight are keys of the type scale — so body copy, captions and inline status
3
+ // text come from one component and stay legible on both themes.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import type { fontSizeTokens, fontWeightTokens } from '../tokens/tokens';
8
+ import styles from './Text.module.scss';
9
+
10
+ /** Maps 1:1 onto the foreground and status colour roles in `_colors.scss`. */
11
+ export const TEXT_TONES = [
12
+ 'default',
13
+ 'muted',
14
+ 'accent',
15
+ 'success',
16
+ 'warning',
17
+ 'danger',
18
+ 'info',
19
+ ] as const;
20
+ export type TextTone = (typeof TEXT_TONES)[number];
21
+
22
+ /** Keyed off the token maps, so a new rung is added once, in `_typography.scss`. */
23
+ export type TextSize = keyof typeof fontSizeTokens;
24
+ export type TextWeight = keyof typeof fontWeightTokens;
25
+
26
+ export interface TextProps {
27
+ children: JSX.Element;
28
+ tone?: TextTone | undefined;
29
+ size?: TextSize | undefined;
30
+ weight?: TextWeight | undefined;
31
+ /**
32
+ * Renders a semantic element instead of a span; `p` for flow text. `strong`
33
+ * and `em` carry meaning to assistive tech — `weight` and `tone` do not.
34
+ */
35
+ as?: 'span' | 'p' | 'div' | 'strong' | 'em' | undefined;
36
+ class?: string | undefined;
37
+ }
38
+
39
+ export function Text(props: TextProps): JSX.Element {
40
+ const Tag = props.as ?? 'span';
41
+
42
+ // Size and weight are set only when asked for: unset means inherit, so <Text>
43
+ // inside a heading or a caption does not silently reset to the body scale.
44
+ return (
45
+ <Tag
46
+ class={cx(styles['text'], styles[`tone-${props.tone ?? 'default'}`], props.class)}
47
+ style={{
48
+ ...(props.size === undefined ? {} : { '--text-scale': `var(--text-${props.size})` }),
49
+ ...(props.weight === undefined
50
+ ? {}
51
+ : { '--text-strength': `var(--weight-${props.weight})` }),
52
+ }}
53
+ >
54
+ {props.children}
55
+ </Tag>
56
+ );
57
+ }
@@ -0,0 +1,15 @@
1
+ @use '../tokens' as t;
2
+
3
+ .textarea {
4
+ @include t.control;
5
+ @include t.custom-scrollbar;
6
+ display: block;
7
+ min-block-size: calc(1lh * 3);
8
+ resize: vertical;
9
+ line-height: t.leading(normal);
10
+ }
11
+
12
+ .autoGrow {
13
+ field-sizing: content;
14
+ resize: none;
15
+ }
@@ -0,0 +1,48 @@
1
+ // Multi-line text control. `field-sizing: content` grows the box natively where
2
+ // supported, with `rows` as the floor — no resize observer, no JS.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { ariaBool } from '../a11y';
6
+ import { cx } from '../cx';
7
+ import styles from './Textarea.module.scss';
8
+
9
+ export interface TextareaProps {
10
+ id?: string | undefined;
11
+ name?: string | undefined;
12
+ value?: string | undefined;
13
+ placeholder?: string | undefined;
14
+ rows?: number | undefined;
15
+ required?: boolean | undefined;
16
+ disabled?: boolean | undefined;
17
+ readonly?: boolean | undefined;
18
+ maxlength?: number | undefined;
19
+ autoGrow?: boolean | undefined;
20
+ class?: string | undefined;
21
+ 'aria-label'?: string | undefined;
22
+ 'aria-describedby'?: string | undefined;
23
+ 'aria-invalid'?: boolean | undefined;
24
+ onInput?: JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent> | undefined;
25
+ onBlur?: JSX.EventHandlerUnion<HTMLTextAreaElement, FocusEvent> | undefined;
26
+ }
27
+
28
+ export function Textarea(props: TextareaProps): JSX.Element {
29
+ return (
30
+ <textarea
31
+ class={cx(styles['textarea'], props.autoGrow === true && styles['autoGrow'], props.class)}
32
+ id={props.id}
33
+ name={props.name}
34
+ value={props.value ?? ''}
35
+ placeholder={props.placeholder}
36
+ rows={props.rows ?? 3}
37
+ required={props.required === true}
38
+ disabled={props.disabled === true}
39
+ readonly={props.readonly === true}
40
+ maxlength={props.maxlength}
41
+ aria-label={props['aria-label']}
42
+ aria-describedby={props['aria-describedby']}
43
+ aria-invalid={ariaBool(props['aria-invalid'])}
44
+ onInput={props.onInput}
45
+ onBlur={props.onBlur}
46
+ />
47
+ );
48
+ }
@@ -0,0 +1,23 @@
1
+ @use '../tokens' as t;
2
+
3
+ .toggle {
4
+ color: t.role('fg-muted');
5
+ }
6
+
7
+ .select {
8
+ min-inline-size: 9rem;
9
+ }
10
+
11
+ // A pure-CSS sun/moon: a filled disc that a box-shadow crops into a crescent.
12
+ .glyph {
13
+ display: block;
14
+ inline-size: 0.9rem;
15
+ block-size: 0.9rem;
16
+ border-radius: t.radius(full);
17
+ background: currentcolor;
18
+
19
+ &[data-theme-glyph='dark'] {
20
+ background: transparent;
21
+ box-shadow: inset -0.28rem -0.18rem 0 0 currentcolor;
22
+ }
23
+ }