@ultimat3/ui 29.4.0 → 30.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 (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
@@ -0,0 +1,76 @@
1
+ // A block of code: `<pre><code>` of classed runs, a named scroll region that stays left-to-right
2
+ // under `dir="rtl"`, and — with script — a wrap toggle and a copy button. The runs are text nodes,
3
+ // so code can never become markup. Languages are passed in, never imported here (axiom 6).
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { findLanguage, highlight, type Language } from '../content/highlight';
7
+ import { cx } from '../cx';
8
+ import { UI_KEYS } from '../i18n-keys';
9
+ import { useUi } from '../theme/context';
10
+ import { solid } from '../theme/solid-adapter';
11
+ import styles from './CodeBlock.module.scss';
12
+ import { CopyButton } from './CopyButton';
13
+
14
+ export interface CodeBlockProps {
15
+ code: string;
16
+ /** The language's name or alias — a fence's info string. Unknown or absent: plain text. */
17
+ language?: string | undefined;
18
+ /** The languages this block may highlight with: `[tsLanguage]`, or `allLanguages` on a server page. */
19
+ languages?: readonly Language[] | undefined;
20
+ /** Already-translated name of the scroll region; defaults to `ui.code.region`. */
21
+ label?: string | undefined;
22
+ /** Start wrapped. The reader can toggle it once script runs. */
23
+ wrap?: boolean | undefined;
24
+ /** `false` hides the copy button. */
25
+ copy?: boolean | undefined;
26
+ class?: string | undefined;
27
+ }
28
+
29
+ export function CodeBlock(props: CodeBlockProps): JSX.Element {
30
+ const ui = useUi();
31
+ const rt = solid();
32
+ const [enhanced, setEnhanced] = rt.createSignal(false);
33
+ rt.createEffect(() => setEnhanced(true));
34
+ const [wrapped, setWrapped] = rt.createSignal(props.wrap === true);
35
+ const tokens = rt.createMemo(() =>
36
+ highlight(props.code, findLanguage(props.language, props.languages)),
37
+ );
38
+
39
+ return (
40
+ <div
41
+ class={cx(styles['block'], props.class)}
42
+ data-enhanced={enhanced() ? 'true' : undefined}
43
+ data-wrap={wrapped() ? 'true' : undefined}
44
+ >
45
+ <div class={styles['bar']}>
46
+ <button
47
+ type="button"
48
+ class={styles['toggle']}
49
+ aria-pressed={wrapped() ? 'true' : 'false'}
50
+ onClick={() => setWrapped(!wrapped())}
51
+ >
52
+ {ui.t(UI_KEYS.codeWrap)}
53
+ </button>
54
+ {props.copy === false ? null : <CopyButton value={props.code} size="sm" />}
55
+ </div>
56
+ {/* biome-ignore lint/a11y/useSemanticElements: the <pre> IS the scroll box; a <section> around it would scroll nothing */}
57
+ <pre
58
+ class={styles['pre']}
59
+ dir="ltr"
60
+ tabindex="0"
61
+ role="region"
62
+ aria-label={props.label ?? ui.t(UI_KEYS.codeRegion)}
63
+ >
64
+ <code>
65
+ {tokens().map((token) =>
66
+ token.kind === 'plain' ? (
67
+ token.text
68
+ ) : (
69
+ <span class={styles[`tok-${token.kind}`]}>{token.text}</span>
70
+ ),
71
+ )}
72
+ </code>
73
+ </pre>
74
+ </div>
75
+ );
76
+ }
@@ -111,3 +111,28 @@
111
111
  font-size: t.text(xs);
112
112
  color: t.role('fg-muted');
113
113
  }
