@ultimat3/ui 23.0.0 → 25.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 (100) hide show
  1. package/CATALOG.md +119 -15
  2. package/CLAUDE.md +17 -8
  3. package/README.md +119 -33
  4. package/package.json +6 -6
  5. package/src/a11y.ts +27 -13
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/Container.tsx +46 -9
  17. package/src/components/DataTable.module.scss +190 -0
  18. package/src/components/DataTable.tsx +161 -51
  19. package/src/components/Divider.tsx +0 -2
  20. package/src/components/DonutChart.module.scss +73 -0
  21. package/src/components/DonutChart.tsx +154 -0
  22. package/src/components/Dropzone.module.scss +3 -1
  23. package/src/components/Dropzone.tsx +17 -8
  24. package/src/components/ErrorState.tsx +39 -12
  25. package/src/components/FileInput.module.scss +3 -1
  26. package/src/components/FileInput.tsx +6 -2
  27. package/src/components/Form.tsx +17 -4
  28. package/src/components/Gauge.module.scss +48 -0
  29. package/src/components/Gauge.tsx +67 -0
  30. package/src/components/Grid.tsx +34 -13
  31. package/src/components/Image.tsx +2 -2
  32. package/src/components/InlineBar.module.scss +23 -0
  33. package/src/components/InlineBar.tsx +31 -0
  34. package/src/components/LineChart.module.scss +147 -0
  35. package/src/components/LineChart.tsx +169 -0
  36. package/src/components/Menu.tsx +9 -5
  37. package/src/components/Meter.tsx +3 -3
  38. package/src/components/PageHeader.tsx +5 -2
  39. package/src/components/Popover.module.scss +17 -20
  40. package/src/components/Popover.tsx +14 -7
  41. package/src/components/Section.tsx +45 -26
  42. package/src/components/Stack.tsx +59 -18
  43. package/src/components/Table.module.scss +30 -0
  44. package/src/components/Table.tsx +14 -3
  45. package/src/components/Tabs.module.scss +6 -0
  46. package/src/components/Tabs.tsx +2 -2
  47. package/src/components/Text.tsx +41 -15
  48. package/src/components/Textarea.tsx +7 -4
  49. package/src/components/Toaster.tsx +1 -1
  50. package/src/components/Toolbar.tsx +7 -6
  51. package/src/components/Tooltip.module.scss +8 -3
  52. package/src/components/app-shell-view.ts +6 -4
  53. package/src/components/bar-chart-view.ts +11 -6
  54. package/src/components/chart-frame-view.ts +219 -0
  55. package/src/components/chart-ticks-view.ts +173 -0
  56. package/src/components/data-table-view.ts +68 -0
  57. package/src/components/donut-chart-view.ts +120 -0
  58. package/src/components/file-input-view.ts +32 -13
  59. package/src/components/gauge-view.ts +43 -0
  60. package/src/components/heading-node.tsx +62 -0
  61. package/src/components/image-source.ts +3 -3
  62. package/src/components/line-chart-view.ts +153 -0
  63. package/src/components/link-target.ts +21 -1
  64. package/src/components/meter-view.ts +13 -0
  65. package/src/components/money-view.ts +2 -7
  66. package/src/components/qr-matrix.ts +16 -23
  67. package/src/components/relative-time-view.ts +11 -4
  68. package/src/form/form-binding.ts +32 -14
  69. package/src/form/use-form.ts +3 -3
  70. package/src/global.scss +9 -0
  71. package/src/icons/build-icons.ts +109 -24
  72. package/src/index.ts +47 -15
  73. package/src/jsx-probe.ts +5 -4
  74. package/src/theme/brand-declarations.ts +113 -0
  75. package/src/theme/brand.ts +104 -96
  76. package/src/theme/context.ts +1 -1
  77. package/src/theme/preset-scifi.ts +99 -0
  78. package/src/theme/preset-shape.ts +18 -0
  79. package/src/theme/presets.ts +15 -0
  80. package/src/theme/shadow-value.ts +39 -0
  81. package/src/theme/theme.ts +36 -11
  82. package/src/toast/toast-state.ts +0 -0
  83. package/src/toast/toast-store.ts +1 -1
  84. package/src/toast/use-toasts.ts +1 -1
  85. package/src/tokens/_colors.scss +20 -0
  86. package/src/tokens/_effects.scss +59 -0
  87. package/src/tokens/_index.scss +2 -0
  88. package/src/tokens/_mixins.scss +6 -4
  89. package/src/tokens/_shadow.scss +16 -0
  90. package/src/tokens/_touch.scss +39 -0
  91. package/src/tokens/_typography.scss +14 -0
  92. package/src/tokens/color-tokens.ts +133 -0
  93. package/src/tokens/colour-vision.ts +106 -0
  94. package/src/tokens/contrast-pairs.ts +6 -1
  95. package/src/tokens/contrast.ts +1 -1
  96. package/src/tokens/theme.scss +2 -0
  97. package/src/tokens/tokens.ts +41 -87
  98. package/src/fake-dom.ts +0 -206
  99. package/src/sass-probe.ts +0 -55
  100. package/src/theme/inline-script.ts +0 -49
