@adecore/ui 0.39.0 → 0.40.1

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 (207) 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/key-value-list/parts.js +1 -5
  57. package/dist/lazy.js +4 -19
  58. package/dist/locales.js +2 -6
  59. package/dist/menu/parts.js +61 -133
  60. package/dist/middle-truncate.js +2 -3
  61. package/dist/placement.js +0 -1
  62. package/dist/popover/parts.js +1 -5
  63. package/dist/popup-layer.js +2 -4
  64. package/dist/prefetch.js +2 -5
  65. package/dist/selection.js +1 -5
  66. package/dist/settings/MasterDetail.js +1 -7
  67. package/dist/settings/SettingsDialog.js +1 -2
  68. package/dist/settings/SettingsRow.js +2 -5
  69. package/dist/settings/SettingsSection.js +1 -1
  70. package/dist/shortcut-hints.js +1 -5
  71. package/dist/shortcut.js +1 -2
  72. package/dist/tabs/overflow.js +1 -6
  73. package/dist/tabs/parts.js +69 -95
  74. package/dist/testing/dedupe.js +5 -7
  75. package/dist/tint.js +3 -5
  76. package/dist/toast-store.js +2 -10
  77. package/dist/tree/use-tree-shift.js +2 -6
  78. package/dist/useColumnResize.js +9 -10
  79. package/dist/useNow.js +2 -9
  80. package/dist/workspace/DropPreview.js +1 -5
  81. package/dist/workspace/preview.js +4 -9
  82. package/package.json +10 -10
  83. package/src/AccentSwatches.tsx +9 -3
  84. package/src/Banner.tsx +5 -11
  85. package/src/Button.tsx +4 -0
  86. package/src/Checkbox.tsx +3 -0
  87. package/src/ChoiceCards.tsx +12 -11
  88. package/src/CloseButton.tsx +1 -0
  89. package/src/ColorSwatch.tsx +7 -8
  90. package/src/ColorWheelPicker.tsx +28 -12
  91. package/src/ColumnResizeHandle.tsx +1 -0
  92. package/src/CopyButton.tsx +6 -0
  93. package/src/DisabledReason.tsx +1 -0
  94. package/src/DockShell.tsx +11 -10
  95. package/src/EmptyState.tsx +10 -6
  96. package/src/ErrorBoundary.tsx +13 -10
  97. package/src/Field.tsx +11 -13
  98. package/src/FileIcon.tsx +4 -4
  99. package/src/Icon.tsx +2 -4
  100. package/src/IconButton.tsx +14 -5
  101. package/src/IconPicker.tsx +30 -15
  102. package/src/Input.tsx +11 -9
  103. package/src/Kbd.tsx +8 -7
  104. package/src/LazyBoundary.tsx +3 -3
  105. package/src/Meter.tsx +7 -2
  106. package/src/MiddleTruncate.tsx +15 -6
  107. package/src/PanelEmpty.tsx +9 -7
  108. package/src/PanelHeader.tsx +2 -5
  109. package/src/Pill.tsx +7 -8
  110. package/src/ProjectSwitcher.tsx +16 -6
  111. package/src/PromptDialog.tsx +31 -26
  112. package/src/RelativeTime.tsx +6 -1
  113. package/src/SegmentBar.tsx +22 -11
  114. package/src/Segmented.tsx +16 -11
  115. package/src/Select.tsx +7 -4
  116. package/src/SelectSearch.tsx +3 -0
  117. package/src/ShortcutHints.tsx +24 -3
  118. package/src/Skeleton.tsx +1 -0
  119. package/src/Slider.tsx +4 -1
  120. package/src/SlidingColumn.tsx +18 -14
  121. package/src/Spinner.tsx +1 -0
  122. package/src/SplitButton.tsx +8 -8
  123. package/src/Stepper.tsx +6 -0
  124. package/src/Switch.tsx +2 -0
  125. package/src/TextMenu.tsx +2 -5
  126. package/src/Tile.tsx +2 -6
  127. package/src/Toasts.tsx +8 -12
  128. package/src/Tooltip.tsx +10 -5
  129. package/src/UIProvider.tsx +7 -7
  130. package/src/Waveform.tsx +41 -12
  131. package/src/Wipe.tsx +5 -6
  132. package/src/ZoomControls.tsx +5 -2
  133. package/src/clipboard.ts +4 -5
  134. package/src/combobox/parts.tsx +18 -14
  135. package/src/command-palette/context.ts +2 -0
  136. package/src/command-palette/index.parts.ts +1 -0
  137. package/src/command-palette/parts.tsx +20 -14
  138. package/src/command-palette/virtual.tsx +21 -0
  139. package/src/dialog/parts.tsx +10 -13
  140. package/src/dialog-layer.ts +5 -3
  141. package/src/error-boundary.ts +7 -20
  142. package/src/file-icon.ts +8 -9
  143. package/src/file-tree/empty-lines.ts +24 -8
  144. package/src/file-tree/helpers.ts +34 -0
  145. package/src/file-tree/highlight.ts +19 -0
  146. package/src/file-tree/index.parts.ts +1 -0
  147. package/src/file-tree/parts.tsx +41 -5
  148. package/src/file-tree/use-empty-lines.ts +22 -5
  149. package/src/file-tree/use-file-tree-shift.tsx +10 -0
  150. package/src/file-tree/use-file-tree.ts +2 -0
  151. package/src/file-tree/use-slots.ts +77 -12
  152. package/src/format/datetime.ts +42 -17
  153. package/src/format/duration.ts +28 -10
  154. package/src/format/index.ts +3 -0
  155. package/src/format/locale.ts +33 -42
  156. package/src/format/number.ts +21 -27
  157. package/src/format/regions.ts +4 -7
  158. package/src/icon-picker.ts +23 -16
  159. package/src/index.ts +8 -0
  160. package/src/key-value-list/parts.tsx +1 -5
  161. package/src/lazy-landings.ts +2 -0
  162. package/src/lazy.tsx +23 -19
  163. package/src/locales.ts +2 -6
  164. package/src/match.ts +14 -0
  165. package/src/menu/parts.tsx +30 -55
  166. package/src/middle-truncate.ts +12 -3
  167. package/src/modality.ts +4 -0
  168. package/src/placement.ts +2 -1
  169. package/src/platform.ts +2 -0
  170. package/src/popover/parts.tsx +3 -5
  171. package/src/popup-layer.ts +6 -4
  172. package/src/prefetch.ts +18 -5
  173. package/src/project-switcher-row.tsx +2 -0
  174. package/src/segmented-keys.ts +6 -0
  175. package/src/select-shared.ts +2 -0
  176. package/src/selection.ts +5 -5
  177. package/src/settings/ConfirmDialog.tsx +3 -0
  178. package/src/settings/MasterDetail.tsx +3 -9
  179. package/src/settings/SettingsDialog.tsx +27 -9
  180. package/src/settings/SettingsRow.tsx +16 -10
  181. package/src/settings/SettingsSection.tsx +10 -5
  182. package/src/settings/index.ts +3 -0
  183. package/src/settings/target.ts +1 -0
  184. package/src/shortcut-hints.ts +10 -5
  185. package/src/shortcut.ts +26 -6
  186. package/src/tabs/overflow.ts +8 -6
  187. package/src/tabs/parts.tsx +50 -26
  188. package/src/testing/dedupe.ts +27 -7
  189. package/src/testing/fake-source.ts +1 -0
  190. package/src/tint.ts +3 -5
  191. package/src/toast-store.ts +20 -14
  192. package/src/tree/parts.tsx +8 -0
  193. package/src/tree/shift.ts +6 -0
  194. package/src/tree/use-tree-shift.tsx +23 -7
  195. package/src/useAsyncAction.ts +8 -0
  196. package/src/useColumnResize.ts +24 -15
  197. package/src/useContentSize.ts +5 -0
  198. package/src/useMeasuredWidth.ts +1 -0
  199. package/src/useNow.ts +6 -11
  200. package/src/workspace/DropPreview.tsx +3 -5
  201. package/src/workspace/SplitView.tsx +109 -0
  202. package/src/workspace/TabStrip.tsx +42 -0
  203. package/src/workspace/Workspace.tsx +5 -0
  204. package/src/workspace/context.ts +1 -0
  205. package/src/workspace/geometry.ts +49 -0
  206. package/src/workspace/model.ts +72 -0
  207. package/src/workspace/preview.ts +15 -15