114
+
115
+ .run {
116
+ margin: 0;
117
+ padding: 0;
118
+ list-style: none;
119
+ }
120
+
121
+ .groupLabel {
122
+ padding-block: t.space(2) t.space(1);
123
+ padding-inline: t.space(3);
124
+ color: t.role('fg-muted');
125
+ font-size: t.text(xs);
126
+ font-weight: t.weight(semibold);
127
+ }
128
+
129
+ // On a phone the palette is full width and anchored to the top, so the on-screen keyboard rising
130
+ // from the bottom never covers the filter or the first rows.
131
+ @include t.respond-down(sm) {
132
+ .palette {
133
+ inset-block-start: 0;
134
+ inline-size: 100%;
135
+ border-start-start-radius: 0;
136
+ border-start-end-radius: 0;
137
+ }
138
+ }
@@ -4,13 +4,17 @@
4
4
  // presentational: the open flag, the query and the active item all live in the caller, and the
5
5
  // rules (`filterItems`, `stepActive`, `keyAction`) live in `command-palette-view.ts` so the caller
6
6
  // does not rewrite them. Renders closed on the server unless told otherwise; with scripting off
7
- // it is inert markup.
7
+ // it is inert markup. Items carrying a `group` render under one `role="group"` heading per run;
8
+ // `useCommandPalette` is the shell that owns the open flag, the query, `⌘K` and focus.
8
9
 
9
10
  import type { JSX } from 'solid-js';
11
+ import { useId } from '../a11y';
10
12
  import { cx } from '../cx';
13
+ import { UI_KEYS } from '../i18n-keys';
14
+ import { useUi } from '../theme/context';
11
15
  import { solid } from '../theme/solid-adapter';
12
16
  import styles from './CommandPalette.module.scss';
13
- import type { CommandPaletteItem } from './command-palette-view';
17
+ import { type CommandPaletteItem, groupItems } from './command-palette-view';
14
18
  import { Kbd } from './Kbd';
15
19
 
16
20
  /** Every string the palette renders, already translated. */
