@adecore/ui 0.38.0 → 0.40.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 (210) hide show
  1. package/dist/AccentSwatches.js +2 -3
  2. package/dist/Banner.js +1 -6
  3. package/dist/ChoiceCards.js +0 -5
  4. package/dist/ColorSwatch.js +1 -4
  5. package/dist/ColorWheelPicker.js +3 -6
  6. package/dist/DockShell.js +3 -5
  7. package/dist/EmptyState.js +0 -1
  8. package/dist/ErrorBoundary.js +1 -5
  9. package/dist/Field.js +2 -6
  10. package/dist/FileIcon.js +2 -4
  11. package/dist/Icon.js +1 -2
  12. package/dist/IconButton.js +1 -1
  13. package/dist/IconPicker.js +3 -7
  14. package/dist/Input.js +1 -2
  15. package/dist/Kbd.js +1 -3
  16. package/dist/LazyBoundary.js +2 -3
  17. package/dist/Meter.js +0 -1
  18. package/dist/MiddleTruncate.js +2 -6
  19. package/dist/PanelEmpty.js +0 -1
  20. package/dist/PanelHeader.js +1 -4
  21. package/dist/Pill.js +1 -6
  22. package/dist/ProjectSwitcher.js +1 -1
  23. package/dist/PromptDialog.js +5 -8
  24. package/dist/RelativeTime.js +1 -1
  25. package/dist/SegmentBar.js +1 -2
  26. package/dist/Segmented.js +2 -5
  27. package/dist/Select.js +1 -4
  28. package/dist/ShortcutHints.js +2 -3
  29. package/dist/Slider.js +0 -1
  30. package/dist/SlidingColumn.js +2 -4
  31. package/dist/SplitButton.js +1 -5
  32. package/dist/TextMenu.js +1 -5
  33. package/dist/Tile.js +0 -4
  34. package/dist/Toasts.js +1 -9
  35. package/dist/Tooltip.js +1 -1
  36. package/dist/UIProvider.js +0 -5
  37. package/dist/Waveform.js +4 -2
  38. package/dist/Wipe.js +1 -6
  39. package/dist/ZoomControls.js +0 -1
  40. package/dist/clipboard.js +2 -5
  41. package/dist/combobox/parts.js +3 -9
  42. package/dist/command-palette/parts.js +5 -14
  43. package/dist/dialog/parts.js +5 -10
  44. package/dist/dialog-layer.js +2 -3
  45. package/dist/error-boundary.js +4 -20
  46. package/dist/file-icon.js +4 -9
  47. package/dist/file-tree/empty-lines.js +2 -8
  48. package/dist/file-tree/use-empty-lines.js +1 -5
  49. package/dist/file-tree/use-slots.js +6 -10
  50. package/dist/format/datetime.js +32 -29
  51. package/dist/format/duration.js +2 -10
  52. package/dist/format/locale.js +19 -39
  53. package/dist/format/number.js +9 -22
  54. package/dist/format/regions.js +4 -7
  55. package/dist/icon-picker.js +5 -14
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +1 -0
  58. package/dist/key-value-list/parts.js +1 -5
  59. package/dist/lazy.js +4 -19
  60. package/dist/locales.js +2 -6
  61. package/dist/menu/parts.js +61 -133
  62. package/dist/middle-truncate.js +2 -3
  63. package/dist/placement.js +0 -1
  64. package/dist/popover/parts.js +1 -5
  65. package/dist/popup-layer.js +2 -4
  66. package/dist/prefetch.js +2 -5
  67. package/dist/selection.js +1 -5
  68. package/dist/settings/MasterDetail.js +1 -7
  69. package/dist/settings/SettingsDialog.js +1 -2
  70. package/dist/settings/SettingsRow.js +2 -5
  71. package/dist/settings/SettingsSection.js +1 -1
  72. package/dist/shortcut-hints.js +1 -5
  73. package/dist/shortcut.js +1 -2
  74. package/dist/tabs/overflow.js +1 -6
  75. package/dist/tabs/parts.js +69 -95
  76. package/dist/testing/dedupe.js +5 -7
  77. package/dist/tint.d.ts +2 -0
  78. package/dist/tint.js +11 -0
  79. package/dist/toast-store.js +2 -10
  80. package/dist/tree/use-tree-shift.js +2 -6
  81. package/dist/useColumnResize.js +9 -10
  82. package/dist/useNow.js +2 -9
  83. package/dist/workspace/DropPreview.js +1 -5
  84. package/dist/workspace/preview.js +4 -9
  85. package/package.json +9 -9
  86. package/src/AccentSwatches.tsx +9 -3
  87. package/src/Banner.tsx +5 -11
  88. package/src/Button.tsx +4 -0
  89. package/src/Checkbox.tsx +3 -0
  90. package/src/ChoiceCards.tsx +12 -11
  91. package/src/CloseButton.tsx +1 -0
  92. package/src/ColorSwatch.tsx +7 -8
  93. package/src/ColorWheelPicker.tsx +28 -12
  94. package/src/ColumnResizeHandle.tsx +1 -0
  95. package/src/CopyButton.tsx +6 -0
  96. package/src/DisabledReason.tsx +1 -0
  97. package/src/DockShell.tsx +11 -10
  98. package/src/EmptyState.tsx +10 -6
  99. package/src/ErrorBoundary.tsx +13 -10
  100. package/src/Field.tsx +11 -13
  101. package/src/FileIcon.tsx +4 -4
  102. package/src/Icon.tsx +2 -4
  103. package/src/IconButton.tsx +14 -5
  104. package/src/IconPicker.tsx +30 -15
  105. package/src/Input.tsx +11 -9
  106. package/src/Kbd.tsx +8 -7
  107. package/src/LazyBoundary.tsx +3 -3
  108. package/src/Meter.tsx +7 -2
  109. package/src/MiddleTruncate.tsx +15 -6
  110. package/src/PanelEmpty.tsx +9 -7
  111. package/src/PanelHeader.tsx +2 -5
  112. package/src/Pill.tsx +7 -8
  113. package/src/ProjectSwitcher.tsx +16 -6
  114. package/src/PromptDialog.tsx +31 -26
  115. package/src/RelativeTime.tsx +6 -1
  116. package/src/SegmentBar.tsx +22 -11
  117. package/src/Segmented.tsx +16 -11
  118. package/src/Select.tsx +7 -4
  119. package/src/SelectSearch.tsx +3 -0
  120. package/src/ShortcutHints.tsx +24 -3
  121. package/src/Skeleton.tsx +1 -0
  122. package/src/Slider.tsx +4 -1
  123. package/src/SlidingColumn.tsx +18 -14
  124. package/src/Spinner.tsx +1 -0
  125. package/src/SplitButton.tsx +8 -8
  126. package/src/Stepper.tsx +6 -0
  127. package/src/Switch.tsx +2 -0
  128. package/src/TextMenu.tsx +2 -5
  129. package/src/Tile.tsx +2 -6
  130. package/src/Toasts.tsx +8 -12
  131. package/src/Tooltip.tsx +10 -5
  132. package/src/UIProvider.tsx +7 -7
  133. package/src/Waveform.tsx +41 -12
  134. package/src/Wipe.tsx +5 -6
  135. package/src/ZoomControls.tsx +5 -2
  136. package/src/clipboard.ts +4 -5
  137. package/src/combobox/parts.tsx +18 -14
  138. package/src/command-palette/context.ts +2 -0
  139. package/src/command-palette/index.parts.ts +1 -0
  140. package/src/command-palette/parts.tsx +20 -14
  141. package/src/command-palette/virtual.tsx +21 -0
  142. package/src/dialog/parts.tsx +10 -13
  143. package/src/dialog-layer.ts +5 -3
  144. package/src/error-boundary.ts +7 -20
  145. package/src/file-icon.ts +8 -9
  146. package/src/file-tree/empty-lines.ts +24 -8
  147. package/src/file-tree/helpers.ts +34 -0
  148. package/src/file-tree/highlight.ts +19 -0
  149. package/src/file-tree/index.parts.ts +1 -0
  150. package/src/file-tree/parts.tsx +41 -5
  151. package/src/file-tree/use-empty-lines.ts +22 -5
  152. package/src/file-tree/use-file-tree-shift.tsx +10 -0
  153. package/src/file-tree/use-file-tree.ts +2 -0
  154. package/src/file-tree/use-slots.ts +77 -12
  155. package/src/format/datetime.ts +42 -17
  156. package/src/format/duration.ts +28 -10
  157. package/src/format/index.ts +3 -0
  158. package/src/format/locale.ts +33 -42
  159. package/src/format/number.ts +21 -27
  160. package/src/format/regions.ts +4 -7
  161. package/src/icon-picker.ts +23 -16
  162. package/src/index.ts +9 -0
  163. package/src/key-value-list/parts.tsx +1 -5
  164. package/src/lazy-landings.ts +2 -0
  165. package/src/lazy.tsx +23 -19
  166. package/src/locales.ts +2 -6
  167. package/src/match.ts +14 -0
  168. package/src/menu/parts.tsx +30 -55
  169. package/src/middle-truncate.ts +12 -3
  170. package/src/modality.ts +4 -0
  171. package/src/placement.ts +2 -1
  172. package/src/platform.ts +2 -0
  173. package/src/popover/parts.tsx +3 -5
  174. package/src/popup-layer.ts +6 -4
  175. package/src/prefetch.ts +18 -5
  176. package/src/project-switcher-row.tsx +2 -0
  177. package/src/segmented-keys.ts +6 -0
  178. package/src/select-shared.ts +2 -0
  179. package/src/selection.ts +5 -5
  180. package/src/settings/ConfirmDialog.tsx +3 -0
  181. package/src/settings/MasterDetail.tsx +3 -9
  182. package/src/settings/SettingsDialog.tsx +27 -9
  183. package/src/settings/SettingsRow.tsx +16 -10
  184. package/src/settings/SettingsSection.tsx +10 -5
  185. package/src/settings/index.ts +3 -0
  186. package/src/settings/target.ts +1 -0
  187. package/src/shortcut-hints.ts +10 -5
  188. package/src/shortcut.ts +26 -6
  189. package/src/tabs/overflow.ts +8 -6
  190. package/src/tabs/parts.tsx +50 -26
  191. package/src/testing/dedupe.ts +27 -7
  192. package/src/testing/fake-source.ts +1 -0
  193. package/src/tint.ts +13 -0
  194. package/src/toast-store.ts +20 -14
  195. package/src/tree/parts.tsx +8 -0
  196. package/src/tree/shift.ts +6 -0
  197. package/src/tree/use-tree-shift.tsx +23 -7
  198. package/src/useAsyncAction.ts +8 -0
  199. package/src/useColumnResize.ts +24 -15
  200. package/src/useContentSize.ts +5 -0
  201. package/src/useMeasuredWidth.ts +1 -0
  202. package/src/useNow.ts +6 -11
  203. package/src/workspace/DropPreview.tsx +3 -5
  204. package/src/workspace/SplitView.tsx +109 -0
  205. package/src/workspace/TabStrip.tsx +42 -0
  206. package/src/workspace/Workspace.tsx +5 -0
  207. package/src/workspace/context.ts +1 -0
  208. package/src/workspace/geometry.ts +49 -0
  209. package/src/workspace/model.ts +72 -0
  210. package/src/workspace/preview.ts +15 -15
