@ultimat3/ui 22.15.0 → 24.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 (65) hide show
  1. package/CATALOG.md +45 -14
  2. package/CLAUDE.md +16 -52
  3. package/README.md +69 -13
  4. package/package.json +6 -5
  5. package/src/a11y.ts +24 -10
  6. package/src/catalog/parse-component.ts +85 -12
  7. package/src/catalog/render-catalog.ts +21 -1
  8. package/src/components/Avatar.module.scss +1 -1
  9. package/src/components/BarChart.module.scss +12 -1
  10. package/src/components/BarChart.tsx +41 -15
  11. package/src/components/Button.tsx +3 -8
  12. package/src/components/Card.module.scss +1 -1
  13. package/src/components/Checkbox.module.scss +4 -4
  14. package/src/components/Checkbox.tsx +6 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/DataTable.module.scss +2 -0
  17. package/src/components/DataTable.tsx +135 -71
  18. package/src/components/Dialog.module.scss +2 -2
  19. package/src/components/Dropzone.module.scss +6 -4
  20. package/src/components/Dropzone.tsx +9 -8
  21. package/src/components/FileInput.module.scss +4 -2
  22. package/src/components/FileInput.tsx +6 -2
  23. package/src/components/Form.module.scss +2 -2
  24. package/src/components/Input.module.scss +1 -1
  25. package/src/components/Link.tsx +64 -12
  26. package/src/components/Meter.tsx +3 -3
  27. package/src/components/Pagination.tsx +89 -37
  28. package/src/components/Popover.module.scss +17 -20
  29. package/src/components/Popover.tsx +12 -5
  30. package/src/components/Radio.module.scss +2 -2
  31. package/src/components/Select.module.scss +3 -3
  32. package/src/components/Skeleton.module.scss +1 -1
  33. package/src/components/Spinner.module.scss +3 -3
  34. package/src/components/Switch.module.scss +2 -2
  35. package/src/components/Table.module.scss +2 -2
  36. package/src/components/Tabs.module.scss +11 -5
  37. package/src/components/Textarea.tsx +7 -4
  38. package/src/components/ThemeToggle.module.scss +1 -1
  39. package/src/components/Toast.module.scss +2 -2
  40. package/src/components/Tooltip.module.scss +8 -3
  41. package/src/components/bar-chart-view.ts +35 -5
  42. package/src/components/button-classes.ts +25 -0
  43. package/src/components/file-input-view.ts +31 -12
  44. package/src/components/link-classes.ts +38 -0
  45. package/src/components/link-target.ts +34 -1
  46. package/src/components/meter-view.ts +13 -0
  47. package/src/components/qr-matrix.ts +16 -23
  48. package/src/components/qr-reader.ts +187 -0
  49. package/src/components/relative-time-view.ts +11 -4
  50. package/src/form/form-binding.ts +9 -8
  51. package/src/icons/build-icons.ts +96 -20
  52. package/src/index.ts +15 -9
  53. package/src/sass-probe.ts +55 -0
  54. package/src/theme/brand.ts +22 -3
  55. package/src/theme/theme.ts +35 -10
  56. package/src/toast/toast-state.ts +0 -0
  57. package/src/tokens/_index.scss +3 -1
  58. package/src/tokens/_mixins.scss +56 -8
  59. package/src/tokens/_motion.scss +4 -0
  60. package/src/tokens/_stroke.scss +20 -0
  61. package/src/tokens/_units.scss +47 -0
  62. package/src/tokens/reset.scss +4 -4
  63. package/src/tokens/theme.scss +2 -0
  64. package/src/tokens/tokens.ts +6 -0
  65. package/src/theme/inline-script.ts +0 -49
@@ -3,7 +3,7 @@
3
3
  @keyframes ultimate-dialog-in {
4
4
  from {
5
5
  opacity: 0;
6
- translate: 0 8px;
6
+ translate: 0 t.space(2);
7
7
  }
8
8
  }
9
9
 
@@ -22,7 +22,7 @@
22
22
  animation: ultimate-dialog-in t.duration(base) t.easing(out);
23
23
  }
24
24
 
25
- @include t.scrim-backdrop($blur: 2px);
25
+ @include t.scrim-backdrop($blur: t.rem(2px));
26
26
  }
27
27
 
