@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,53 @@
1
+ // A button whose only content is an icon, so `label` is mandatory: it becomes
2
+ // the accessible name and the tooltip. There is no unlabelled icon button.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { ariaBool } from '../a11y';
6
+ import { cx } from '../cx';
7
+ import styles from './IconButton.module.scss';
8
+ import type { ButtonVariant, Size, Tone } from './variants';
9
+
10
+ export interface IconButtonProps {
11
+ /** Required accessible name — also used as the native tooltip. */
12
+ label: string;
13
+ children: JSX.Element;
14
+ variant?: ButtonVariant | undefined;
15
+ tone?: Tone | undefined;
16
+ size?: Size | undefined;
17
+ disabled?: boolean | undefined;
18
+ round?: boolean | undefined;
19
+ id?: string | undefined;
20
+ class?: string | undefined;
21
+ 'aria-pressed'?: boolean | undefined;
22
+ 'aria-expanded'?: boolean | undefined;
23
+ 'aria-controls'?: string | undefined;
24
+ onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> | undefined;
25
+ }
26
+
27
+ export function IconButton(props: IconButtonProps): JSX.Element {
28
+ return (
29
+ <button
30
+ id={props.id}
31
+ type="button"
32
+ title={props.label}
33
+ aria-label={props.label}
34
+ aria-pressed={ariaBool(props['aria-pressed'])}
35
+ aria-expanded={ariaBool(props['aria-expanded'])}
36
+ aria-controls={props['aria-controls']}
37
+ disabled={props.disabled === true}
38
+ class={cx(
39
+ styles['iconButton'],
40
+ styles[`variant-${props.variant ?? 'ghost'}`],
41
+ styles[`tone-${props.tone ?? 'neutral'}`],
42
+ styles[`size-${props.size ?? 'md'}`],
43
+ props.round === true && styles['round'],
44
+ props.class,
45
+ )}
46
+ onClick={props.onClick}
47
+ >
48
+ <span aria-hidden="true" class={styles['glyph']}>
49
+ {props.children}
50
+ </span>
51
+ </button>
52
+ );
53
+ }
@@ -0,0 +1,14 @@
1
+ @use '../tokens' as t;
2
+
3
+ .image {
4
+ // The inlined width/height give the browser the ratio; `block-size: auto` keeps
5
+ // it as the box scales down, so a decode never moves what is under the image.
6
+ display: block;
7
+ max-inline-size: 100%;
8
+ block-size: auto;
9
+
10
+ // Painted only when the image fails to load: the browser renders the alt text
11
+ // inside this box, and it inherits the img's own colour and font-size.
12
+ color: t.role('fg-muted');
13
+ font-size: t.text(sm);
14
+ }
@@ -0,0 +1,59 @@
1
+ // Zero-CLS image primitive: a plain <img>, no JS, no fetch, no client state.
2
+ //
3
+ // The build-time half of the pipeline in `docs/idea/07-rendering-seo.md` — reading
4
+ // real dimensions, encoding AVIF/WebP renditions, the data-URI blur placeholder —
5
+ // is NOT here. This component emits exactly what it is handed and fabricates
6
+ // nothing: no variants it was not given, no dimensions it did not measure.
7
+
8
+ import type { JSX } from 'solid-js';
9
+ import { cx } from '../cx';
10
+ import styles from './Image.module.scss';
11
+ import {
12
+ assertNonEmptySrc,
13
+ boxFor,
14
+ type ImageBox,
15
+ type ImageLoadingHints,
16
+ type ImageVariant,
17
+ loadingHints,
18
+ srcsetFor,
19
+ } from './image-source';
20
+
21
+ export interface ImageProps {
22
+ src: string;
23
+ /**
24
+ * Required, with no default: an <Image> whose meaning is undescribed is a type
25
+ * error at the call site. Pass `alt=""` for a decorative image, deliberately.
26
+ */
27
+ alt: string;
28
+ /** Renditions to offer; the descriptors and their order are derived, not written. */
29
+ variants?: readonly ImageVariant[] | undefined;
30
+ /** Layout width of the box, e.g. `(max-width: 700px) 100vw, 620px`. */
31
+ sizes?: string | undefined;
32
+ /** The LCP image, at most one per route: eager, high fetch priority. */
33
+ priority?: boolean | undefined;
34
+ /** Intrinsic dimensions, from the build step that measured them. Both or neither. */
35
+ width?: number | undefined;
36
+ height?: number | undefined;
37
+ class?: string | undefined;
38
+ }
39
+
40
+ export function Image(props: ImageProps): JSX.Element {
41
+ const src = (): string => assertNonEmptySrc('Image', props.src, props.src);
42
+ const hints = (): ImageLoadingHints => loadingHints(props.priority);
43
+ const box = (): ImageBox | undefined => boxFor(props.width, props.height);
44
+
45
+ return (
46
+ <img
47
+ class={cx(styles['image'], props.class)}
48
+ src={src()}
49
+ alt={props.alt}
50
+ srcset={srcsetFor(props.variants)}
51
+ sizes={props.sizes}
52
+ width={box()?.width}
53
+ height={box()?.height}
54
+ loading={hints().loading}
55
+ fetchpriority={hints().fetchpriority}
56
+ decoding={hints().decoding}
57
+ />
58
+ );
59
+ }
@@ -0,0 +1,55 @@
1
+ @use '../tokens' as t;
2
+
3
+ .wrap {
4
+ @include t.control;
5
+ display: flex;
6
+ align-items: center;
7
+ gap: t.space(2);
8
+
9
+ &:focus-within {
10
+ border-color: t.role('accent');
11
+ outline: 2px solid t.role('accent');
12
+ outline-offset: 1px;
13
+ }
14
+
15
+ &[data-disabled='true'] {
16
+ background: t.role('bg-soft');
17
+ color: t.role('fg-muted');
18
+ }
19
+
20
+ &:has([aria-invalid='true']) {
21
+ border-color: t.role('danger');
22
+ }
23
+ }
24
+
25
+ // The wrapper owns the box; the control itself is transparent.
26
+ .input {
27
+ flex: 1;
28
+ min-inline-size: 0;
29
+ border: 0;
30
+ background: none;
31
+ color: inherit;
32
+ font: inherit;
33
+ outline: none;
34
+ }
35
+
36
+ .adornment {
37
+ display: inline-flex;
38
+ color: t.role('fg-muted');
39
+ font-size: t.text(sm);
40
+ white-space: nowrap;
41
+ }
42
+
43
+ .size-sm {
44
+ padding-block: t.space(1);
45
+ font-size: t.text(sm);
46
+ }
47
+
48
+ .size-md {
49
+ padding-block: t.space(2);
50
+ }
51
+
52
+ .size-lg {
53
+ padding-block: t.space(3);
54
+ font-size: t.text(lg);
55
+ }
@@ -0,0 +1,84 @@
1
+ // Single-line text control. No `type="number"` convenience wrapper: numeric
2
+ // input uses `inputmode` + a text type so locale decimal separators survive.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { ariaBool } from '../a11y';
6
+ import { cx } from '../cx';
7
+ import styles from './Input.module.scss';
8
+ import type { Size } from './variants';
9
+
10
+ export type InputType =
11
+ | 'text'
12
+ | 'email'
13
+ | 'password'
14
+ | 'search'
15
+ | 'tel'
16
+ | 'url'
17
+ | 'date'
18
+ | 'time'
19
+ | 'datetime-local';
20
+
21
+ export interface InputProps {
22
+ id?: string | undefined;
23
+ name?: string | undefined;
24
+ type?: InputType | undefined;
25
+ value?: string | undefined;
26
+ placeholder?: string | undefined;
27
+ size?: Size | undefined;
28
+ required?: boolean | undefined;
29
+ disabled?: boolean | undefined;
30
+ readonly?: boolean | undefined;
31
+ autocomplete?: string | undefined;
32
+ inputmode?: 'text' | 'numeric' | 'decimal' | 'tel' | 'email' | 'url' | 'search' | undefined;
33
+ maxlength?: number | undefined;
34
+ /** Non-interactive adornments; keep them icons or units, never controls. */
35
+ prefix?: JSX.Element | undefined;
36
+ suffix?: JSX.Element | undefined;
37
+ class?: string | undefined;
38
+ 'aria-label'?: string | undefined;
39
+ 'aria-describedby'?: string | undefined;
40
+ 'aria-invalid'?: boolean | undefined;
41
+ onInput?: JSX.EventHandlerUnion<HTMLInputElement, InputEvent> | undefined;
42
+ onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event> | undefined;
43
+ onBlur?: JSX.EventHandlerUnion<HTMLInputElement, FocusEvent> | undefined;
44
+ }
45
+
46
+ export function Input(props: InputProps): JSX.Element {
47
+ return (
48
+ <span
49
+ class={cx(styles['wrap'], styles[`size-${props.size ?? 'md'}`], props.class)}
50
+ data-disabled={props.disabled === true ? 'true' : undefined}
51
+ >
52
+ {props.prefix === undefined ? null : (
53
+ <span aria-hidden="true" class={styles['adornment']}>
54
+ {props.prefix}
55
+ </span>
56
+ )}
57
+ <input
58
+ class={styles['input']}
59
+ id={props.id}
60
+ name={props.name}
61
+ type={props.type ?? 'text'}
62
+ value={props.value ?? ''}
63
+ placeholder={props.placeholder}
64
+ required={props.required === true}
65
+ disabled={props.disabled === true}
66
+ readonly={props.readonly === true}
67
+ autocomplete={props.autocomplete}
68
+ inputmode={props.inputmode}
69
+ maxlength={props.maxlength}
70
+ aria-label={props['aria-label']}
71
+ aria-describedby={props['aria-describedby']}
72
+ aria-invalid={ariaBool(props['aria-invalid'])}
73
+ onInput={props.onInput}
74
+ onChange={props.onChange}
75
+ onBlur={props.onBlur}
76
+ />
77
+ {props.suffix === undefined ? null : (
78
+ <span aria-hidden="true" class={styles['adornment']}>
79
+ {props.suffix}
80
+ </span>
81
+ )}
82
+ </span>
83
+ );
84
+ }
@@ -0,0 +1,42 @@
1
+ @use '../tokens' as t;
2
+
3
+ .link {
4
+ @include t.focus-ring;
5
+ @include t.transition(color, text-decoration-color);
6
+ border-radius: t.radius(sm);
7
+ text-underline-offset: 0.18em;
8
+ text-decoration-thickness: 1px;
9
+ }
10
+
11
+ .tone-accent {
12
+ color: t.role('accent');
13
+
14
+ &:hover {
15
+ color: t.role('accent-strong');
16
+ }
17
+ }
18
+
19
+ .tone-inherit {
20
+ color: inherit;
21
+ }
22
+
23
+ .underline-always {
24
+ text-decoration-line: underline;
25
+ }
26
+
27
+ .underline-hover {
28
+ text-decoration-line: none;
29
+
30
+ &:hover {
31
+ text-decoration-line: underline;
32
+ }
33
+ }
34
+
35
+ .underline-none {
36
+ text-decoration-line: none;
37
+ }
38
+
39
+ // Screen-reader-only suffix, e.g. "(opens in a new tab)".
40
+ .hint {
41
+ @include t.visually-hidden;
42
+ }
@@ -0,0 +1,46 @@
1
+ // Anchor primitive. External links get `rel` hardening and a translated
2
+ // "opens in a new tab" hint automatically — never a bare `target="_blank"`.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { cx } from '../cx';
6
+ import styles from './Link.module.scss';
7
+
8
+ export interface LinkProps {
9
+ href: string;
10
+ children: JSX.Element;
11
+ external?: boolean | undefined;
12
+ /** Announced suffix for external links; supply via `t()` at the call site. */
13
+ externalHint?: string | undefined;
14
+ underline?: 'always' | 'hover' | 'none' | undefined;
15
+ tone?: 'accent' | 'inherit' | undefined;
16
+ id?: string | undefined;
17
+ class?: string | undefined;
18
+ 'aria-current'?: 'page' | 'step' | 'true' | false | undefined;
19
+ onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent> | undefined;
20
+ }
21
+
22
+ export function Link(props: LinkProps): JSX.Element {
23
+ const isExternal = (): boolean => props.external === true || /^https?:\/\//.test(props.href);
24
+
25
+ return (
26
+ <a
27
+ id={props.id}
28
+ href={props.href}
29
+ target={isExternal() ? '_blank' : undefined}
30
+ rel={isExternal() ? 'noopener noreferrer' : undefined}
31
+ aria-current={props['aria-current']}
32
+ class={cx(
33
+ styles['link'],
34
+ styles[`underline-${props.underline ?? 'hover'}`],
35
+ styles[`tone-${props.tone ?? 'accent'}`],
36
+ props.class,
37
+ )}
38
+ onClick={props.onClick}
39
+ >
40
+ {props.children}
41
+ {isExternal() && props.externalHint !== undefined ? (
42
+ <span class={styles['hint']}>{props.externalHint}</span>
43
+ ) : null}
44
+ </a>
45
+ );
46
+ }
@@ -0,0 +1,28 @@
1
+ @use '../tokens' as t;
2
+
3
+ .select {
4
+ min-inline-size: 9rem;
5
+ }
6
+
7
+ .links {
8
+ display: flex;
9
+ flex-wrap: wrap;
10
+ gap: t.space(2);
11
+ font-size: t.text(sm);
12
+ }
13
+
14
+ .link {
15
+ @include t.focus-ring;
16
+ border-radius: t.radius(sm);
17
+ color: t.role('fg-muted');
18
+ text-decoration: none;
19
+
20
+ &:hover {
21
+ color: t.role('accent');
22
+ }
23
+
24
+ &[aria-current='true'] {
25
+ color: t.role('fg-strong');
26
+ font-weight: t.weight(semibold);
27
+ }
28
+ }
@@ -0,0 +1,61 @@
1
+ // Locale picker. Option labels come from `Intl.DisplayNames` in each locale's own
2
+ // language (endonyms), so the list needs no translation catalog of its own.
3
+
4
+ import type { Locale } from '@ultimat3/i18n';
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { UI_KEYS } from '../i18n-keys';
8
+ import { useUi } from '../theme/context';
9
+ import styles from './LocaleSwitcher.module.scss';
10
+ import { Select, type SelectOption } from './Select';
11
+
12
+ export interface LocaleSwitcherProps {
13
+ locales: readonly Locale[];
14
+ /** Defaults to the context locale. */
15
+ value?: Locale | undefined;
16
+ onLocaleChange?: ((locale: Locale) => void) | undefined;
17
+ /** Render as links instead of a select, for a 0kb-JS `site/` route. */
18
+ hrefFor?: ((locale: Locale) => string) | undefined;
19
+ class?: string | undefined;
20
+ }
21
+
22
+ /** Endonym: the language's name in its own language, e.g. `de` -> "Deutsch". */
23
+ export function localeLabel(tag: Locale): string {
24
+ const names = new Intl.DisplayNames([tag], { type: 'language' });
25
+ return names.of(tag) ?? tag;
26
+ }
27
+
28
+ export function LocaleSwitcher(props: LocaleSwitcherProps): JSX.Element {
29
+ const ui = useUi();
30
+ const current = (): Locale => props.value ?? ui.locale;
31
+ const options = (): SelectOption[] =>
32
+ props.locales.map((tag) => ({ value: tag, label: localeLabel(tag) }));
33
+
34
+ if (props.hrefFor !== undefined) {
35
+ return (
36
+ <nav class={cx(styles['links'], props.class)} aria-label={ui.t(UI_KEYS.language)}>
37
+ {props.locales.map((tag) => (
38
+ <a
39
+ class={styles['link']}
40
+ href={props.hrefFor?.(tag)}
41
+ hreflang={tag}
42
+ lang={tag}
43
+ aria-current={tag === current() ? 'true' : undefined}
44
+ >
45
+ {localeLabel(tag)}
46
+ </a>
47
+ ))}
48
+ </nav>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <Select
54
+ class={cx(styles['select'], props.class)}
55
+ aria-label={ui.t(UI_KEYS.language)}
56
+ value={current()}
57
+ options={options()}
58
+ onChange={(event) => props.onLocaleChange?.(event.currentTarget.value)}
59
+ />
60
+ );
61
+ }
@@ -0,0 +1,63 @@
1
+ @use '../tokens' as t;
2
+
3
+ .anchor {
4
+ position: relative;
5
+ display: inline-block;
6
+ }
7
+
8
+ .menu {
9
+ @include t.surface('surface-raised', 'lg');
10
+ position: absolute;
11
+ z-index: t.z(dropdown);
12
+ inset-block-start: calc(100% + #{t.space(1)});
13
+ display: flex;
14
+ flex-direction: column;
15
+ min-inline-size: 12rem;
16
+ padding: t.space(1);
17
+ }
18
+
19
+ .align-start {
20
+ inset-inline-start: 0;
21
+ }
22
+
23
+ .align-end {
24
+ inset-inline-end: 0;
25
+ }
26
+
27
+ .item {
28
+ @include t.focus-ring;
29
+ @include t.transition(background, color);
30
+ display: flex;
31
+ align-items: center;
32
+ gap: t.space(2);
33
+ padding-block: t.space(2);
34
+ padding-inline: t.space(3);
35
+ border-radius: t.radius(sm);
36
+ color: t.role('fg');
37
+ font-size: t.text(sm);
38
+ // Menu labels read from the start edge in both writing directions.
39
+ text-align: start;
40
+ cursor: pointer;
41
+
42
+ &:hover:not(:disabled) {
43
+ background: t.role('bg-soft');
44
+ }
45
+
46
+ &:disabled {
47
+ color: t.role('fg-muted');
48
+ cursor: not-allowed;
49
+ }
50
+ }
51
+
52
+ .destructive {
53
+ color: t.role('danger');
54
+
55
+ &:hover:not(:disabled) {
56
+ background: t.role('danger-soft');
57
+ }
58
+ }
59
+
60
+ .icon {
61
+ display: inline-flex;
62
+ color: t.role('fg-muted');
63
+ }
@@ -0,0 +1,115 @@
1
+ // Action menu: a trigger plus a `role="menu"` list with roving tabindex.
2
+ // Distinct from Select — a menu runs commands, it does not hold a form value.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import { createRovingTabindex, useId } from '../a11y';
6
+ import { cx } from '../cx';
7
+ import { useUi } from '../theme/context';
8
+ import { solid } from '../theme/solid-adapter';
9
+ import styles from './Menu.module.scss';
10
+
11
+ export interface MenuItem {
12
+ id: string;
13
+ /** Already-translated item label. */
14
+ label: string;
15
+ onSelect: () => void;
16
+ disabled?: boolean | undefined;
17
+ /** Renders with the danger role; use for irreversible actions. */
18
+ destructive?: boolean | undefined;
19
+ icon?: JSX.Element | undefined;
20
+ }
21
+
22
+ export interface MenuProps {
23
+ items: readonly MenuItem[];
24
+ open: boolean;
25
+ onOpenChange: (open: boolean) => void;
26
+ trigger: (control: {
27
+ id: string;
28
+ 'aria-haspopup': 'menu';
29
+ 'aria-expanded': boolean;
30
+ 'aria-controls': string;
31
+ }) => JSX.Element;
32
+ /** Already-translated accessible name for the menu. */
33
+ label: string;
34
+ align?: 'start' | 'end' | undefined;
35
+ class?: string | undefined;
36
+ }
37
+
38
+ export function Menu(props: MenuProps): JSX.Element {
39
+ const ui = useUi();
40
+ const rt = solid();
41
+ const menuId = useId('menu');
42
+ let root: HTMLDivElement | undefined;
43
+ let list: HTMLDivElement | undefined;
44
+
45
+ const onKeyDown = createRovingTabindex(
46
+ () =>
47
+ list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>('[role="menuitem"]')),
48
+ { orientation: 'vertical', dir: ui.dir },
49
+ );
50
+
51
+ rt.createEffect(() => {
52
+ if (!props.open || typeof document === 'undefined') return;
53
+ const onPointerDown = (event: Event): void => {
54
+ if (root !== undefined && !root.contains(event.target as Node)) props.onOpenChange(false);
55
+ };
56
+ const onEscape = (event: KeyboardEvent): void => {
57
+ if (event.key === 'Escape') props.onOpenChange(false);
58
+ };
59
+ document.addEventListener('pointerdown', onPointerDown, true);
60
+ document.addEventListener('keydown', onEscape);
61
+ rt.onCleanup(() => {
62
+ document.removeEventListener('pointerdown', onPointerDown, true);
63
+ document.removeEventListener('keydown', onEscape);
64
+ });
65
+ });
66
+
67
+ return (
68
+ <div
69
+ class={cx(styles['anchor'], props.class)}
70
+ ref={(el: HTMLDivElement) => {
71
+ root = el;
72
+ }}
73
+ >
74
+ {props.trigger({
75
+ id: `${menuId}-trigger`,
76
+ 'aria-haspopup': 'menu',
77
+ 'aria-expanded': props.open,
78
+ 'aria-controls': menuId,
79
+ })}
80
+ {props.open ? (
81
+ <div
82
+ ref={(el: HTMLDivElement) => {
83
+ list = el;
84
+ }}
85
+ id={menuId}
86
+ role="menu"
87
+ aria-label={props.label}
88
+ class={cx(styles['menu'], styles[`align-${props.align ?? 'start'}`])}
89
+ onKeyDown={onKeyDown}
90
+ >
91
+ {props.items.map((item, index) => (
92
+ <button
93
+ type="button"
94
+ role="menuitem"
95
+ class={cx(styles['item'], item.destructive === true && styles['destructive'])}
96
+ tabindex={index === 0 ? 0 : -1}
97
+ disabled={item.disabled === true}
98
+ onClick={() => {
99
+ item.onSelect();
100
+ props.onOpenChange(false);
101
+ }}
102
+ >
103
+ {item.icon === undefined ? null : (
104
+ <span aria-hidden="true" class={styles['icon']}>
105
+ {item.icon}
106
+ </span>
107
+ )}
108
+ {item.label}
109
+ </button>
110
+ ))}
111
+ </div>
112
+ ) : null}
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,11 @@
1
+ @use '../tokens' as t;
2
+
3
+ .money {
4
+ // Tabular figures so a column of amounts lines up without a monospace font.
5
+ font-variant-numeric: tabular-nums;
6
+ white-space: nowrap;
7
+ }
8
+
9
+ .negative {
10
+ color: t.role('danger');
11
+ }
@@ -0,0 +1,49 @@
1
+ // Formats through @ultimat3/money using the locale and currency from context —
2
+ // never a process-wide default, never a float, never a hardcoded symbol.
3
+
4
+ import type { FormatMoneyOptions, Money as MoneyValue } from '@ultimat3/money';
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { useUi } from '../theme/context';
8
+ import styles from './Money.module.scss';
9
+ import { type MoneyFormatter, moneyText } from './money-view';
10
+
11
+ export interface MoneyProps {
12
+ /** A Money value, or bare minor units in the context currency. */
13
+ value: MoneyValue | number;
14
+ /** Overrides the context currency for bare-number values. */
15
+ currency?: string | undefined;
16
+ /** Overrides the context locale. Use only for side-by-side comparisons. */
17
+ locale?: string | undefined;
18
+ /** Colour negatives with the danger role, e.g. in a ledger. */
19
+ signed?: boolean | undefined;
20
+ /** Passed to @ultimat3/money: display, accounting, grouping, fractionDigits. */
21
+ options?: FormatMoneyOptions | undefined;
22
+ format?: MoneyFormatter | undefined;
23
+ class?: string | undefined;
24
+ }
25
+
26
+ export function Money(props: MoneyProps): JSX.Element {
27
+ const ui = useUi();
28
+ const minor = (): number => (typeof props.value === 'number' ? props.value : props.value.minor);
29
+ const text = (): string =>
30
+ moneyText({
31
+ value: props.value,
32
+ locale: props.locale ?? ui.locale,
33
+ currency: props.currency ?? ui.currency,
34
+ ...(props.options === undefined ? {} : { options: props.options }),
35
+ ...(props.format === undefined ? {} : { format: props.format }),
36
+ });
37
+
38
+ return (
39
+ <span
40
+ class={cx(
41
+ styles['money'],
42
+ props.signed === true && minor() < 0 && styles['negative'],
43
+ props.class,
44
+ )}
45
+ >
46
+ {text()}
47
+ </span>
48
+ );
49
+ }