@@ -39,6 +43,8 @@ export interface CommandPaletteProps {
39
43
  onHoverItem: (id: string) => void;
40
44
  onRunItem: (id: string) => void;
41
45
  onClose: () => void;
46
+ /** Items are being fetched for the query (`onQuery`): says so where the list goes. */
47
+ loading?: boolean | undefined;
42
48
  /** The element's id, so a trigger can name it with `aria-controls`. */
43
49
  id?: string | undefined;
44
50
  /** Refs out, so a caller can read focus or the attribute behind its own capability guard. */
@@ -49,6 +55,8 @@ export interface CommandPaletteProps {
49
55
 
50
56
  export function CommandPalette(props: CommandPaletteProps): JSX.Element {
51
57
  const rt = solid();
58
+ const ui = useUi();
59
+ const groupBase = useId('palette-group');
52
60
  let dialogEl: HTMLDialogElement | undefined;
53
61
  let filterEl: HTMLInputElement | undefined;
54
62
 
@@ -66,6 +74,24 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element {
66
74
  }
67
75
  });
68
76
 
77
+ const row = (item: CommandPaletteItem): JSX.Element => (
78
+ <li>
79
+ {/* A native button, never a row with a handler; roving TABINDEX, not roving focus. */}
80
+ <button
81
+ type="button"
82
+ class={styles['item']}
83
+ data-active={item.id === props.activeId ? 'true' : undefined}
84
+ tabIndex={item.id === props.activeId ? 0 : -1}
85
+ onClick={() => props.onRunItem(item.id)}
86
+ onMouseEnter={() => props.onHoverItem(item.id)}
87
+ onKeyDown={props.onFilterKeyDown}
88
+ >
89
+ <span class={styles['label']}>{item.label}</span>
90
+ {item.hint === '' ? null : <span class={styles['hint']}>{item.hint}</span>}
91
+ </button>
92
+ </li>
93
+ );
94
+
69
95
  return (
70
96
  <dialog
71
97
  ref={(el: HTMLDialogElement) => {
@@ -98,28 +124,30 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element {
98
124
  />
99
125
  <Kbd>{props.labels.escHint}</Kbd>
100
126
  </div>
101
- {props.items.length === 0 ? (
127
+ {props.loading === true ? (
128
+ <p class={styles['empty']} role="status">
129
+ {ui.t(UI_KEYS.loading)}
130
+ </p>
131
+ ) : props.items.length === 0 ? (
102
132
  <p class={styles['empty']}>{props.labels.emptyText}</p>
133
+ ) : props.items.every((item) => item.group === undefined) ? (
134
+ <ul class={styles['list']}>{props.items.map(row)}</ul>
103
135
  ) : (
104
- <ul class={styles['list']}>
105
- {props.items.map((item) => (
106
- <li>
107
- {/* A native button, never a row with a handler; roving TABINDEX, not roving focus. */}
108
- <button
109
- type="button"
110
- class={styles['item']}
111
- data-active={item.id === props.activeId ? 'true' : undefined}
112
- tabIndex={item.id === props.activeId ? 0 : -1}
113
- onClick={() => props.onRunItem(item.id)}
114
- onMouseEnter={() => props.onHoverItem(item.id)}
115
- onKeyDown={props.onFilterKeyDown}
116
- >
117
- <span class={styles['label']}>{item.label}</span>
118
- {item.hint === '' ? null : <span class={styles['hint']}>{item.hint}</span>}
119
- </button>
120
- </li>
121
- ))}
122
- </ul>
136
+ <div class={styles['list']}>
137
+ {groupItems(props.items).map((run, index) =>
138
+ run.group === undefined ? (
139
+ <ul class={styles['run']}>{run.items.map(row)}</ul>
140
+ ) : (
141
+ // biome-ignore lint/a11y/useSemanticElements: commands, not form controls; a fieldset is a form's group
142
+ <div role="group" aria-labelledby={`${groupBase}-${index}`}>
143
+ <p class={styles['groupLabel']} id={`${groupBase}-${index}`}>
144
+ {run.group}
145
+ </p>
146
+ <ul class={styles['run']}>{run.items.map(row)}</ul>
147
+ </div>
148
+ ),
149
+ )}
150
+ </div>
123
151
  )}
124
152
  </div>
125
153
  </dialog>
@@ -0,0 +1,36 @@
1
+ @use '../tokens' as t;
2
+
3
+ // The trigger flows where the caller put it; the dialog sits in the top layer once open.
4
+ .root {
5
+ display: contents;
6
+ }
7
+
8
+ .dialog {
9
+ // Full width on a phone, flush to the bottom edge where the thumb is.
10
+ @include t.respond-down(sm) {
11
+ inline-size: 100%;
12
+ max-inline-size: 100%;
13
+ margin-block-end: 0;
14
+ border-end-start-radius: 0;
15
+ border-end-end-radius: 0;
16
+ padding-block-end: t.safe-area-inset(block-end);
17
+ }
18
+ }
19
+
20
+ .consequence {
21
+ color: t.role('fg');
22
+ }
23
+
24
+ .actions {
25
+ display: flex;
26
+ flex-wrap: wrap;
27
+ gap: t.space(3);
28
+ justify-content: flex-end;
29
+
30
+ // Stacked below `sm`, primary LAST — the bottom row is the one in thumb reach.
31
+ @include t.respond-down(sm) {
32
+ flex-direction: column;
33
+ align-items: stretch;
34
+ inline-size: 100%;
35
+ }
36
+ }
@@ -0,0 +1,188 @@
1
+ // A destructive confirmation, the one way: a trigger that opens a modal `<dialog>` by invoker
2
+ // command (`commandfor` + `show-modal`, no script), a real `<form method="post">` inside it, Cancel
3
+ // holding `autofocus` so a stray Enter destroys nothing, Esc closing and the backdrop never.
4
+ //
5
+ // With `onConfirm` instead of `action` an island runs the act: busy refuses a second submit, a
6
+ // refusal keeps the dialog open with the reason in the form summary, focused. The native
7
+ // `<dialog>` and `Dialog`'s sheet, not `Dialog` itself: that one opens from an effect and closes
8
+ // from a script button, and this one must open, cancel and post with scripting off.
9
+
10
+ import type { JSX } from 'solid-js';
11
+ import { useId } from '../a11y';
12
+ import { cx } from '../cx';
13
+ import { UI_KEYS } from '../i18n-keys';
14
+ import { useUi } from '../theme/context';
15
+ import { solid } from '../theme/solid-adapter';
16
+ import buttonStyles from './Button.module.scss';
17
+ import { buttonClassKeys } from './button-classes';
18
+ import styles from './ConfirmDialog.module.scss';
19
+ import {
20
+ CONFIRM_IDLE,
21
+ type ConfirmEvent,
22
+ type ConfirmState,
23
+ confirmLabelOf,
24
+ confirmStep,
25
+ } from './confirm-dialog-view';
26
+ import dialogStyles from './Dialog.module.scss';
27
+ import { Form } from './Form';
28
+ import type { Tone } from './variants';
29
+
30
+ interface ConfirmDialogBase {
31
+ /** Already translated. The dialog's accessible name: the question, "Withdraw this post?". */
32
+ title: string;
33
+ /** What happens, in one sentence, read before the act: "Readers lose the page at once." */
34
+ consequence: string;
35
+ /** The verb — "Withdraw post", never "OK". No default; an empty one is `X_UI_INVALID_VALUE`. */
36
+ confirmLabel: string;
37
+ /** The trigger button's label. */
38
+ trigger: string;
39
+ /** The confirm button's tone; `danger` when omitted. */
40
+ tone?: Tone | undefined;
41
+ /** Defaults to the translated `ui.cancel`. */
42
+ cancelLabel?: string | undefined;
43
+ /** A refusal to show on open — the server's, after a native post came back refused. */
44
+ error?: string | undefined;
45
+ /** Hidden inputs, and any gate on the act — a typed name, a reason — with native constraints. */
46
+ children?: JSX.Element | undefined;
47
+ id?: string | undefined;
48
+ class?: string | undefined;
49
+ }
50
+
51
+ /** Without script: the form posts natively to the action's route. */
52
+ export interface ConfirmDialogPostProps extends ConfirmDialogBase {
53
+ action: string;
54
+ onConfirm?: undefined;
55
+ }
56
+
57
+ /**
58
+ * In an island: the act runs here. Resolve `undefined` when done (the dialog closes), or an
59
+ * already-translated refusal (it stays open, saying so); a rejection reads as `ui.error.title`.
60
+ */
61
+ export interface ConfirmDialogIslandProps extends ConfirmDialogBase {
62
+ onConfirm: (data: FormData) => Promise<string | undefined>;
63
+ action?: undefined;
64
+ }
65
+
66
+ export type ConfirmDialogProps = ConfirmDialogPostProps | ConfirmDialogIslandProps;
67
+
68
+ /** Chrome ≥ 135, Safari ≥ 26.2, Firefox ≥ 144: the trigger opens the dialog with no script. */
69
+ const invokersNative = (): boolean =>
70
+ typeof HTMLButtonElement !== 'undefined' && 'commandForElement' in HTMLButtonElement.prototype;
71
+
72
+ export function ConfirmDialog(props: ConfirmDialogProps): JSX.Element {
73
+ const ui = useUi();
74
+ const rt = solid();
75
+ const id = props.id ?? useId('confirm');
76
+ const titleId = `${id}-title`;
77
+ const confirmLabel = confirmLabelOf(props.confirmLabel);
78
+ const [state, setState] = rt.createSignal<ConfirmState>(CONFIRM_IDLE);
79
+ let dialog: HTMLDialogElement | undefined;
80
+
81
+ const step = (event: ConfirmEvent): boolean => {
82
+ const next = confirmStep(state(), event);
83
+ if (next === null) return false;
84
+ setState(next);
85
+ return true;
86
+ };
87
+ const message = (): string | undefined => {
88
+ const now = state();
89
+ return now.kind === 'refused' ? now.message : props.error;
90
+ };
91
+
92
+ const onSubmit = (event: SubmitEvent): void => {
93
+ const onConfirm = props.onConfirm;
94
+ // Cancel is `formmethod="dialog"`: the platform closes the dialog, nothing is confirmed.
95
+ const submitter = event.submitter as HTMLButtonElement | null;
96
+ if (onConfirm === undefined || submitter?.getAttribute('formmethod') === 'dialog') return;
97
+ event.preventDefault();
98
+ if (!step({ type: 'submit' })) return;
99
+ const form = event.currentTarget as HTMLFormElement;
100
+ onConfirm(new FormData(form)).then(
101
+ (refusal) => {
102
+ step({ type: 'settled', refusal });
103
+ if (refusal === undefined) dialog?.close();
104
+ },
105
+ () => step({ type: 'settled', refusal: ui.t(UI_KEYS.error) }),
106
+ );
107
+ };
108
+
109
+ return (
110
+ <div class={cx(styles['root'], props.class)}>
111
+ <button
112
+ type="button"
113
+ class={cx(
114
+ ...buttonClassKeys({ variant: 'secondary', tone: props.tone ?? 'danger' }).map(
115
+ (key) => buttonStyles[key],
116
+ ),
117
+ )}
118
+ commandfor={id}
119
+ command="show-modal"
120
+ onClick={() => {
121
+ // The gap an engine without invoker commands leaves, filled where there is a script.
122
+ if (!invokersNative() && dialog !== undefined && !dialog.open) dialog.showModal();
123
+ }}
124
+ >
125
+ <span class={buttonStyles['label']}>{props.trigger}</span>
126
+ </button>
127
+ {/* No backdrop handler at all: a confirmation is never dismissed by a stray click. */}
128
+ <dialog
129
+ ref={(el: HTMLDialogElement) => {
130
+ dialog = el;
131
+ }}
132
+ id={id}
133
+ class={cx(dialogStyles['dialog'], dialogStyles['size-sm'], styles['dialog'])}
134
+ aria-labelledby={titleId}
135
+ onClose={() => step({ type: 'closed' })}
136
+ >
137
+ <div class={dialogStyles['panel']}>
138
+ <header class={dialogStyles['header']}>
139
+ <h2 class={dialogStyles['title']} id={titleId}>
140
+ {props.title}
141
+ </h2>
142
+ </header>
143
+ <div class={dialogStyles['body']}>
144
+ <Form
145
+ method="post"
146
+ action={props.action}
147
+ error={message()}
148
+ busy={state().kind === 'busy'}
149
+ onSubmit={onSubmit}
150
+ actions={
151
+ <div class={styles['actions']}>
152
+ {/* `autofocus`: showModal() lands here, so Enter straight after open cancels. */}
153
+ <button
154
+ type="submit"
155
+ formmethod="dialog"
156
+ formnovalidate
157
+ autofocus
158
+ class={cx(
159
+ ...buttonClassKeys({ variant: 'secondary' }).map((key) => buttonStyles[key]),
160
+ )}
161
+ >
162
+ <span class={buttonStyles['label']}>
163
+ {props.cancelLabel ?? ui.t(UI_KEYS.cancel)}
164
+ </span>
165
+ </button>
166
+ <button
167
+ type="submit"
168
+ class={cx(
169
+ ...buttonClassKeys({ tone: props.tone ?? 'danger' }).map(
170
+ (key) => buttonStyles[key],
171
+ ),
172
+ )}
173
+ aria-busy={state().kind === 'busy' ? 'true' : undefined}
174
+ >
175
+ <span class={buttonStyles['label']}>{confirmLabel}</span>
176
+ </button>
177
+ </div>
178
+ }
179
+ >
180
+ <p class={styles['consequence']}>{props.consequence}</p>
181
+ {props.children}
182
+ </Form>
183
+ </div>
184
+ </div>
185
+ </dialog>
186
+ </div>
187
+ );
188
+ }