package/src/Toasts.tsx CHANGED
@@ -27,14 +27,12 @@ const TONE = {
27
27
  /* The box is as tall as the title's own line, so what sits beside the title centers on that line. */
28
28
  const TITLE_LINE = 'flex h-(--text-sm--line-height) shrink-0 items-center';
29
29
 
30
- /*
31
- * How long the offer in a toast still stands, drawn from the timer that takes the toast away: the
32
- * ring starts as far along as that timer already is, so both end together. With motion off it stays full.
33
- */
30
+ /* Starts as far along as the timer that takes the toast away, so both end together. With motion off it stays full. */
34
31
  function TimerRing({ deadline, onDismiss }: { deadline: ToastDeadline; onDismiss(): void }) {
35
32
  const { t } = useTranslation('ui');
36
33
  const lifetime = deadline.end - deadline.start;
37
34
  const [elapsed] = useState(() => elapsedOf(deadline, Date.now()));
35
+
38
36
  return (
39
37
  <IconButton size="xs" label={t('action.dismiss')} className="group/ring relative -my-px" onClick={onDismiss}>
40
38
  <svg width={16} height={16} viewBox="0 0 16 16" className="-rotate-90" aria-hidden>
@@ -74,6 +72,7 @@ function ToastCard<T extends Toast>({ toast, footer, onDismiss }: { toast: T; fo
74
72
  const actions = [...(toast.action === undefined ? [] : [toast.action]), ...(toast.actions ?? [])];
75
73
  // An offer that runs out shows how long it has left instead.
76
74
  const countdown = actions.length > 0 ? toast.deadline : undefined;
75
+
77
76
  return (
78
77
  <Surface className="group flex items-start gap-3 rounded-[10px] py-2.5 pr-2 pl-3">
79
78
  <span className={clsx(TITLE_LINE, 'w-4 justify-center')}>
@@ -85,8 +84,7 @@ function ToastCard<T extends Toast>({ toast, footer, onDismiss }: { toast: T; fo
85
84
  </span>
86
85
  <div className="flex min-w-0 grow flex-col gap-1">
87
86
  <span className="text-sm break-words text-text">{toast.title}</span>
88
- {/* Wraps rather than clips: the line under the title is the one that says what went
89
- wrong, and a reason cut off at the card's edge is no reason at all. */}
87
+ {/* Wraps rather than clips: a reason cut off at the card's edge is no reason at all. */}
90
88
  {toast.description !== undefined && toast.description !== '' && (
91
89
  <span className="text-xs break-words text-pretty text-text-muted">{toast.description}</span>
92
90
  )}
@@ -122,20 +120,18 @@ function ToastCard<T extends Toast>({ toast, footer, onDismiss }: { toast: T; fo
122
120
 
123
121
  export interface ToastsProps<T extends Toast> {
124
122
  store: ToastStoreHook<T>;
125
- /* Drawn under the description, for what only the app knows how to offer, such as copying a command's output. */
123
+
124
+ /* Drawn under the description. */
126
125
  footer?: (toast: T) => ReactNode;
126
+
127
127
  className?: string;
128
128
  ref?: Ref<HTMLDivElement>;
129
129
  }
130
130
 
131
- /*
132
- * Bottom right, over everything: what an action is doing while it runs and how it went when it is
133
- * over, and a deletion that can still be taken back. A success takes itself away after four seconds,
134
- * a deletion after eight, and a failure waits to be read.
135
- */
136
131
  export function Toasts<T extends Toast>({ store, footer, className, ref }: ToastsProps<T>) {
137
132
  // Through `useStore`: the compiler takes a call of the prop itself for a plain function and would skip it on a later draw.
138
133
  const toasts = useStore(store, (state) => state.toasts);
134
+
139
135
  // Stays mounted with nothing in it: a screen reader skips what a live region says the moment it is inserted.
140
136
  return (
141
137
  <div
package/src/Tooltip.tsx CHANGED
@@ -19,28 +19,33 @@ export interface TooltipProps {
19
19
  label: ReactNode;
20
20
  open?: boolean;
21
21
  onOpenChange?(open: boolean): void;
22
+
22
23
  /* A virtual anchor positions a controlled hint over canvas content without adding an input target. */
23
24
  anchor?: ComponentProps<typeof BaseTooltip.Positioner>['anchor'];
25
+
24
26
  /* A shortcut, or a short phrase about a key that is not one ("Shift skips the cache"). */
25
27
  kbd?: Shortcut | string;
28
+
26
29
  side?: TooltipSide;
27
30
  sideOffset?: number;
28
- /* Makes the label the accessible name of the trigger as well: what an icon-only button needs,
29
- and the way to keep the name and the tooltip from ever saying two different things. */
31
+
32
+ /* Makes the label the accessible name of the trigger as well, so the name and the tooltip never say two different things. */
30
33
  name?: boolean;
31
- /* Keeps the hint closed, for a trigger that only needs one some of the time. */
34
+
32
35
  disabled?: boolean;
33
- /* The trigger element. Its own children and handlers are kept; Base UI merges the tooltip props in. */
36
+
37
+ /* Its own children and handlers are kept; Base UI merges the tooltip props in. */
34
38
  children?: ReactElement<Record<string, unknown>>;
35
39
  }
36
40
 
37
- /* A hint on hover and on keyboard focus. Every hint in an app is one of these, never a `title`. */
41
+ /* Every hint in an app is one of these, never a `title`. */
38
42
  export function Tooltip({ label, open, onOpenChange, anchor, kbd, side = 'top', sideOffset = 6, name = false, disabled = false, children }: TooltipProps) {
39
43
  // Held Cmd (Ctrl off macOS) prints this shortcut under the trigger (`ShortcutHints`).
40
44
  const hintRef = (element: HTMLElement | null) => {
41
45
  if (element === null || kbd === undefined || typeof kbd === 'string') {
42
46
  return undefined;
43
47
  }
48
+
44
49
  return registerShortcutHint(element, kbd);
45
50
  };
46
51
 
@@ -7,27 +7,27 @@ import { startInputModality } from './modality.ts';
7
7
  import { TooltipProvider } from './Tooltip.tsx';
8
8
 
9
9
  export interface UIProviderProps {
10
- /* The app's i18next instance. The library's `ui` namespace is added to it, in every language it ships. */
10
+ /* The library's `ui` namespace is added to it, in every language it ships. */
11
11
  i18n: I18n;
12
- /* Where the formatters read the language and the region a person set. Without one they write English in the region of that language. */
12
+
13
+ /* Without one the formatters write English in the region of that language. */
13
14
  formatSource?: FormatSource;
15
+
14
16
  children: ReactNode;
15
17
  }
16
18
 
17
- /*
18
- * Everything the library needs from an app, once, around the whole tree: its words in the app's
19
- * i18next, the source of the formatters, the shared tooltip delay and the note of which input device
20
- * is in use. Mount it above the first component of the library.
21
- */
22
19
  export function UIProvider({ i18n, formatSource, children }: UIProviderProps) {
23
20
  const [handed, setHanded] = useState<{ i18n: I18n | null; source: FormatSource | undefined }>({ i18n: null, source: undefined });
21
+
24
22
  // In render and not in an effect, so the first child already reads the words and the region.
25
23
  // Both calls only store what they are handed, so a render React throws away leaves nothing wrong behind.
26
24
  if (handed.i18n !== i18n || handed.source !== formatSource) {
27
25
  addUiResources(i18n);
26
+
28
27
  if (formatSource !== undefined) {
29
28
  setFormatSource(formatSource);
30
29
  }
30
+
31
31
  setHanded({ i18n, source: formatSource });
32
32
  }
33
33
 
package/src/Waveform.tsx CHANGED
@@ -7,6 +7,7 @@ import { useMeasuredWidth } from './useMeasuredWidth.ts';
7
7
 
8
8
  /* About one bar per 3px, each 2px wide; a bar sits at its own time, so it lines up with the marks and the playhead. */
9
9
  const BAR_PITCH = 3;
10
+
10
11
  const BAR_WIDTH = 2;
11
12
 
12
13
  /* The height of a silent bar, so silence still reads as a line. */
@@ -20,50 +21,62 @@ const barsOf = (levels: readonly number[], duration: number, from: number, to: n
20
21
  if (count <= 0 || levels.length === 0 || duration <= 0 || to <= from) {
21
22
  return [];
22
23
  }
24
+
23
25
  const perSecond = levels.length / duration;
26
+
24
27
  return Array.from({ length: count }, (_, i) => {
25
28
  const start = from + ((to - from) * i) / count;
26
29
  const end = from + ((to - from) * (i + 1)) / count;
30
+
27
31
  if (end <= 0 || start >= duration) {
28
32
  return null;
29
33
  }
34
+
30
35
  const first = Math.max(0, Math.floor(start * perSecond));
31
36
  const last = Math.min(levels.length - 1, Math.max(first, Math.ceil(end * perSecond) - 1));
32
37
  let level = 0;
38
+
33
39
  for (let index = first; index <= last; index++) {
34
40
  level = Math.max(level, levels[index] ?? 0);
35
41
  }
42
+
36
43
  return Math.min(1, Math.max(SILENCE, level));
37
44
  });
38
45
  };
39
46
 
40
47
  export interface WaveformProps {
41
- /* The loudness as samples spread evenly over the duration, from 0 for silence to 1 for the loudest, at any rate. */
48
+ /* Samples spread evenly over the duration, from 0 for silence to 1 for the loudest, at any rate. */
42
49
  levels: readonly number[];
43
- /* In seconds. */
50
+
51
+ /* In seconds, as are all times here. */
44
52
  duration: number;
45
- /* The stretch it shows, in seconds, such as the visible part of a zoomed timeline. Defaults to the whole. */
53
+
54
+ /* Defaults to the whole. */
46
55
  range?: readonly [from: number, to: number];
47
- /* How far it has played, in seconds. Left out, the waveform draws no playhead. */
56
+
57
+ /* Left out, the waveform draws no playhead. */
48
58
  value?: number;
49
- /* Seeks to a time in seconds. Without it the waveform only shows. */
59
+
60
+ /* Without it the waveform only shows. */
50
61
  onValueChange?(value: number): void;
51
- /* The time a drag lets go at, and the one a key moved to: where a player resumes what it held during the drag. */
62
+
63
+ /* Where a drag lets go and where a key moved to. */
52
64
  onValueCommitted?(value: number): void;
53
- /* How far an arrow key moves the playhead, in seconds. */
65
+
54
66
  step?: number;
55
- /* Hears a key before the waveform does. A key it calls `preventDefault` on, the waveform leaves alone and lets
56
- bubble, for a player around it that binds the key to something else. */
67
+
68
+ /* Runs first; a key it calls `preventDefault` on, the waveform leaves alone and lets bubble. */
57
69
  onKeyDown?(e: KeyboardEvent<HTMLDivElement>): void;
58
- /* Times in seconds drawn as lines through the waveform, such as cuts. */
70
+
59
71
  marks?: readonly number[];
60
72
  label: string;
73
+
61
74
  /* Its height, which the waveform fills. */
62
75
  className?: string;
76
+
63
77
  ref?: Ref<HTMLDivElement>;
64
78
  }
65
79
 
66
- /* The loudness of a piece of audio over time, played up to the playhead, where a press or a drag seeks. */
67
80
  export function Waveform({
68
81
  levels,
69
82
  duration,
@@ -88,11 +101,18 @@ export function Waveform({
88
101
  const bars = barsOf(levels, duration, from, to, count);
89
102
 
90
103
  const positionOf = (time: number): number => (span > 0 ? (time - from) / span : 0);
91
- const inView = (time: number): boolean => positionOf(time) >= 0 && positionOf(time) <= 1;
104
+
105
+ const inView = (time: number): boolean => {
106
+ const position = positionOf(time);
107
+
108
+ return position >= 0 && position <= 1;
109
+ };
110
+
92
111
  const clamp = (time: number): number => Math.min(duration, Math.max(0, time));
93
112
 
94
113
  const seekAt = (e: PointerEvent<HTMLDivElement>): void => {
95
114
  const rect = e.currentTarget.getBoundingClientRect();
115
+
96
116
  if (rect.width > 0) {
97
117
  dragged.current = clamp(from + ((e.clientX - rect.left) / rect.width) * span);
98
118
  onValueChange?.(dragged.current);
@@ -108,10 +128,13 @@ export function Waveform({
108
128
 
109
129
  const moveByKey = (e: KeyboardEvent<HTMLDivElement>): void => {
110
130
  onKeyDown?.(e);
131
+
111
132
  if (e.defaultPrevented) {
112
133
  return;
113
134
  }
135
+
114
136
  const at = value ?? 0;
137
+
115
138
  const moves: Record<string, number> = {
116
139
  ArrowLeft: at - step,
117
140
  ArrowDown: at - step,
@@ -122,10 +145,13 @@ export function Waveform({
122
145
  Home: 0,
123
146
  End: duration
124
147
  };
148
+
125
149
  const next = moves[e.key];
150
+
126
151
  if (next === undefined) {
127
152
  return;
128
153
  }
154
+
129
155
  // The slider owns these keys; a transport listening higher up would move the playhead a second time.
130
156
  e.preventDefault();
131
157
  e.stopPropagation();
@@ -172,7 +198,9 @@ export function Waveform({
172
198
  if (level === null) {
173
199
  return null;
174
200
  }
201
+
175
202
  const center = from + (span * (i + 0.5)) / count;
203
+
176
204
  return (
177
205
  <rect
178
206
  key={i}
@@ -186,6 +214,7 @@ export function Waveform({
186
214
  })}
187
215
  {marks.filter(inView).map((mark) => {
188
216
  const x = Math.round(positionOf(mark) * width) + 0.5;
217
+
189
218
  return (
190
219
  <line key={mark} x1={x} x2={x} y1={0} y2={100} strokeWidth={1} vectorEffect="non-scaling-stroke" className="stroke-accent opacity-60" />
191
220
  );
package/src/Wipe.tsx CHANGED
@@ -9,6 +9,7 @@ import { splitAt, splitForKey } from './wipe-split.ts';
9
9
  export interface WipeProps {
10
10
  /* The share of the width left of the handle, from 0 to 1. */
11
11
  value: number;
12
+
12
13
  onValueChange(value: number): void;
13
14
  before: ReactNode;
14
15
  after: ReactNode;
@@ -18,12 +19,7 @@ export interface WipeProps {
18
19
  ref?: Ref<HTMLDivElement>;
19
20
  }
20
21
 
21
- /*
22
- * Two versions of one picture in one frame: `before` left of the split and `after` right of it, with a
23
- * handle between them a person drags or moves with the arrow keys. It fills the positioned element it
24
- * sits in. The handle is a line with a round knob on it, or the plain line, which is dragged anywhere
25
- * along its height.
26
- */
22
+ /* It fills the positioned element it sits in. A plain line handle is dragged anywhere along its height. */
27
23
  export function Wipe({ value: split, onValueChange: onSplit, before, after, label, handle = 'knob', className, ref }: WipeProps) {
28
24
  const frame = useRef<HTMLDivElement>(null);
29
25
  const percent = `${split * 100}%`;
@@ -31,6 +27,7 @@ export function Wipe({ value: split, onValueChange: onSplit, before, after, labe
31
27
  function moveTo(clientX: number): void {
32
28
  const rect = frame.current?.getBoundingClientRect();
33
29
  const next = rect ? splitAt(clientX, rect) : null;
30
+
34
31
  if (next !== null) {
35
32
  onSplit(next);
36
33
  }
@@ -51,9 +48,11 @@ export function Wipe({ value: split, onValueChange: onSplit, before, after, labe
51
48
 
52
49
  function onKeyDown(e: KeyboardEvent<HTMLDivElement>): void {
53
50
  const next = splitForKey(split, e.key);
51
+
54
52
  if (next === null) {
55
53
  return;
56
54
  }
55
+
57
56
  // Only while the handle has the focus, so a canvas or a timeline around it never hears these keys.
58
57
  e.preventDefault();
59
58
  e.stopPropagation();
@@ -13,8 +13,10 @@ import { activeZoomPreset, steppedZoom, ZOOM_PRESETS } from './zoom.ts';
13
13
  export interface ZoomSelection {
14
14
  label: string;
15
15
  shortcut?: Shortcut;
16
+
16
17
  /* Nothing is selected, so the row is there but says so rather than doing nothing. */
17
18
  enabled: boolean;
19
+
18
20
  onZoom(): void;
19
21
  }
20
22
 
@@ -30,14 +32,16 @@ export interface ZoomLabels {
30
32
  export interface ZoomControlsProps {
31
33
  /* 1 is 100%. */
32
34
  zoom: number;
35
+
33
36
  onZoomChange(zoom: number): void;
34
37
  onFitAll(): void;
35
- /* The percentages in the menu under the readout. */
36
38
  presets?: readonly number[];
37
39
  labels?: ZoomLabels;
38
40
  shortcuts?: { zoomReset?: Shortcut; fitAll?: Shortcut };
41
+
39
42
  /* Left out by a surface with nothing to select. */
40
43
  selection?: ZoomSelection;
44
+
41
45
  className?: string;
42
46
  ref?: Ref<HTMLDivElement>;
43
47
  }
@@ -50,7 +54,6 @@ function MenuIcon({ icon }: { icon: LucideIcon }) {
50
54
  );
51
55
  }
52
56
 
53
- /* The zoom group of a dock: out, the readout with its presets, in, and fit everything. */
54
57
  export function ZoomControls({
55
58
  zoom,
56
59
  onZoomChange,
package/src/clipboard.ts CHANGED
@@ -1,15 +1,14 @@
1
- /*
2
- * The clipboard, as the app uses it. A browser hands text over only to a document that is focused
3
- * and allowed, and there is nothing a person can do about a refusal, so both sides stay quiet.
4
- */
1
+ /* A browser hands text over only to a focused, allowed document, and a person can do nothing about a refusal, so both sides stay quiet. */
5
2
 
6
- /* Resolves to whether the text got there, for a caller that tells the person; it never rejects. */
3
+ /* Never rejects. */
7
4
  export const copyText = async (text: string): Promise<boolean> => {
8
5
  try {
9
6
  if (!navigator.clipboard) {
10
7
  return false;
11
8
  }
9
+
12
10
  await navigator.clipboard.writeText(text);
11
+
13
12
  return true;
14
13
  } catch {
15
14
  return false;
@@ -12,7 +12,7 @@ import { splitPlacement, type PlacementKey } from '../placement.ts';
12
12
  import { renderPositioner } from '../popup-layer.ts';
13
13
  import type { Shortcut } from '../shortcut.ts';
14
14
 
15
- /* A line in the list instead of rows: nothing found, a search still running. It takes no room while it has nothing to say. */
15
+ /* It takes no room while it has nothing to say. */
16
16
  const LIST_NOTE = 'text-center text-xs text-text-faint not-empty:px-3 not-empty:py-4';
17
17
 
18
18
  /* The field keeps free text: Base UI's autocomplete, whose suggestions only offer text, rather than its combobox, which holds a value. */
@@ -20,14 +20,16 @@ export const ComboboxRoot = Autocomplete.Root;
20
20
 
21
21
  export type ComboboxInputProps = Omit<ComponentProps<typeof Autocomplete.Input>, 'size' | 'className'> & {
22
22
  className?: string;
23
+
23
24
  /* The heights of `Input`. */
24
25
  size?: 'md' | 'sm';
26
+
25
27
  mono?: boolean;
28
+
26
29
  /* Drawn in front of the text. `className` then goes to the box around the icon and the input; `ref` stays on the input. */
27
30
  icon?: LucideIcon;
28
31
  };
29
32
 
30
- /* The field the suggestions open under, drawn as the library's `Input`. */
31
33
  export function ComboboxInput({ size = 'md', mono = false, icon, className, id, ...props }: ComboboxInputProps) {
32
34
  const field = useFieldControl();
33
35
  const small = size === 'sm';
@@ -61,9 +63,9 @@ type Placement = Pick<ComponentProps<typeof Autocomplete.Positioner>, PlacementK
61
63
 
62
64
  export type ComboboxPopupProps = ComponentProps<typeof Autocomplete.Popup> & Placement;
63
65
 
64
- /* The portal, the positioner and the menu surface of the suggestions: under the field and at least as wide as it. */
65
66
  export function ComboboxPopup({ side = 'bottom', align = 'start', sideOffset = 6, className, ...props }: ComboboxPopupProps) {
66
67
  const [placement, popupProps] = splitPlacement(props);
68
+
67
69
  return (
68
70
  <Autocomplete.Portal>
69
71
  <Autocomplete.Positioner className="popup-positioner" render={renderPositioner} {...placement} side={side} align={align} sideOffset={sideOffset}>
@@ -75,28 +77,28 @@ export function ComboboxPopup({ side = 'bottom', align = 'start', sideOffset = 6
75
77
 
76
78
  export function ComboboxList(props: ComponentProps<typeof Autocomplete.List>) {
77
79
  const { t } = useTranslation('ui');
80
+
78
81
  return <Autocomplete.List aria-label={t('combobox.suggestions')} {...props} />;
79
82
  }
80
83
 
81
84
  export type ComboboxItemProps = ComponentProps<typeof Autocomplete.Item> & {
82
- /* Before the label, such as a 14px icon or a file's mark. */
83
85
  icon?: ReactNode;
84
- /* Beside the label and the first to give way when the row is narrow, such as the folder a file sits in. */
86
+
87
+ /* The first to give way when the row is narrow. */
85
88
  hint?: ReactNode;
86
- /* A second line under the label. */
89
+
87
90
  description?: ReactNode;
88
- /* At the end of the row, before the shortcut: a state, a count, a reference. */
91
+
92
+ /* Before the shortcut. */
89
93
  trailing?: ReactNode;
94
+
90
95
  shortcut?: Shortcut;
91
- /* Leaves the row out: only what makes it an option (the highlight, Enter, the click) stays, for a row that lays out itself, such as lines of source. */
96
+
97
+ /* Keeps only what makes it an option, for a row that lays out itself. */
92
98
  unstyled?: boolean;
93
99
  };
94
100
 
95
- /*
96
- * One suggestion. Its children are the label. `onClick` runs for a click and for Enter on the
97
- * highlighted row; Base UI clicks the row for Enter, so a modifier held with Enter is read in the
98
- * field's `onKeyDown` instead.
99
- */
101
+ /* Base UI clicks the row for Enter, so a modifier held with Enter is read in the field's `onKeyDown` instead. */
100
102
  export function ComboboxItem({ icon, hint, description, trailing, shortcut, unstyled = false, className, children, ...props }: ComboboxItemProps) {
101
103
  if (unstyled) {
102
104
  return (
@@ -105,7 +107,9 @@ export function ComboboxItem({ icon, hint, description, trailing, shortcut, unst
105
107
  </Autocomplete.Item>
106
108
  );
107
109
  }
110
+
108
111
  const label = <span className="min-w-0 truncate">{children}</span>;
112
+
109
113
  return (
110
114
  <Autocomplete.Item className={withClass(clsx('menu-item', description !== undefined && 'items-start'), className)} {...props}>
111
115
  {icon !== undefined && <span className="flex h-lh shrink-0 items-center text-text-faint">{icon}</span>}
@@ -141,7 +145,7 @@ export function ComboboxEmpty({ className, ...props }: ComponentProps<typeof Aut
141
145
  return <Autocomplete.Empty className={withClass(LIST_NOTE, className)} {...props} />;
142
146
  }
143
147
 
144
- /* A line read out as it changes, such as a search that is still running or how many it found. It stays mounted; only its children come and go. */
148
+ /* Read out as it changes. It stays mounted; only its children come and go. */
145
149
  export function ComboboxStatus({ className, ...props }: ComponentProps<typeof Autocomplete.Status>) {
146
150
  return <Autocomplete.Status className={withClass(LIST_NOTE, className)} {...props} />;
147
151
  }
@@ -3,8 +3,10 @@ import { createContext, useContext, type RefObject } from 'react';
3
3
  interface PaletteRefs {
4
4
  /* For the popup to put the focus in when it opens: a control before it would take it otherwise. */
5
5
  input: RefObject<HTMLInputElement | null>;
6
+
6
7
  /* For the root to scroll back to the top when the app sets the query. */
7
8
  list: RefObject<HTMLDivElement | null>;
9
+
8
10
  highlight(row: Element): void;
9
11
  virtualScroll: RefObject<((index: number, reason: string) => void) | null>;
10
12
  }
@@ -7,6 +7,7 @@ export {
7
7
  ComboboxItem as Item,
8
8
  ComboboxStatus as Status
9
9
  } from '../combobox/parts.tsx';
10
+
10
11
  export {
11
12
  CommandPaletteFooter as Footer,
12
13
  CommandPaletteInput as Input,
@@ -33,11 +33,7 @@ type DialogProps = Pick<ComponentProps<typeof DialogRoot>, 'open' | 'defaultOpen
33
33
 
34
34
  export type CommandPaletteRootProps<ItemValue> = AutocompleteProps<ItemValue> & DialogProps;
35
35
 
36
- /*
37
- * A dialog around a list that is always open. `open` and its handlers are the dialog's; the rest
38
- * is Base UI's autocomplete, so the query, the items and their filter work as in a combobox. The
39
- * first row is highlighted and stays so while the pointer leaves the list, which is what Enter runs.
40
- */
36
+ /* `open` and its handlers are the dialog's; the rest is Base UI's autocomplete. The first row stays highlighted while the pointer leaves the list, which is what Enter runs. */
41
37
  export function CommandPaletteRoot<ItemValue>({
42
38
  open,
43
39
  defaultOpen,
@@ -59,8 +55,7 @@ export function CommandPaletteRoot<ItemValue>({
59
55
  const [focusHighlight, setFocusHighlight] = useState(false);
60
56
  /*
61
57
  * Base UI moves the highlight to the first row for a typed query only. For a query the app sets,
62
- * the list is empty for one commit that is never painted: Base UI drops the highlight then and
63
- * puts it on the first row of what comes back.
58
+ * the list is empty for one unpainted commit, so Base UI highlights the first row of what comes back.
64
59
  */
65
60
  const [settledValue, setSettledValue] = useState(value);
66
61
  const resetting = value !== undefined && value !== settledValue;
@@ -69,9 +64,11 @@ export function CommandPaletteRoot<ItemValue>({
69
64
  if (!resetting) {
70
65
  return;
71
66
  }
67
+
72
68
  if (listRef.current !== null) {
73
69
  listRef.current.scrollTop = 0;
74
70
  }
71
+
75
72
  // The rows may come back only after Base UI has seen the empty list in a commit of its own.
76
73
  // oxlint-disable-next-line react/set-state-in-effect
77
74
  setSettledValue(value);
@@ -81,11 +78,13 @@ export function CommandPaletteRoot<ItemValue>({
81
78
  if (row.hasAttribute('data-highlighted')) {
82
79
  return;
83
80
  }
81
+
84
82
  // Base UI has no imperative highlight action and disables highlighting on item focus.
85
83
  // Enable pointer navigation just for this event when the app has disabled hover highlighting.
86
84
  if (!highlightItemOnHover) {
87
85
  flushSync(() => setFocusHighlight(true));
88
86
  }
87
+
89
88
  try {
90
89
  row.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, movementX: 1 }));
91
90
  } finally {
@@ -106,6 +105,7 @@ export function CommandPaletteRoot<ItemValue>({
106
105
  if (details.reason === 'input-change') {
107
106
  setSettledValue(next);
108
107
  }
108
+
109
109
  onValueChange?.(next, details);
110
110
  }}
111
111
  items={(resetting ? NO_ITEMS : items) as readonly ItemValue[] | undefined}
@@ -113,6 +113,7 @@ export function CommandPaletteRoot<ItemValue>({
113
113
  if (item !== undefined && virtualScroll.current !== null) {
114
114
  queueMicrotask(() => virtualScroll.current?.(details.index, details.reason));
115
115
  }
116
+
116
117
  onItemHighlighted?.(item, details);
117
118
  }}
118
119
  inline
@@ -132,13 +133,11 @@ export type CommandPalettePopupProps = Omit<DialogPopupProps, 'size'> & {
132
133
  size?: keyof typeof SIZE;
133
134
  };
134
135
 
135
- /*
136
- * The dialog's surface. It hangs from a fixed line near the top instead of the middle: the list
137
- * changes height with every key, and a centered popup would walk up and down the screen.
138
- */
136
+ /* It hangs from a fixed line near the top: the list changes height with every key, and a centered popup would walk up and down. */
139
137
  export function CommandPalettePopup({ size = 'md', initialFocus, className, ...props }: CommandPalettePopupProps) {
140
138
  const { t } = useTranslation('ui');
141
139
  const refs = useContext(PaletteRefsContext);
140
+
142
141
  return (
143
142
  <DialogPopup
144
143
  aria-label={t('palette.label')}
@@ -152,19 +151,24 @@ export function CommandPalettePopup({ size = 'md', initialFocus, className, ...p
152
151
  export type CommandPaletteInputProps = Omit<ComponentProps<typeof Autocomplete.Input>, 'className'> & {
153
152
  /* In front of the text instead of the magnifier: the mark of a mode, a button back. */
154
153
  leading?: ReactNode;
154
+
155
155
  /* After the text, before the Escape hint: toggles of a search, a button that uses the text. */
156
156
  trailing?: ReactNode;
157
+
157
158
  /* The Escape key at the end of the field. Off where Escape does something else first. */
158
159
  escapeHint?: boolean;
160
+
159
161
  /* A pattern or a path read back letter by letter. */
160
162
  mono?: boolean;
163
+
161
164
  /* Goes to the row around the field; `ref` stays on the input. */
162
165
  className?: string;
163
166
  };
164
167
 
165
- /* The field across the top. It keeps the keys while the arrows move the highlight; a control beside it should not take the focus on a click. */
168
+ /* It keeps the keys while the arrows move the highlight; a control beside it should not take the focus on a click. */
166
169
  export function CommandPaletteInput({ leading, trailing, escapeHint = true, mono = false, className, ref, ...props }: CommandPaletteInputProps) {
167
170
  const refs = useContext(PaletteRefsContext);
171
+
168
172
  return (
169
173
  <div className={clsx('relative flex shrink-0 items-center gap-2 border-b border-border px-3', className)}>
170
174
  {leading ?? <Icon icon={Search} size={14} className="shrink-0 text-text-faint" />}
@@ -180,10 +184,11 @@ export function CommandPaletteInput({ leading, trailing, escapeHint = true, mono
180
184
  );
181
185
  }
182
186
 
183
- /* The rows. It scrolls on its own, so the field and the footer stay put. */
187
+ /* It scrolls on its own, so the field and the footer stay put. */
184
188
  export function CommandPaletteList({ className, ref, onFocus, ...props }: ComponentProps<typeof Autocomplete.List>) {
185
189
  const { t } = useTranslation('ui');
186
190
  const refs = useContext(PaletteRefsContext);
191
+
187
192
  return (
188
193
  <Autocomplete.List
189
194
  ref={mergeRefs(ref, refs?.list)}
@@ -193,9 +198,11 @@ export function CommandPaletteList({ className, ref, onFocus, ...props }: Compon
193
198
  onFocus={(event) => {
194
199
  onFocus?.(event);
195
200
  const row = event.target.closest('[role="option"]');
201
+
196
202
  if (row === null || !event.currentTarget.contains(row) || refs?.input.current == null) {
197
203
  return;
198
204
  }
205
+
199
206
  refs.highlight(row);
200
207
  refs.input.current.focus({ preventScroll: true });
201
208
  }}
@@ -205,7 +212,6 @@ export function CommandPaletteList({ className, ref, onFocus, ...props }: Compon
205
212
 
206
213
  export type CommandPaletteFooterProps = useRender.ComponentProps<'div'>;
207
214
 
208
- /* Under the list: the keys that work here, a count, an action that leaves the palette. */
209
215
  export function CommandPaletteFooter({ render, className, ref, ...props }: CommandPaletteFooterProps) {
210
216
  return useRender({
211
217
  render,