@@ -2,7 +2,7 @@
2
2
  // Distinct from Select — a menu runs commands, it does not hold a form value.
3
3
 
4
4
  import type { JSX } from 'solid-js';
5
- import { createFocusTrap, createRovingTabindex, useId } from '../a11y';
5
+ import { focusTrap, rovingTabindex, useId } from '../a11y';
6
6
  import { cx } from '../cx';
7
7
  import { MENU_ITEM_SELECTOR, tabStopIndex } from '../roving';
8
8
  import { useUi } from '../theme/context';
@@ -24,11 +24,15 @@ export interface MenuProps {
24
24
  items: readonly MenuItem[];
25
25
  open: boolean;
26
26
  onOpenChange: (open: boolean) => void;
27
+ /**
28
+ * The menu button. `aria-controls` is present only while the list is mounted: an IDREF to an
29
+ * element not in the document names nothing.
30
+ */
27
31
  trigger: (control: {
28
32
  id: string;
29
33
  'aria-haspopup': 'menu';
30
34
  'aria-expanded': boolean;
31
- 'aria-controls': string;
35
+ 'aria-controls': string | undefined;
32
36
  }) => JSX.Element;
33
37
  /** Already-translated accessible name for the menu. */
34
38
  label: string;
@@ -45,7 +49,7 @@ export function Menu(props: MenuProps): JSX.Element {
45
49
 
46
50
  // Disabled items are excluded from BOTH answers — the list arrows walk and the one item that
47
51
  // carries the tab stop — because `focus()` on a disabled button silently does nothing.
48
- const onKeyDown = createRovingTabindex(
52
+ const onKeyDown = rovingTabindex(
49
53
  () =>
50
54
  list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)),
51
55
  { orientation: 'vertical', dir: ui.dir },
@@ -65,7 +69,7 @@ export function Menu(props: MenuProps): JSX.Element {
65
69
  // Closing unmounts the panel with focus inside it, which resets focus to <body> and makes the
66
70
  // next Tab restart at the top of the document. The trap moves focus in on open and hands it
67
71
  // back to the trigger on close, so Escape returns the user where they were.
68
- const trap = list === undefined ? undefined : createFocusTrap(list);
72
+ const trap = list === undefined ? undefined : focusTrap(list);
69
73
  trap?.activate();
70
74
  rt.onCleanup(() => {
71
75
  document.removeEventListener('pointerdown', onPointerDown, true);
@@ -85,7 +89,7 @@ export function Menu(props: MenuProps): JSX.Element {
85
89
  id: `${menuId}-trigger`,
86
90
  'aria-haspopup': 'menu',
87
91
  'aria-expanded': props.open,
88
- 'aria-controls': menuId,
92
+ 'aria-controls': props.open ? menuId : undefined,
89
93
  })}
90
94
  {props.open ? (
91
95
  <div
@@ -9,7 +9,7 @@
9
9
  import type { JSX } from 'solid-js';
10
10
  import { cx } from '../cx';
11
11
  import styles from './Meter.module.scss';
12
- import { meterShare, meterWidth } from './meter-view';
12
+ import { meterAria, meterShare, meterWidth } from './meter-view';
13
13
  import type { Tone } from './variants';
14
14
 
15
15
  export interface MeterProps {
@@ -49,8 +49,8 @@ export function Meter(props: MeterProps): JSX.Element {
49
49
  role="meter"
50
50
  aria-label={props.label}
51
51
  aria-valuemin={0}
52
- aria-valuemax={props.max}
53
- aria-valuenow={props.value}
52
+ aria-valuemax={meterAria(props.value, props.max).max}
53
+ aria-valuenow={meterAria(props.value, props.max).now}
54
54
  >
55
55
  {bars()}
56
56
  </svg>
@@ -6,6 +6,7 @@ import type { JSX } from 'solid-js';
6
6
  import { cx } from '../cx';
7
7
  import { Breadcrumb, type BreadcrumbItem } from './Breadcrumb';
8
8
  import { type HeadingLevel, headingTag } from './heading-level';
9
+ import { headingNode } from './heading-node';
9
10
  import styles from './PageHeader.module.scss';
10
11
 
11
12
  export interface PageHeaderProps {
@@ -24,7 +25,9 @@ export interface PageHeaderProps {
24
25
  }
25
26
 
26
27
  export function PageHeader(props: PageHeaderProps): JSX.Element {
27
- const Heading = headingTag(props.level ?? 1);
28
+ // Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
29
+ const heading = headingTag(props.level ?? 1);
30
+ const title = (): string => props.title;
28
31
 
29
32
  return (
30
33
  <header class={cx(styles['pageHeader'], props.class)}>
@@ -33,7 +36,7 @@ export function PageHeader(props: PageHeaderProps): JSX.Element {
33
36
  <div class={styles['titles']}>
34
37
  {props.media === undefined ? null : <div class={styles['media']}>{props.media}</div>}
35
38
  <div class={styles['text']}>
36
- <Heading class={styles['title']}>{props.title}</Heading>
39
+ {headingNode(heading, { class: styles['title'] }, title)}
37
40
  {props.description === undefined ? null : (
38
41
  <p class={styles['description']}>{props.description}</p>
39
42
  )}
@@ -33,27 +33,24 @@
33
33
  inset-block-start: 0;
34
34
  }
35
35
 
36
- .align-start {
37
- inset-inline-start: 0;
38
- }
39
-
40
- .align-end {
41
- inset-inline-end: 0;
42
- }
43
-
44
- .align-center {
45
- inset-inline-start: 50%;
46
- translate: -50% 0;
47
- }
36
+ // Alignment is along the anchor's inline edge, so it exists only for the block placements. An
37
+ // inline placement owns both inline insets itself; an align rule that reached it (and the reset
38
+ // that used to undo one) wiped the placement's own inset and dropped the panel over the anchor.
39
+ .placement-block-end,
40
+ .placement-block-start {
41
+ &.align-start {
42
+ inset-inline-start: 0;
43
+ }
48
44
 
49
- // Inline placements own their own inline axis; alignment must not fight them.
50
- .placement-inline-end,
51
- .placement-inline-start {
52
- &.align-start,
53
- &.align-center,
54
45
  &.align-end {
55
- inset-inline-start: auto;
56
- inset-inline-end: auto;
57
- translate: none;
46
+ inset-inline-end: 0;
47
+ }
48
+
49
+ // Auto margins inside an inset band far wider than any panel: centred in either direction. A 50%
50
+ // logical inset with a -50% physical translate is a whole width off under `dir="rtl"`.
51
+ &.align-center {
52
+ inset-inline: -100vw;
53
+ margin-inline: auto;
54
+ inline-size: fit-content;
58
55
  }
59
56
  }
@@ -1,9 +1,9 @@
1
1
  // Non-modal anchored panel. Positioning is CSS-only (a relatively positioned
2
- // anchor plus logical insets), so there is no measure/reflow loop and the panel
3
- // flips sides automatically under `dir="rtl"`.
2
+ // anchor plus logical insets and auto-margin centring), so there is no measure/reflow
3
+ // loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.
4
4
 
5
5
  import type { JSX } from 'solid-js';
6
- import { createFocusTrap, useId } from '../a11y';
6
+ import { focusTrap, useId } from '../a11y';
7
7
  import { cx } from '../cx';
8
8
  import { solid } from '../theme/solid-adapter';
9
9
  import styles from './Popover.module.scss';
@@ -13,11 +13,14 @@ export type Placement = 'block-end' | 'block-start' | 'inline-end' | 'inline-sta
13
13
  export interface PopoverProps {
14
14
  open: boolean;
15
15
  onOpenChange: (open: boolean) => void;
16
- /** The anchor. Wire its onClick to `onOpenChange` in the caller. */
16
+ /**
17
+ * The anchor. Wire its onClick to `onOpenChange` in the caller. `aria-controls` is present only
18
+ * while the panel is mounted: an IDREF to an element not in the document names nothing.
19
+ */
17
20
  trigger: (control: {
18
21
  id: string;
19
22
  'aria-expanded': boolean;
20
- 'aria-controls': string;
23
+ 'aria-controls': string | undefined;
21
24
  }) => JSX.Element;
22
25
  children: JSX.Element;
23
26
  /** Already-translated accessible name for the panel. */
@@ -47,7 +50,7 @@ export function Popover(props: PopoverProps): JSX.Element {
47
50
  // Closing unmounts the panel with focus inside it, which drops focus to <body> and makes the
48
51
  // next Tab restart at the top of the document. The trap moves focus into the panel on open and
49
52
  // hands it back to the trigger on close.
50
- const trap = panel === undefined ? undefined : createFocusTrap(panel);
53
+ const trap = panel === undefined ? undefined : focusTrap(panel);
51
54
  trap?.activate();
52
55
  rt.onCleanup(() => {
53
56
  document.removeEventListener('pointerdown', onPointerDown, true);
@@ -63,7 +66,11 @@ export function Popover(props: PopoverProps): JSX.Element {
63
66
  root = el;
64
67
  }}
65
68
  >
66
- {props.trigger({ id: triggerId, 'aria-expanded': props.open, 'aria-controls': panelId })}
69
+ {props.trigger({
70
+ id: triggerId,
71
+ 'aria-expanded': props.open,
72
+ 'aria-controls': props.open ? panelId : undefined,
73
+ })}
67
74
  {props.open ? (
68
75
  <div
69
76
  ref={(el: HTMLDivElement) => {
@@ -6,6 +6,7 @@ import type { JSX } from 'solid-js';
6
6
  import { useId } from '../a11y';
7
7
  import { cx } from '../cx';
8
8
  import { type HeadingLevel, headingTag } from './heading-level';
9
+ import { headingNode } from './heading-node';
9
10
  import styles from './Section.module.scss';
10
11
 
11
12
  export interface SectionProps {
@@ -23,33 +24,51 @@ export interface SectionProps {
23
24
  }
24
25
 
25
26
  export function Section(props: SectionProps): JSX.Element {
26
- const Tag = props.as ?? 'section';
27
- const Heading = headingTag(props.level ?? 2);
27
+ // Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
28
+ const heading = headingTag(props.level ?? 2);
28
29
  const titleId = useId('section');
29
-
30
- return (
31
- <Tag
32
- class={cx(styles['section'], props.class)}
33
- aria-labelledby={props.title === undefined ? undefined : titleId}
34
- >
35
- {props.title === undefined && props.actions === undefined ? null : (
36
- <div class={styles['head']}>
37
- <div class={styles['text']}>
38
- {props.title === undefined ? null : (
39
- <Heading id={titleId} class={styles['title']}>
40
- {props.title}
41
- </Heading>
42
- )}
43
- {props.description === undefined ? null : (
44
- <p class={styles['description']}>{props.description}</p>
45
- )}
46
- </div>
47
- {props.actions === undefined ? null : (
48
- <div class={styles['actions']}>{props.actions}</div>
30
+ const cls = (): string => cx(styles['section'], props.class);
31
+ const labelledBy = (): string | undefined => (props.title === undefined ? undefined : titleId);
32
+ const title = (): string | undefined => props.title;
33
+ const head = (): JSX.Element =>
34
+ props.title === undefined && props.actions === undefined ? null : (
35
+ <div class={styles['head']}>
36
+ <div class={styles['text']}>
37
+ {props.title === undefined
38
+ ? null
39
+ : headingNode(heading, { id: titleId, class: styles['title'] }, title)}
40
+ {props.description === undefined ? null : (
41
+ <p class={styles['description']}>{props.description}</p>
49
42
  )}
50
43
  </div>
51
- )}
52
- <div class={styles['body']}>{props.children}</div>
53
- </Tag>
54
- );
44
+ {props.actions === undefined ? null : <div class={styles['actions']}>{props.actions}</div>}
45
+ </div>
46
+ );
47
+ const body = (): JSX.Element => <div class={styles['body']}>{props.children}</div>;
48
+
49
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
50
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
51
+ switch (props.as ?? 'section') {
52
+ case 'article':
53
+ return (
54
+ <article class={cls()} aria-labelledby={labelledBy()}>
55
+ {head()}
56
+ {body()}
57
+ </article>
58
+ );
59
+ case 'aside':
60
+ return (
61
+ <aside class={cls()} aria-labelledby={labelledBy()}>
62
+ {head()}
63
+ {body()}
64
+ </aside>
65
+ );
66
+ case 'section':
67
+ return (
68
+ <section class={cls()} aria-labelledby={labelledBy()}>
69
+ {head()}
70
+ {body()}
71
+ </section>
72
+ );
73
+ }
55
74
  }
@@ -28,22 +28,63 @@ const ALIGN: Readonly<Record<Align, string>> = {
28
28
  };
29
29
 
30
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
- );
31
+ const cls = (): string =>
32
+ cx(
33
+ styles['stack'],
34
+ styles[`direction-${props.direction ?? 'column'}`],
35
+ props.wrap === true && styles['wrap'],
36
+ props.class,
37
+ );
38
+ const style = (): JSX.CSSProperties => ({
39
+ '--stack-gap': `var(--space-${props.gap ?? 4})`,
40
+ '--stack-align': ALIGN[props.align ?? 'stretch'],
41
+ '--stack-justify': ALIGN[props.justify ?? 'start'],
42
+ });
43
+
44
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
45
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
46
+ switch (props.as ?? 'div') {
47
+ case 'ul':
48
+ return (
49
+ <ul class={cls()} style={style()}>
50
+ {props.children}
51
+ </ul>
52
+ );
53
+ case 'ol':
54
+ return (
55
+ <ol class={cls()} style={style()}>
56
+ {props.children}
57
+ </ol>
58
+ );
59
+ case 'nav':
60
+ return (
61
+ <nav class={cls()} style={style()}>
62
+ {props.children}
63
+ </nav>
64
+ );
65
+ case 'section':
66
+ return (
67
+ <section class={cls()} style={style()}>
68
+ {props.children}
69
+ </section>
70
+ );
71
+ case 'header':
72
+ return (
73
+ <header class={cls()} style={style()}>
74
+ {props.children}
75
+ </header>
76
+ );
77
+ case 'footer':
78
+ return (
79
+ <footer class={cls()} style={style()}>
80
+ {props.children}
81
+ </footer>
82
+ );
83
+ case 'div':
84
+ return (
85
+ <div class={cls()} style={style()}>
86
+ {props.children}
87
+ </div>
88
+ );
89
+ }
49
90
  }
@@ -75,3 +75,33 @@
75
75
  .striped :global(tbody tr:nth-child(even) td) {
76
76
  background: t.role('bg-soft', 0.6);
77
77
  }
78
+
79
+ // The first column stays at the inline start while the scroller moves the rest. A sticky cell
80
+ // needs an opaque background or the scrolled cells show through it; the edge line is a border,
81
+ // not an inset shadow, because a border is logical and mirrors under RTL by itself.
82
+ .table[data-sticky-first] :global(:is(th, td):first-child) {
83
+ position: sticky;
84
+ inset-inline-start: 0;
85
+ z-index: t.z(raised);
86
+ background-color: t.role('surface');
87
+ border-inline-end: 1px solid t.role('line');
88
+ }
89
+
90
+ .table[data-sticky-first] :global(th:first-child) {
91
+ background-color: t.role('bg-soft');
92
+ }
93
+
94
+ // The corner cell is sticky on both axes, so it must sit above both the header row and the column.
95
+ .sticky[data-sticky-first] :global(thead th:first-child) {
96
+ z-index: t.z(sticky);
97
+ }
98
+
99
+ .sticky[data-sticky-first] :global(thead th:not(:first-child)) {
100
+ z-index: t.z(raised);
101
+ }
102
+
103
+ // A striped row keeps its stripe on the pinned cell: the translucent stripe laid over the opaque
104
+ // surface, so the stripe shows and the scrolled cells still do not.
105
+ .striped[data-sticky-first] :global(tbody tr:nth-child(even) td:first-child) {
106
+ background: linear-gradient(t.role('bg-soft', 0.6), t.role('bg-soft', 0.6)), t.role('surface');
107
+ }
@@ -1,6 +1,6 @@
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.
1
+ // Presentational table. Owns what every hand-rolled table gets wrong: a sticky header, a first
2
+ // column that stays put while the rest scrolls, and horizontal overflow contained inside the
3
+ // table's own scroll container so the page body never scrolls sideways.
4
4
 
5
5
  import type { JSX } from 'solid-js';
6
6
  import { cx } from '../cx';
@@ -13,6 +13,14 @@ export interface TableProps {
13
13
  /** Hide the caption visually while keeping it for assistive tech. */
14
14
  hideCaption?: boolean | undefined;
15
15
  stickyHeader?: boolean | undefined;
16
+ /** Pins the first column at the inline start while the table scrolls sideways. Off by default. */
17
+ stickyFirstColumn?: boolean | undefined;
18
+ /**
19
+ * Declares `role="table"` on the element itself. For a caller that restyles the table's
20
+ * `display` (DataTable's card view): WebKit drops a table's semantics once `display` changes,
21
+ * and the explicit role is what keeps it a table. The rows and cells are the caller's to mark.
22
+ */
23
+ explicitRoles?: boolean | undefined;
16
24
  density?: 'comfortable' | 'compact' | undefined;
17
25
  /** Zebra striping. Off by default — a border is usually enough. */
18
26
  striped?: boolean | undefined;
@@ -27,6 +35,9 @@ export function Table(props: TableProps): JSX.Element {
27
35
  // inside it — and a <section> with no name is generic, so the table keeps its own caption.
28
36
  <section class={cx(styles['scroller'], props.class)} tabindex="0">
29
37
  <table
38
+ role={props.explicitRoles === true ? 'table' : undefined}
39
+ // An attribute, not a class: it is state the stylesheet and a test both read.
40
+ data-sticky-first={props.stickyFirstColumn === true ? '' : undefined}
30
41
  class={cx(
31
42
  styles['table'],
32
43
  styles[`density-${props.density ?? 'comfortable'}`],
@@ -18,11 +18,17 @@
18
18
  border-inline-end: 1px solid t.role('line');
19
19
  }
20
20
 
21
+ // On the list's inline-END edge, beside its border. A shadow's x offset is physical, so RTL —
22
+ // where that edge is the left one — gets the mirrored sign, as `Switch.module.scss` does.
21
23
  .tab[aria-selected='true'] {
22
24
  box-shadow: inset calc(-1 * #{t.stroke(thick)}) 0 0 0 t.role('accent');
23
25
  }
24
26
  }
25
27
 
28
+ [dir='rtl'] .orientation-vertical .tab[aria-selected='true'] {
29
+ box-shadow: inset t.stroke(thick) 0 0 0 t.role('accent');
30
+ }
31
+
26
32
  .list {
27
33
  @include t.custom-scrollbar;
28
34
  display: flex;
@@ -2,7 +2,7 @@
2
2
  // between them, Home/End jump to the ends, and arrow direction follows `dir`.
3
3
 
4
4
  import type { JSX } from 'solid-js';
5
- import { ariaBool, createRovingTabindex, useId } from '../a11y';
5
+ import { ariaBool, rovingTabindex, useId } from '../a11y';
6
6
  import { cx } from '../cx';
7
7
  import { TAB_SELECTOR, tabStopIndex } from '../roving';
8
8
  import { useUi } from '../theme/context';
@@ -35,7 +35,7 @@ export function Tabs(props: TabsProps): JSX.Element {
35
35
  // A disabled tab is excluded from BOTH answers — the list arrows walk and the one tab that
36
36
  // carries the tab stop — because `focus()` on a disabled button silently does nothing, so a
37
37
  // disabled tab left in the list pins the reducer on its index and hides every tab after it.
38
- const onKeyDown = createRovingTabindex(
38
+ const onKeyDown = rovingTabindex(
39
39
  () => (list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(TAB_SELECTOR))),
40
40
  { orientation: props.orientation ?? 'horizontal', dir: ui.dir },
41
41
  );
@@ -37,21 +37,47 @@ export interface TextProps {
37
37
  }
38
38
 
39
39
  export function Text(props: TextProps): JSX.Element {
40
- const Tag = props.as ?? 'span';
41
-
40
+ const cls = (): string =>
41
+ cx(styles['text'], styles[`tone-${props.tone ?? 'default'}`], props.class);
42
42
  // Size and weight are set only when asked for: unset means inherit, so <Text>
43
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
- );
44
+ const style = (): JSX.CSSProperties => ({
45
+ ...(props.size === undefined ? {} : { '--text-scale': `var(--text-${props.size})` }),
46
+ ...(props.weight === undefined ? {} : { '--text-strength': `var(--weight-${props.weight})` }),
47
+ });
48
+
49
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
50
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
51
+ switch (props.as ?? 'span') {
52
+ case 'p':
53
+ return (
54
+ <p class={cls()} style={style()}>
55
+ {props.children}
56
+ </p>
57
+ );
58
+ case 'div':
59
+ return (
60
+ <div class={cls()} style={style()}>
61
+ {props.children}
62
+ </div>
63
+ );
64
+ case 'strong':
65
+ return (
66
+ <strong class={cls()} style={style()}>
67
+ {props.children}
68
+ </strong>
69
+ );
70
+ case 'em':
71
+ return (
72
+ <em class={cls()} style={style()}>
73
+ {props.children}
74
+ </em>
75
+ );
76
+ case 'span':
77
+ return (
78
+ <span class={cls()} style={style()}>
79
+ {props.children}
80
+ </span>
81
+ );
82
+ }
57
83
  }
@@ -28,11 +28,13 @@ export interface TextareaProps {
28
28
  /**
29
29
  * `<textarea>` has NO `value` attribute — its value is its text content, and the parser drops the
30
30
  * attribute silently, so `value={…}` rendered every admin edit box empty whatever the row held.
31
- * The leading newline is the serializer's, not the value's: the HTML parser strips exactly one
32
- * newline after `<textarea>`, so emitting it unconditionally is what makes a value that itself
33
- * starts with a newline survive the round trip.
31
+ * On the SERVER the leading newline is the serializer's, not the value's: the HTML parser strips
32
+ * exactly one newline after `<textarea>`, so emitting it unconditionally is what makes a value that
33
+ * itself starts with a newline survive the round trip. In a BROWSER no parser runs — an island
34
+ * builds the element — so the value is the `.value` property, and the newline would be data.
34
35
  */
35
36
  export function Textarea(props: TextareaProps): JSX.Element {
37
+ const onServer = typeof document === 'undefined';
36
38
  return (
37
39
  <textarea
38
40
  class={cx(styles['textarea'], props.autoGrow === true && styles['autoGrow'], props.class)}
@@ -49,8 +51,9 @@ export function Textarea(props: TextareaProps): JSX.Element {
49
51
  aria-invalid={ariaBool(props['aria-invalid'])}
50
52
  onInput={props.onInput}
51
53
  onBlur={props.onBlur}
54
+ value={onServer ? undefined : (props.value ?? '')}
52
55
  >
53
- {`\n${props.value ?? ''}`}
56
+ {onServer ? `\n${props.value ?? ''}` : undefined}
54
57
  </textarea>
55
58
  );
56
59
  }
@@ -15,7 +15,7 @@ import { Button } from './Button';
15
15
  import { Toast, type ToastPlacement, ToastRegion } from './Toast';
16
16
 
17
17
  export interface ToasterProps {
18
- /** The queue. One per app — `createToastStore()` in the island that mounts this. */
18
+ /** The queue. One per app — `toastStore()` in the island that mounts this. */
19
19
  store: ToastStore;
20
20
  /** Already-translated landmark name, e.g. "Notifications". */
21
21
  label: string;
@@ -5,7 +5,7 @@
5
5
  // own arrow keys, so making the strip a single stop would strand every control past it.
6
6
 
7
7
  import type { JSX } from 'solid-js';
8
- import { createRovingTabindex, focusableWithin } from '../a11y';
8
+ import { focusableWithin, rovingTabindex } from '../a11y';
9
9
  import { cx } from '../cx';
10
10
  import { useUi } from '../theme/context';
11
11
  import styles from './Toolbar.module.scss';
@@ -27,12 +27,13 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
27
27
  let strip: HTMLDivElement | undefined;
28
28
 
29
29
  // Arrows follow the writing direction; `focusableWithin` skips anything hidden or disabled, and
30
- // `createRovingTabindex` declines outright while a control that answers arrows itself — the
30
+ // `rovingTabindex` declines outright while a control that answers arrows itself — the
31
31
  // search Input this strip exists to hold — has focus.
32
- const onKeyDown = createRovingTabindex(
33
- () => (strip === undefined ? [] : focusableWithin(strip)),
34
- { orientation: 'horizontal', dir: ui.dir, loop: false },
35
- );
32
+ const onKeyDown = rovingTabindex(() => (strip === undefined ? [] : focusableWithin(strip)), {
33
+ orientation: 'horizontal',
34
+ dir: ui.dir,
35
+ loop: false,
36
+ });
36
37
 
37
38
  return (
38
39
  <div
@@ -9,8 +9,13 @@
9
9
  @include t.transition(opacity, translate);
10
10
  position: absolute;
11
11
  z-index: t.z(tooltip);
12
- inset-inline-start: 50%;
13
- translate: -50% t.space(1);
12
+ // Centred over the trigger in either direction: auto margins inside an inset band far wider than
13
+ // any bubble. A 50% logical inset with a -50% physical translate lands a whole width off-centre
14
+ // under `dir="rtl"` (`rtl-sheets.test.ts`).
15
+ inset-inline: -100vw;
16
+ margin-inline: auto;
17
+ inline-size: max-content;
18
+ translate: 0 t.space(1);
14
19
  max-inline-size: 18rem;
15
20
  padding-block: t.space(1);
16
21
  padding-inline: t.space(2);
@@ -36,6 +41,6 @@
36
41
  // Hover and keyboard focus both reveal it — a hover-only tooltip is inaccessible.
37
42
  .wrap:hover .bubble,
38
43
  .wrap:focus-within .bubble {
39
- translate: -50% 0;
44
+ translate: 0 0;
40
45
  opacity: 1;
41
46
  }