@@ -4,22 +4,17 @@ import clsx from 'clsx';
4
4
  const LIST_WIDTHS = { 280: 'w-70', 320: 'w-80', 340: 'w-85' } as const;
5
5
 
6
6
  export interface MasterDetailProps {
7
- /* The column on the left, usually `MasterItem`s under their own headers. */
7
+ /* Usually `MasterItem`s under their own headers. */
8
8
  list: ReactNode;
9
+
9
10
  listWidth: keyof typeof LIST_WIDTHS;
10
- /* Names the list for a screen reader. */
11
11
  listLabel: string;
12
12
  detail: ReactNode;
13
13
  className?: string;
14
14
  ref?: Ref<HTMLDivElement>;
15
15
  }
16
16
 
17
- /*
18
- * A list beside the detail of what is picked in it, for a pane of many things of one kind (accounts,
19
- * devices, shortcut categories). Each side scrolls on its own, and the detail takes the rest of the
20
- * width, like a single-column pane. Its section must be `split`, so the dialog hands the pane its whole height
21
- * instead of a padded scrolling column.
22
- */
17
+ /* Its section must be `split`, so the dialog hands the pane its whole height instead of a padded scrolling column. */
23
18
  export function MasterDetail({ list, listWidth, listLabel, detail, className, ref }: MasterDetailProps) {
24
19
  return (
25
20
  <div ref={ref} className={clsx('flex min-h-0 min-w-0 grow border-t border-border max-[640px]:flex-col', className)}>
@@ -47,7 +42,6 @@ export interface MasterItemProps {
47
42
  ref?: Ref<HTMLButtonElement>;
48
43
  }
49
44
 
50
- /* One row of the list. The picked one is lifted a shade. */
51
45
  export function MasterItem({ selected, onSelect, children, className, ref }: MasterItemProps) {
52
46
  return (
53
47
  <button
@@ -17,34 +17,40 @@ export interface SettingsSectionEntry {
17
17
  id: string;
18
18
  icon: LucideIcon;
19
19
  label: string;
20
- /* One line under the pane title that says what the pane is about. */
21
20
  description: string;
21
+
22
22
  /* Rendered in a `LazyBoundary`, so a lazy component loads when its section is opened. */
23
23
  pane: ComponentType;
24
+
24
25
  /* A list beside a detail that scrolls on its own (`MasterDetail`), instead of one padded column. */
25
26
  split?: boolean;
26
- /* A block above the pane that runs to its edges, with the title bar floating over its top. Loads with
27
- the pane; a `split` section draws none. */
27
+
28
+ /* Runs to the pane's edges, with the title bar floating over its top; a `split` section draws none. */
28
29
  hero?: ComponentType;
29
30
  }
30
31
 
31
32
  export interface SettingsGroupEntry {
32
33
  /* The first group usually goes without one. */
33
34
  label: string | null;
35
+
34
36
  sections: readonly SettingsSectionEntry[];
35
37
  }
36
38
 
37
39
  export interface SettingsSearchResult {
38
40
  section: string;
41
+
39
42
  /* The row to reveal (a `SettingsRow` `searchId`), or null for a result that is the pane itself. */
40
43
  id: string | null;
44
+
41
45
  label: string;
42
46
  }
43
47
 
44
48
  export interface SettingsSearch {
45
49
  find(query: string): readonly SettingsSearchResult[];
46
- /* The shortcut that focuses the field, as it is printed. */
50
+
51
+ /* The printed shortcut that focuses the field. */
47
52
  hint?: string;
53
+
48
54
  /* Focuses and selects the field each time it grows. */
49
55
  focusAt?: number;
50
56
  }
@@ -53,20 +59,24 @@ export interface SettingsDialogProps {
53
59
  open: boolean;
54
60
  onOpenChange(open: boolean): void;
55
61
  section: string;
56
- /* A tab or a section picked, or a search result with the row it leads to. */
57
62
  onNavigate(next: { section: string; target?: string | null }): void;
58
63
  groups: readonly SettingsGroupEntry[];
59
- /* At the foot of the navigation, under the groups. */
60
64
  footer?: readonly SettingsSectionEntry[];
65
+
61
66
  /* Under the footer sections, past a hairline: a section whose tab the app draws itself (a `Tabs.Tab`
62
67
  with the section's id as its value), such as an account. */
63
68
  account?: { section: SettingsSectionEntry; tab: ReactNode };
69
+
64
70
  search?: SettingsSearch;
71
+
65
72
  /* The row a search result jumped to, lit until `onTargetShown`. */
66
73
  target?: string | null;
74
+
67
75
  onTargetShown?(): void;
68
- /* Extra on the popup. */
76
+
77
+ /* On the popup. */
69
78
  className?: string;
79
+
70
80
  ref?: Ref<HTMLDivElement>;
71
81
  }
72
82
 
@@ -87,6 +97,7 @@ function NavTab({ section }: { section: SettingsSectionEntry }) {
87
97
 
88
98
  function SettingsNav({ groups, footer, account }: Pick<SettingsDialogProps, 'groups' | 'footer' | 'account'>) {
89
99
  const { t } = useTranslation('ui');
100
+
90
101
  return (
91
102
  <Tabs.List aria-label={t('settings.sections')} activateOnFocus className="flex min-h-0 grow flex-col">
92
103
  <div className="flex min-h-0 flex-col gap-3.5 overflow-y-auto">
@@ -148,8 +159,10 @@ function SearchField({ search, query, onQuery, onPick }: SearchFieldProps) {
148
159
  event.stopPropagation();
149
160
  onQuery('');
150
161
  }
162
+
151
163
  if (event.key === 'Enter') {
152
164
  const first = search.find(query)[0];
165
+
153
166
  if (first) {
154
167
  onPick(first);
155
168
  }
@@ -183,7 +196,6 @@ interface SearchResultsProps {
183
196
  onPick(result: SettingsSearchResult): void;
184
197
  }
185
198
 
186
- /* In place of the navigation while a query is typed: the panes and rows that match, each a jump to where it lives. */
187
199
  function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
188
200
  const { t, i18n } = useTranslation('ui');
189
201
  // The language is a dependency the linter cannot see; every result reads its words off i18next.
@@ -193,6 +205,7 @@ function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
193
205
  if (results.length === 0) {
194
206
  return <p className="px-2.5 py-1 text-xs break-words text-text-muted">{t('settings.search.none', { query: query.trim() })}</p>;
195
207
  }
208
+
196
209
  return (
197
210
  <ul className="flex min-h-0 flex-col gap-0.5 overflow-y-auto" aria-label={t('settings.search.results')}>
198
211
  {results.map((result) => (
@@ -211,7 +224,7 @@ function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
211
224
  );
212
225
  }
213
226
 
214
- /* Sections on the left, one pane on the right. A pane mounts only while its section is open. */
227
+ /* A pane mounts only while its section is open. */
215
228
  export function SettingsDialog({
216
229
  open,
217
230
  onOpenChange,
@@ -236,12 +249,15 @@ export function SettingsDialog({
236
249
  const barFill = useRef<HTMLDivElement>(null);
237
250
  // The latest callback behind a stable one, so a lit row's timer does not start over on every render.
238
251
  const latestShown = useRef(onTargetShown);
252
+
239
253
  useEffect(() => {
240
254
  latestShown.current = onTargetShown;
241
255
  });
256
+
242
257
  const targetValue = { target, shown: () => latestShown.current?.() };
243
258
 
244
259
  const labelOf = (id: string): string => sections.find((entry) => entry.id === id)?.label ?? id;
260
+
245
261
  const pick = (result: SettingsSearchResult): void => onNavigate({ section: result.section, target: result.id });
246
262
 
247
263
  return (
@@ -249,6 +265,7 @@ export function SettingsDialog({
249
265
  open={open}
250
266
  onOpenChange={(next) => {
251
267
  onOpenChange(next);
268
+
252
269
  if (!next) {
253
270
  setQuery('');
254
271
  }
@@ -302,6 +319,7 @@ export function SettingsDialog({
302
319
  {sections.map((entry) => {
303
320
  const Pane = entry.pane;
304
321
  const Hero = entry.split ? undefined : entry.hero;
322
+
305
323
  return (
306
324
  <Tabs.Panel
307
325
  key={entry.id}
@@ -11,18 +11,25 @@ const TARGET_MS = 1600;
11
11
  export interface SettingsRowProps {
12
12
  label: ReactNode;
13
13
  description?: ReactNode;
14
- /* The control, aligned right; it carries its own accessible name. */
14
+
15
+ /* It carries its own accessible name. */
15
16
  control?: ReactNode;
16
- /* Content under the label line that needs the whole width, like a list of swatches. */
17
+
18
+ /* Under the label line, at the whole width. */
17
19
  children?: ReactNode;
18
- /* Where the row only shows state, its whole line reads a shade softer. */
20
+
21
+ /* For a row that only shows state. */
19
22
  muted?: boolean;
20
- /* Before the label, held to its first line (`TopIcon`) or a tile of its own. */
23
+
24
+ /* Held to the label's first line (`TopIcon`) or a tile of its own. */
21
25
  leading?: ReactNode;
22
- /* A row that only makes sense under the one above it steps in. */
26
+
27
+ /* For a row that only makes sense under the one above it. */
23
28
  indent?: boolean;
29
+
24
30
  /* The id a search result names to lead here. */
25
31
  searchId?: string;
32
+
26
33
  className?: string;
27
34
  ref?: Ref<HTMLDivElement>;
28
35
  }
@@ -34,7 +41,7 @@ export interface TopIconProps {
34
41
  ref?: Ref<HTMLSpanElement>;
35
42
  }
36
43
 
37
- /* An icon beside text that may wrap: the box is one line of `text-sm` high, so the icon sits on the first line. */
44
+ /* The box is one line of `text-sm` high, so the icon sits on the first line of text that may wrap. */
38
45
  export function TopIcon({ icon, size = 16, className, ref }: TopIconProps) {
39
46
  return (
40
47
  <span ref={ref} className="flex h-5.5 shrink-0 items-center">
@@ -43,10 +50,7 @@ export function TopIcon({ icon, size = 16, className, ref }: TopIconProps) {
43
50
  );
44
51
  }
45
52
 
46
- /*
47
- * One setting: what it is on the left, the control on the right. Where the two do not fit side by
48
- * side (a tablet, a narrow window) the control wraps under the label instead of widening the dialog.
49
- */
53
+ /* Where label and control do not fit side by side, the control wraps under the label instead of widening the dialog. */
50
54
  export function SettingsRow({ label, description, control, children, muted = false, leading, indent = false, searchId, className, ref }: SettingsRowProps) {
51
55
  const row = useRef<HTMLDivElement>(null);
52
56
  const { target, shown } = useSettingsTarget();
@@ -56,8 +60,10 @@ export function SettingsRow({ label, description, control, children, muted = fal
56
60
  if (!targeted) {
57
61
  return;
58
62
  }
63
+
59
64
  row.current?.scrollIntoView({ block: 'center' });
60
65
  const timer = window.setTimeout(shown, TARGET_MS);
66
+
61
67
  return () => window.clearTimeout(timer);
62
68
  }, [targeted, shown]);
63
69
 
@@ -6,22 +6,27 @@ import { Icon } from '../Icon.tsx';
6
6
  export interface SettingsSectionProps {
7
7
  title?: string;
8
8
  description?: ReactNode;
9
- /* Drawn before the title, muted, for a pane whose sections are kinds of thing. */
9
+
10
+ /* For a pane whose sections are kinds of thing. */
10
11
  icon?: LucideIcon;
11
- /* A tag on the header line, after the action, such as where the settings apply. */
12
+
13
+ /* On the header line, after the action. */
12
14
  tag?: ReactNode;
13
- /* A small control on the header line, aligned right. */
15
+
14
16
  action?: ReactNode;
15
- /* A note under the card, in the faint color. */
17
+
18
+ /* A note under the card. */
16
19
  footer?: ReactNode;
20
+
17
21
  children: ReactNode;
18
22
  className?: string;
19
23
  ref?: Ref<HTMLElement>;
20
24
  }
21
25
 
22
- /* A titled group of rows in one card; rows divide themselves with a hairline. Without a title it is the card alone. */
26
+ /* Rows divide themselves with a hairline. Without a title it is the card alone. */
23
27
  export function SettingsSection({ title, description, icon, tag, action, footer, children, className, ref }: SettingsSectionProps) {
24
28
  const hasHeader = title !== undefined || description !== undefined || tag !== undefined || action !== undefined;
29
+
25
30
  return (
26
31
  <section ref={ref} className={clsx('flex min-w-0 flex-col gap-2.5', className)} aria-label={title}>
27
32
  {hasHeader && (
@@ -1,8 +1,10 @@
1
1
  /* The settings dialog and the parts a pane is built from. */
2
2
 
3
3
  export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog.tsx';
4
+
4
5
  export { DetailHeader, type DetailHeaderProps } from './DetailHeader.tsx';
5
6
  export { MasterDetail, MasterItem, type MasterDetailProps, type MasterItemProps } from './MasterDetail.tsx';
7
+
6
8
  export {
7
9
  SettingsDialog,
8
10
  type SettingsDialogProps,
@@ -11,6 +13,7 @@ export {
11
13
  type SettingsSearchResult,
12
14
  type SettingsSectionEntry
13
15
  } from './SettingsDialog.tsx';
16
+
14
17
  export { SettingsRow, TopIcon, type SettingsRowProps, type TopIconProps } from './SettingsRow.tsx';
15
18
  export { SettingsSection, type SettingsSectionProps } from './SettingsSection.tsx';
16
19
  export { useSettingsTarget } from './target.ts';
@@ -3,6 +3,7 @@ import { createContext, useContext } from 'react';
3
3
  interface SettingsTarget {
4
4
  /* The row a search result jumped to, by its `searchId`. */
5
5
  target: string | null;
6
+
6
7
  /* Called once the row was lit long enough, so it is not lit again on the next render. */
7
8
  shown(): void;
8
9
  }
@@ -15,6 +15,7 @@ let nextId = 0;
15
15
  export const registerShortcutHint = (element: Element, shortcut: Shortcut): (() => void) => {
16
16
  nextId += 1;
17
17
  targets.set(element, { id: nextId, shortcut });
18
+
18
19
  return () => {
19
20
  targets.delete(element);
20
21
  };
@@ -24,11 +25,7 @@ export const shortcutHintTargets = (): ReadonlyMap<Element, HintTarget> => targe
24
25
 
25
26
  export type HoldKey = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'repeat'>;
26
27
 
27
- /*
28
- * Whether the hints are up: the platform's modifier (Cmd on macOS, Ctrl elsewhere) held on its own for
29
- * `HINT_DELAY_MS`. Any other key, the modifier coming up or the caller cancelling takes them down, so
30
- * a shortcut pressed in passing never flashes them.
31
- */
28
+ /* The platform's modifier held on its own for `HINT_DELAY_MS`; any other key takes the hints down, so a shortcut pressed in passing never flashes them. */
32
29
  export class ModifierHold {
33
30
  private readonly apple: boolean;
34
31
  private readonly onChange: (shown: boolean) => void;
@@ -45,7 +42,9 @@ export class ModifierHold {
45
42
  if (event.repeat && event.key === this.modifierKey()) {
46
43
  return;
47
44
  }
45
+
48
46
  this.cancel();
47
+
49
48
  if (event.key === this.modifierKey() && !this.otherModifierHeld(event)) {
50
49
  this.timer = setTimeout(() => {
51
50
  this.timer = null;
@@ -66,6 +65,7 @@ export class ModifierHold {
66
65
  clearTimeout(this.timer);
67
66
  this.timer = null;
68
67
  }
68
+
69
69
  if (this.shown) {
70
70
  this.shown = false;
71
71
  this.onChange(false);
@@ -97,8 +97,10 @@ export interface HintPlacement {
97
97
  }
98
98
 
99
99
  const HINT_GAP_PX = 2;
100
+
100
101
  /* Room a hint needs to be centered under its button; nearer a window edge it lines up with that edge instead. */
101
102
  const EDGE_ROOM_PX = 48;
103
+
102
104
  const HINT_HEIGHT_PX = 24;
103
105
 
104
106
  /* Under the button, centered, and above it where the window ends first. */
@@ -107,11 +109,14 @@ export const placeHint = (rect: HintRect, viewport: { width: number; height: num
107
109
  const top = Math.round(below ? rect.bottom + HINT_GAP_PX : rect.top - HINT_GAP_PX);
108
110
  const translateY = below ? '0' : '-100%';
109
111
  const center = (rect.left + rect.right) / 2;
112
+
110
113
  if (center < EDGE_ROOM_PX) {
111
114
  return { left: Math.round(rect.left), top, translateX: '0', translateY };
112
115
  }
116
+
113
117
  if (center > viewport.width - EDGE_ROOM_PX) {
114
118
  return { right: Math.round(viewport.width - rect.right), top, translateX: '0', translateY };
115
119
  }
120
+
116
121
  return { left: Math.round(center), top, translateX: '-50%', translateY };
117
122
  };
package/src/shortcut.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /*
2
- * One shortcut, written once. The same value decides whether a key event is the shortcut and how the
3
- * shortcut is printed. `mod` is Cmd on macOS and Ctrl everywhere else; `ctrl` and `meta` name the
4
- * physical key and exist for the few shortcuts that differ per platform on purpose.
2
+ * `mod` is Cmd on macOS and Ctrl everywhere else; `ctrl` and `meta` name the physical key, for the few
3
+ * shortcuts that differ per platform on purpose.
5
4
  */
6
5
  export interface Shortcut {
7
6
  mod: boolean;
@@ -9,12 +8,12 @@ export interface Shortcut {
9
8
  meta: boolean;
10
9
  alt: boolean;
11
10
  shift: boolean;
11
+
12
12
  /* An uppercase letter, a digit, a punctuation mark or a named key (`Enter`, `ArrowLeft`, `F2`).
13
13
  Empty for modifiers held during a pointer gesture (`Mod` then "drag"), which never match a key. */
14
14
  key: string;
15
15
  }
16
16
 
17
- /* What a shortcut is matched against. A real `KeyboardEvent` satisfies it, a test writes one by hand. */
18
17
  export type KeyLike = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'key' | 'code'>;
19
18
 
20
19
  /* Matched on the physical key: Shift turns `[` into `{` and Option turns most keys into a dead key. */
@@ -56,19 +55,25 @@ export const shortcut = (text: string): Shortcut => {
56
55
  // A trailing `++` is the plus key itself, which a plain split would read as two empty words.
57
56
  const words = text === '+' ? ['+'] : text.endsWith('++') ? [...text.slice(0, -2).split('+'), '+'] : text.split('+');
58
57
  const result: Shortcut = { mod: false, ctrl: false, meta: false, alt: false, shift: false, key: '' };
58
+
59
59
  for (const word of words) {
60
60
  if (Object.hasOwn(MODIFIER_FIELDS, word)) {
61
61
  result[MODIFIER_FIELDS[word]!] = true;
62
+
62
63
  continue;
63
64
  }
65
+
64
66
  if (!isKey(word)) {
65
67
  throw new Error(`Unknown key "${word}" in shortcut "${text}"`);
66
68
  }
69
+
67
70
  if (result.key !== '') {
68
71
  throw new Error(`Shortcut "${text}" names two keys`);
69
72
  }
73
+
70
74
  result.key = word.length === 1 ? word.toUpperCase() : word;
71
75
  }
76
+
72
77
  return result;
73
78
  };
74
79
 
@@ -77,16 +82,21 @@ const matchesKey = (key: string, event: KeyLike): boolean => {
77
82
  if (/^[A-Z]$/.test(key)) {
78
83
  return event.code === `Key${key}`;
79
84
  }
85
+
80
86
  if (/^[0-9]$/.test(key)) {
81
87
  return event.code === `Digit${key}`;
82
88
  }
89
+
83
90
  const code = PUNCTUATION_CODES[key];
91
+
84
92
  if (code) {
85
93
  return event.code === code;
86
94
  }
95
+
87
96
  if (key === 'Space') {
88
97
  return event.code === 'Space';
89
98
  }
99
+
90
100
  return event.key === key;
91
101
  };
92
102
 
@@ -94,9 +104,11 @@ const matchesKey = (key: string, event: KeyLike): boolean => {
94
104
  export const matchesShortcut = (target: Shortcut, event: KeyLike, apple: boolean): boolean => {
95
105
  const meta = target.meta || (target.mod && apple);
96
106
  const ctrl = target.ctrl || (target.mod && !apple);
107
+
97
108
  if (target.key === '' || event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== target.alt || event.shiftKey !== target.shift) {
98
109
  return false;
99
110
  }
111
+
100
112
  return matchesKey(target.key, event);
101
113
  };
102
114
 
@@ -113,19 +125,23 @@ const KEY_LABELS: Record<string, { apple: string; other: string }> = {
113
125
  ArrowDown: { apple: '↓', other: '↓' }
114
126
  };
115
127
 
116
- /* The modifiers and the key as separate caps, in the order the platform prints them. */
128
+ /* In the order the platform prints them. */
117
129
  export const shortcutParts = (target: Shortcut, apple: boolean): string[] => {
118
130
  const parts: string[] = [];
131
+
119
132
  if (apple) {
120
133
  if (target.ctrl) {
121
134
  parts.push('⌃');
122
135
  }
136
+
123
137
  if (target.alt) {
124
138
  parts.push('⌥');
125
139
  }
140
+
126
141
  if (target.shift) {
127
142
  parts.push('⇧');
128
143
  }
144
+
129
145
  if (target.meta || target.mod) {
130
146
  parts.push('⌘');
131
147
  }
@@ -133,20 +149,25 @@ export const shortcutParts = (target: Shortcut, apple: boolean): string[] => {
133
149
  if (target.ctrl || target.mod) {
134
150
  parts.push('Ctrl');
135
151
  }
152
+
136
153
  if (target.alt) {
137
154
  parts.push('Alt');
138
155
  }
156
+
139
157
  if (target.shift) {
140
158
  parts.push('Shift');
141
159
  }
160
+
142
161
  if (target.meta) {
143
162
  parts.push('Meta');
144
163
  }
145
164
  }
165
+
146
166
  if (target.key !== '') {
147
167
  const label = KEY_LABELS[target.key];
148
168
  parts.push(label ? label[apple ? 'apple' : 'other'] : target.key);
149
169
  }
170
+
150
171
  return parts;
151
172
  };
152
173
 
@@ -171,5 +192,4 @@ export const KEY_SHORTCUTS = {
171
192
  rename: /* @__PURE__ */ shortcut('F2')
172
193
  } as const;
173
194
 
174
- /* Whether the platform's own modifier is down during a pointer gesture: Cmd on macOS, Ctrl elsewhere. */
175
195
  export const isModHeld = (event: Pick<KeyLike, 'metaKey' | 'ctrlKey'>, apple: boolean): boolean => (apple ? event.metaKey : event.ctrlKey);
@@ -1,27 +1,29 @@
1
- /*
2
- * The tabs a strip shows, by index, given the width of every tab, the room the strip has, the gap
3
- * between two tabs and the width of the button that opens the rest. Everything fits, or the tabs fit
4
- * from the start up to the button. The kept tabs (the picked one, the pinned ones) are never left to
5
- * the menu: they take the place of the last ones that no longer fit beside them.
6
- */
1
+ /* The kept tabs (the picked one, the pinned ones) never go to the menu: they take the place of the last ones that no longer fit beside them. */
7
2
  export const shownTabs = (widths: readonly number[], room: number, gap: number, more: number, kept: readonly number[]): number[] => {
8
3
  const span = (indices: readonly number[]): number => indices.reduce((sum, index) => sum + widths[index]!, 0) + gap * Math.max(0, indices.length - 1);
4
+
9
5
  const all = widths.map((_, index) => index);
6
+
10
7
  // Half a pixel of slack, since a measured width is fractional and the room is not.
11
8
  if (span(all) <= room + 0.5) {
12
9
  return all;
13
10
  }
11
+
14
12
  const limit = room - more - gap + 0.5;
15
13
  const held = all.filter((index) => kept.includes(index));
16
14
  const shown: number[] = [];
15
+
17
16
  for (const index of all) {
18
17
  if (held.includes(index)) {
19
18
  continue;
20
19
  }
20
+
21
21
  if (span([...held, ...shown, index]) > limit) {
22
22
  break;
23
23
  }
24
+
24
25
  shown.push(index);
25
26
  }
27
+
26
28
  return [...held, ...shown].sort((left, right) => left - right);
27
29
  };