28
28
  .size-sm {
@@ -4,7 +4,7 @@
4
4
  @include t.column(t.space(2), center);
5
5
  @include t.transition(background-color, border-color);
6
6
  position: relative;
7
- border: 2px dashed t.role('line');
7
+ border: t.stroke(thick) dashed t.role('line');
8
8
  border-radius: t.radius(lg);
9
9
  background: t.role('surface');
10
10
  padding-block: t.space(8);
@@ -15,8 +15,8 @@
15
15
  // The hidden input keeps the focus, so the ring has to be drawn on the surface around it.
16
16
  &:focus-within {
17
17
  border-color: t.role('accent');
18
- outline: 2px solid t.role('accent');
19
- outline-offset: 2px;
18
+ outline: t.stroke(thick) solid t.role('accent');
19
+ outline-offset: t.stroke(thick);
20
20
  }
21
21
 
22
22
  &[data-over='true'] {
@@ -25,8 +25,10 @@
25
25
  background: t.role('bg-soft');
26
26
  }
27
27
 
28
+ // Dimmed directly: `@include t.disabled` appends `:disabled`, which a `<label>` carrying a data
29
+ // flag can never be, so the rule it compiled to matched nothing (`disabled-dim.test.ts`).
28
30
  &[data-disabled='true'] {
29
- @include t.disabled;
31
+ opacity: 0.55;
30
32
  background: t.role('bg-soft');
31
33
  cursor: not-allowed;
32
34
  }
@@ -12,7 +12,7 @@ import { useUi } from '../theme/context';
12
12
  import { solid } from '../theme/solid-adapter';
13
13
  import styles from './Dropzone.module.scss';
14
14
  import type { FileSelection } from './file-input-view';
15
- import { adoptDroppedFiles, progressPercent, selectFiles } from './file-input-view';
15
+ import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
16
16
 
17
17
  export interface DropzoneProps {
18
18
  /** Already-translated instruction. Required — it is the control's accessible name. */
@@ -49,8 +49,12 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
49
49
  const inputId = (): string => props.id ?? fallbackId;
50
50
  const percent = (): number => progressPercent(props.progress ?? 0);
51
51
 
52
- const offer = (files: FileList | null | undefined): void => {
53
- props.onSelect(selectFiles([...(files ?? [])], props));
52
+ // The input first, then the callback: a drop that only reaches `onSelect` leaves the real
53
+ // control empty, and a refused file left in it is posted by the form anyway.
54
+ const offer = (files: FileList | null | undefined, origin: 'drop' | 'pick'): void => {
55
+ const selection = selectFiles([...(files ?? [])], props);
56
+ adoptAcceptedFiles(input, files, selection, origin);
57
+ props.onSelect(selection);
54
58
  };
55
59
 
56
60
  // `dragover` must be cancelled on every tick or the browser refuses the drop and navigates to
@@ -74,10 +78,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
74
78
  event.preventDefault();
75
79
  setOver(false);
76
80
  if (props.disabled === true) return;
77
- // The input first, then the callback: a drop that only reaches `onSelect` leaves the real
78
- // control empty, and the form the label sits in submits nothing.
79
- adoptDroppedFiles(input, event.dataTransfer?.files);
80
- offer(event.dataTransfer?.files);
81
+ offer(event.dataTransfer?.files, 'drop');
81
82
  }}
82
83
  >
83
84
  <span class={styles['label']}>{props.label}</span>
@@ -96,7 +97,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
96
97
  disabled={props.disabled === true}
97
98
  aria-describedby={props['aria-describedby']}
98
99
  aria-invalid={ariaBool(props['aria-invalid'])}
99
- onChange={(event) => offer(event.currentTarget.files)}
100
+ onChange={(event) => offer(event.currentTarget.files, 'pick')}
100
101
  />
101
102
  {props.progress === undefined ? null : (
102
103
  <span
@@ -8,7 +8,7 @@
8
8
 
9
9
  &:focus-within {
10
10
  border-color: t.role('accent');
11
- outline: 2px solid t.role('accent');
11
+ outline: t.stroke(thick) solid t.role('accent');
12
12
  outline-offset: 1px;
13
13
  }
14
14
 
@@ -51,8 +51,10 @@
51
51
  border-color: t.role('accent');
52
52
  }
53
53
 
54
+ // Dimmed directly: a pseudo-element is never itself `:disabled`, so `@include t.disabled` here
55
+ // compiled to a selector nothing matches (`disabled-dim.test.ts`).
54
56
  &:disabled::file-selector-button {
55
- @include t.disabled;
57
+ opacity: 0.55;
56
58
  cursor: not-allowed;
57
59
  }
58
60
  }
@@ -10,7 +10,7 @@ import { UI_KEYS } from '../i18n-keys';
10
10
  import { useUi } from '../theme/context';
11
11
  import styles from './FileInput.module.scss';
12
12
  import type { FileSelection } from './file-input-view';
13
- import { progressPercent, selectFiles } from './file-input-view';
13
+ import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
14
14
  import type { Size } from './variants';
15
15
 
16
16
  export interface FileInputProps {
@@ -61,7 +61,11 @@ export function FileInput(props: FileInputProps): JSX.Element {
61
61
  aria-invalid={ariaBool(props['aria-invalid'])}
62
62
  onBlur={props.onBlur}
63
63
  onChange={(event) => {
64
- props.onSelect?.(selectFiles([...(event.currentTarget.files ?? [])], props));
64
+ const selection = selectFiles([...(event.currentTarget.files ?? [])], props);
65
+ // `accept` only steers the picker; "All files" gets past it, and the form would post
66
+ // what this control just refused.
67
+ adoptAcceptedFiles(event.currentTarget, event.currentTarget.files, selection, 'pick');
68
+ props.onSelect?.(selection);
65
69
  }}
66
70
  />
67
71
  {props.progress === undefined ? null : (
@@ -7,8 +7,8 @@
7
7
  }
8
8
 
9
9
  .summary:focus {
10
- outline: 2px solid t.role('danger');
11
- outline-offset: 4px;
10
+ outline: t.stroke(thick) solid t.role('danger');
11
+ outline-offset: t.space(1);
12
12
  }
13
13
 
14
14
  .actions {
@@ -8,7 +8,7 @@
8
8
 
9
9
  &:focus-within {
10
10
  border-color: t.role('accent');
11
- outline: 2px solid t.role('accent');
11
+ outline: t.stroke(thick) solid t.role('accent');
12
12
  outline-offset: 1px;
13
13
  }
14
14
 
@@ -1,25 +1,62 @@
1
1
  // Anchor primitive. External links get `rel` hardening and a translated
2
2
  // "opens in a new tab" hint automatically — never a bare `target="_blank"`.
3
+ // `appearance="button"` is Button's look on the same anchor: a control that navigates is a link.
3
4
 
4
5
  import type { JSX } from 'solid-js';
5
6
  import { cx } from '../cx';
7
+ import buttonStyles from './Button.module.scss';
6
8
  import styles from './Link.module.scss';
7
- import { linkTarget } from './link-target';
9
+ import { linkClasses } from './link-classes';
10
+ import { linkRel, linkTarget } from './link-target';
11
+ import type { ButtonVariant, Size, Tone } from './variants';
8
12
 
9
- export interface LinkProps {
13
+ interface LinkBaseProps {
10
14
  href: string;
11
15
  children: JSX.Element;
12
16
  external?: boolean | undefined;
13
17
  /** Announced suffix for external links; supply via `t()` at the call site. */
14
18
  externalHint?: string | undefined;
15
- underline?: 'always' | 'hover' | 'none' | undefined;
16
- tone?: 'accent' | 'inherit' | undefined;
19
+ /** A pager's direction. Joins the hardening an external link always carries, never replaces it. */
20
+ rel?: 'next' | 'prev' | undefined;
17
21
  id?: string | undefined;
18
22
  class?: string | undefined;
19
23
  'aria-current'?: 'page' | 'step' | 'true' | false | undefined;
20
24
  onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent> | undefined;
21
25
  }
22
26
 
27
+ /** Inline text — the default. */
28
+ export interface TextLinkProps extends LinkBaseProps {
29
+ /** `button` wears Button's classes on the same `<a>`; the two looks take different props. */
30
+ appearance?: 'link' | undefined;
31
+ underline?: 'always' | 'hover' | 'none' | undefined;
32
+ /** A text link: `accent` or `inherit`. A button-link: any `Tone`. */
33
+ tone?: 'accent' | 'inherit' | undefined;
34
+ variant?: undefined;
35
+ size?: undefined;
36
+ fullWidth?: undefined;
37
+ iconStart?: undefined;
38
+ iconEnd?: undefined;
39
+ }
40
+
41
+ /** Button's look on an anchor. There is no `href` on `<Button>`: this is that control. */
42
+ export interface ButtonLinkProps extends LinkBaseProps {
43
+ appearance: 'button';
44
+ /** `appearance="button"` only. */
45
+ variant?: ButtonVariant | undefined;
46
+ tone?: Tone | undefined;
47
+ /** `appearance="button"` only. */
48
+ size?: Size | undefined;
49
+ /** `appearance="button"` only. */
50
+ fullWidth?: boolean | undefined;
51
+ /** `appearance="button"` only. */
52
+ iconStart?: JSX.Element | undefined;
53
+ /** `appearance="button"` only. */
54
+ iconEnd?: JSX.Element | undefined;
55
+ underline?: undefined;
56
+ }
57
+
58
+ export type LinkProps = TextLinkProps | ButtonLinkProps;
59
+
23
60
  export function Link(props: LinkProps): JSX.Element {
24
61
  // Both the href and the external verdict come from ONE decision (`link-target.ts`): a
25
62
  // `javascript:` href used to be emitted verbatim AND classified internal, so it lost the
@@ -28,22 +65,37 @@ export function Link(props: LinkProps): JSX.Element {
28
65
  linkTarget(props.href, props.external);
29
66
  const isExternal = (): boolean => target().external;
30
67
 
68
+ /**
69
+ * A button-link wraps its label exactly as `<Button>` does — the text truncates inside the
70
+ * padding, the icons sit outside the truncated run — because the same classes on a different
71
+ * child structure is a different button.
72
+ */
73
+ const content = (): JSX.Element =>
74
+ props.appearance === 'button'
75
+ ? [
76
+ props.iconStart,
77
+ <span class={buttonStyles['label']}>{props.children}</span>,
78
+ props.iconEnd,
79
+ ]
80
+ : props.children;
81
+
82
+ const classes = (): string => {
83
+ const look = linkClasses(props);
84
+ const sheet = look.sheet === 'button' ? buttonStyles : styles;
85
+ return cx(...look.keys.map((key) => sheet[key]), props.class);
86
+ };
87
+
31
88
  return (
32
89
  <a
33
90
  id={props.id}
34
91
  href={target().href}
35
92
  target={isExternal() ? '_blank' : undefined}
36
- rel={isExternal() ? 'noopener noreferrer' : undefined}
93
+ rel={linkRel(isExternal(), props.rel)}
37
94
  aria-current={props['aria-current']}
38
- class={cx(
39
- styles['link'],
40
- styles[`underline-${props.underline ?? 'hover'}`],
41
- styles[`tone-${props.tone ?? 'accent'}`],
42
- props.class,
43
- )}
95
+ class={classes()}
44
96
  onClick={props.onClick}
45
97
  >
46
- {props.children}
98
+ {content()}
47
99
  {isExternal() && props.externalHint !== undefined ? (
48
100
  <span class={styles['hint']}>{props.externalHint}</span>
49
101
  ) : null}
@@ -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>
@@ -1,32 +1,63 @@
1
1
  // Cursor-first pagination — the only shape a Postgres-backed list should use.
2
2
  // Numbered mode exists for prerendered archives where the total is known.
3
+ // One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a
4
+ // server-rendered list — anchors the browser follows, with no script on the page.
3
5
 
4
6
  import type { JSX } from 'solid-js';
5
7
  import { cx } from '../cx';
6
8
  import { UI_KEYS } from '../i18n-keys';
7
9
  import { useUi } from '../theme/context';
8
10
  import { Button } from './Button';
11
+ import { Link } from './Link';
9
12
  import styles from './Pagination.module.scss';
10
13
 
11
- export interface PaginationProps {
14
+ type PageDirection = 'next' | 'prev';
15
+
16
+ interface PaginationBaseProps {
12
17
  /** Opaque cursor for the following page; absent disables "next". */
13
18
  nextCursor?: string | undefined;
14
19
  prevCursor?: string | undefined;
20
+ /** Already-translated; falls back to the ui.* catalog keys. */
21
+ labelPrevious?: string | undefined;
22
+ labelNext?: string | undefined;
23
+ class?: string | undefined;
24
+ }
25
+
26
+ /** Callback mode: the page is an island and pages itself. */
27
+ export interface PaginationCallbackProps extends PaginationBaseProps {
28
+ /** Callback mode. Never beside `hrefFor` — the type refuses the pair. */
15
29
  onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
16
30
  /** Numbered mode: 1-based page and total. Ignored when cursors are present. */
17
31
  page?: number | undefined;
18
32
  totalPages?: number | undefined;
19
33
  onPage?: ((page: number) => void) | undefined;
20
- /** Already-translated; falls back to the ui.* catalog keys. */
21
- labelPrevious?: string | undefined;
22
- labelNext?: string | undefined;
23
- class?: string | undefined;
34
+ hrefFor?: undefined;
24
35
  }
25
36
 
37
+ /** Link mode: each cursor is a URL, so the pager works with scripting off. */
38
+ export interface PaginationLinkProps extends PaginationBaseProps {
39
+ /**
40
+ * Link mode: the URL of the page a cursor names. Renders `<a rel="next|prev">` and attaches no
41
+ * handler, so it needs no island. Cursor paging only — never beside `onCursor`, `onPage`,
42
+ * `page` or `totalPages`.
43
+ */
44
+ hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
45
+ onCursor?: undefined;
46
+ page?: undefined;
47
+ totalPages?: undefined;
48
+ onPage?: undefined;
49
+ }
50
+
51
+ export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
52
+
26
53
  export function Pagination(props: PaginationProps): JSX.Element {
27
54
  const ui = useUi();
28
- const previous = (): string => props.labelPrevious ?? ui.t(UI_KEYS.previous);
29
- const next = (): string => props.labelNext ?? ui.t(UI_KEYS.next);
55
+ const label = (direction: PageDirection): string =>
56
+ direction === 'prev'
57
+ ? (props.labelPrevious ?? ui.t(UI_KEYS.previous))
58
+ : (props.labelNext ?? ui.t(UI_KEYS.next));
59
+ const cursor = (direction: PageDirection): string | undefined =>
60
+ direction === 'prev' ? props.prevCursor : props.nextCursor;
30
61
  // A cursor present means cursor mode, exactly as `page`'s own doc says. The inverted form —
31
62
  // "cursor mode when a number is MISSING" — silently dropped the cursor and paged by number the
32
63
  // moment a caller passed both, which is the shape a list that knows its total naturally has.
@@ -36,23 +67,62 @@ export function Pagination(props: PaginationProps): JSX.Element {
36
67
  props.page === undefined ||
37
68
  props.totalPages === undefined;
38
69
 
39
- return (
40
- <nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
70
+ const exhausted = (direction: PageDirection): boolean => {
71
+ if (cursorMode()) return cursor(direction) === undefined;
72
+ return direction === 'prev'
73
+ ? (props.page ?? 1) <= 1
74
+ : (props.page ?? 1) >= (props.totalPages ?? 1);
75
+ };
76
+
77
+ const go = (direction: PageDirection): void => {
78
+ const to = cursor(direction);
79
+ if (cursorMode()) {
80
+ if (to !== undefined) props.onCursor?.(to, direction);
81
+ } else if (direction === 'prev') {
82
+ props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
83
+ } else {
84
+ props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
85
+ }
86
+ };
87
+
88
+ /**
89
+ * One side of the pager. In link mode a side WITH a cursor is an anchor and a side without one
90
+ * is the same disabled button callback mode shows: an anchor with nowhere to go is a dead
91
+ * control that still takes a tab stop, and dropping the side would move the other one between
92
+ * the first page and the second.
93
+ */
94
+ const side = (direction: PageDirection): JSX.Element => {
95
+ const to = cursor(direction);
96
+ if (props.hrefFor !== undefined && to !== undefined) {
97
+ return (
98
+ <Link
99
+ appearance="button"
100
+ variant="secondary"
101
+ size="sm"
102
+ tone="neutral"
103
+ rel={direction}
104
+ href={props.hrefFor(to, direction)}
105
+ >
106
+ {label(direction)}
107
+ </Link>
108
+ );
109
+ }
110
+ return (
41
111
  <Button
42
112
  variant="secondary"
43
113
  size="sm"
44
114
  tone="neutral"
45
- disabled={cursorMode() ? props.prevCursor === undefined : (props.page ?? 1) <= 1}
46
- onClick={() => {
47
- if (cursorMode()) {
48
- if (props.prevCursor !== undefined) props.onCursor?.(props.prevCursor, 'prev');
49
- } else {
50
- props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
51
- }
52
- }}
115
+ disabled={exhausted(direction)}
116
+ onClick={props.hrefFor === undefined ? () => go(direction) : undefined}
53
117
  >
54
- {previous()}
118
+ {label(direction)}
55
119
  </Button>
120
+ );
121
+ };
122
+
123
+ return (
124
+ <nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
125
+ {side('prev')}
56
126
 
57
127
  {cursorMode() ? null : (
58
128
  <span class={styles['status']} aria-live="polite">
@@ -60,25 +130,7 @@ export function Pagination(props: PaginationProps): JSX.Element {
60
130
  </span>
61
131
  )}
62
132
 
63
- <Button
64
- variant="secondary"
65
- size="sm"
66
- tone="neutral"
67
- disabled={
68
- cursorMode()
69
- ? props.nextCursor === undefined
70
- : (props.page ?? 1) >= (props.totalPages ?? 1)
71
- }
72
- onClick={() => {
73
- if (cursorMode()) {
74
- if (props.nextCursor !== undefined) props.onCursor?.(props.nextCursor, 'next');
75
- } else {
76
- props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
77
- }
78
- }}
79
- >
80
- {next()}
81
- </Button>
133
+ {side('next')}
82
134
  </nav>
83
135
  );
84
136
  }
@@ -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,6 +1,6 @@
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
6
  import { createFocusTrap, useId } from '../a11y';
@@ -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. */
@@ -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) => {
@@ -67,8 +67,8 @@
67
67
  }
68
68
 
69
69
  .input:focus-visible + .dot {
70
- outline: 2px solid t.role('accent');
71
- outline-offset: 2px;
70
+ outline: t.stroke(thick) solid t.role('accent');
71
+ outline-offset: t.stroke(thick);
72
72
  }
73
73
 
74
74
  .text {
@@ -30,9 +30,9 @@
30
30
  inset-inline-end: t.space(3);
31
31
  inline-size: 0.4rem;
32
32
  block-size: 0.4rem;
33
- border-inline-end: 2px solid t.role('fg-muted');
34
- border-block-end: 2px solid t.role('fg-muted');
35
- transform: translateY(-2px) rotate(45deg);
33
+ border-inline-end: t.stroke(thick) solid t.role('fg-muted');
34
+ border-block-end: t.stroke(thick) solid t.role('fg-muted');
35
+ transform: translateY(t.rem(-2px)) rotate(45deg);
36
36
  pointer-events: none;
37
37
  }
38
38
 
@@ -24,7 +24,7 @@
24
24
  t.role('bg-soft') 63%
25
25
  )
26
26
  0 0 / 200% 100%;
27
- animation: ultimate-shimmer 1.4s linear infinite;
27
+ animation: ultimate-shimmer calc(#{t.duration(slower)} * 2) linear infinite;
28
28
  }
29
29
 
30
30
  .shape-text {
@@ -8,10 +8,10 @@
8
8
 
9
9
  .spinner {
10
10
  display: inline-block;
11
- border: 2px solid t.role('line');
11
+ border: t.stroke(thick) solid t.role('line');
12
12
  border-block-start-color: t.role('accent');
13
13
  border-radius: t.radius(full);
14
- animation: ultimate-spin 700ms linear infinite;
14
+ animation: ultimate-spin t.duration(slower) linear infinite;
15
15
  // Reduced motion is handled globally in reset.scss; the ring still reads as
16
16
  // a distinct shape when the animation is neutralised.
17
17
  }
@@ -29,5 +29,5 @@
29
29
  .size-lg {
30
30
  inline-size: 2rem;
31
31
  block-size: 2rem;
32
- border-width: 3px;
32
+ border-width: t.stroke(heavy);
33
33
  }
@@ -57,8 +57,8 @@
57
57
  }
58
58
 
59
59
  .input:focus-visible + .track {
60
- outline: 2px solid t.role('accent');
61
- outline-offset: 2px;
60
+ outline: t.stroke(thick) solid t.role('accent');
61
+ outline-offset: t.stroke(thick);
62
62
  }
63
63
 
64
64
  .text {
@@ -10,8 +10,8 @@
10
10
  background: t.role('surface');
11
11
 
12
12
  &:focus-visible {
13
- outline: 2px solid t.role('accent');
14
- outline-offset: -2px;
13
+ outline: t.stroke(thick) solid t.role('accent');
14
+ outline-offset: calc(-1 * #{t.stroke(thick)});
15
15
  }
16
16
  }
17
17