@adecore/ui 0.14.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 (318) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/AccentSwatches.d.ts +15 -0
  4. package/dist/AccentSwatches.js +21 -0
  5. package/dist/Banner.d.ts +12 -0
  6. package/dist/Banner.js +18 -0
  7. package/dist/Button.d.ts +9 -0
  8. package/dist/Button.js +28 -0
  9. package/dist/ButtonGroup.d.ts +3 -0
  10. package/dist/ButtonGroup.js +11 -0
  11. package/dist/Checkbox.d.ts +11 -0
  12. package/dist/Checkbox.js +9 -0
  13. package/dist/ChoiceCards.d.ts +23 -0
  14. package/dist/ChoiceCards.js +21 -0
  15. package/dist/CloseButton.d.ts +15 -0
  16. package/dist/CloseButton.js +11 -0
  17. package/dist/ColorSwatch.d.ts +14 -0
  18. package/dist/ColorSwatch.js +28 -0
  19. package/dist/ColumnResizeHandle.d.ts +9 -0
  20. package/dist/ColumnResizeHandle.js +12 -0
  21. package/dist/DisabledReason.d.ts +6 -0
  22. package/dist/DisabledReason.js +8 -0
  23. package/dist/DockShell.d.ts +9 -0
  24. package/dist/DockShell.js +49 -0
  25. package/dist/EmptyState.d.ts +16 -0
  26. package/dist/EmptyState.js +11 -0
  27. package/dist/ErrorBoundary.d.ts +24 -0
  28. package/dist/ErrorBoundary.js +47 -0
  29. package/dist/Field.d.ts +17 -0
  30. package/dist/Field.js +48 -0
  31. package/dist/FileIcon.d.ts +8 -0
  32. package/dist/FileIcon.js +15 -0
  33. package/dist/Icon.d.ts +9 -0
  34. package/dist/Icon.js +12 -0
  35. package/dist/IconButton.d.ts +23 -0
  36. package/dist/IconButton.js +25 -0
  37. package/dist/IconPicker.d.ts +17 -0
  38. package/dist/IconPicker.js +93 -0
  39. package/dist/Input.d.ts +14 -0
  40. package/dist/Input.js +25 -0
  41. package/dist/Kbd.d.ts +23 -0
  42. package/dist/Kbd.js +20 -0
  43. package/dist/ListRow.d.ts +10 -0
  44. package/dist/ListRow.js +20 -0
  45. package/dist/Meter.d.ts +12 -0
  46. package/dist/Meter.js +7 -0
  47. package/dist/PanelEmpty.d.ts +16 -0
  48. package/dist/PanelEmpty.js +7 -0
  49. package/dist/PanelHeader.d.ts +6 -0
  50. package/dist/PanelHeader.js +20 -0
  51. package/dist/Pill.d.ts +27 -0
  52. package/dist/Pill.js +25 -0
  53. package/dist/ProjectSwitcher.d.ts +30 -0
  54. package/dist/ProjectSwitcher.js +18 -0
  55. package/dist/PromptDialog.d.ts +40 -0
  56. package/dist/PromptDialog.js +47 -0
  57. package/dist/SectionLabel.d.ts +3 -0
  58. package/dist/SectionLabel.js +16 -0
  59. package/dist/SegmentBar.d.ts +18 -0
  60. package/dist/SegmentBar.js +44 -0
  61. package/dist/Segmented.d.ts +17 -0
  62. package/dist/Segmented.js +7 -0
  63. package/dist/Select.d.ts +27 -0
  64. package/dist/Select.js +34 -0
  65. package/dist/Separator.d.ts +7 -0
  66. package/dist/Separator.js +8 -0
  67. package/dist/ShortcutHints.d.ts +1 -0
  68. package/dist/ShortcutHints.js +94 -0
  69. package/dist/Skeleton.d.ts +6 -0
  70. package/dist/Skeleton.js +6 -0
  71. package/dist/SlidingColumn.d.ts +19 -0
  72. package/dist/SlidingColumn.js +41 -0
  73. package/dist/Spinner.d.ts +6 -0
  74. package/dist/Spinner.js +7 -0
  75. package/dist/Stepper.d.ts +14 -0
  76. package/dist/Stepper.js +18 -0
  77. package/dist/Surface.d.ts +3 -0
  78. package/dist/Surface.js +16 -0
  79. package/dist/Switch.d.ts +10 -0
  80. package/dist/Switch.js +7 -0
  81. package/dist/TextMenu.d.ts +5 -0
  82. package/dist/TextMenu.js +31 -0
  83. package/dist/Tile.d.ts +11 -0
  84. package/dist/Tile.js +10 -0
  85. package/dist/Toasts.d.ts +9 -0
  86. package/dist/Toasts.js +58 -0
  87. package/dist/Tooltip.d.ts +16 -0
  88. package/dist/Tooltip.js +21 -0
  89. package/dist/UIProvider.d.ts +9 -0
  90. package/dist/UIProvider.js +26 -0
  91. package/dist/Waveform.d.ts +16 -0
  92. package/dist/Waveform.js +123 -0
  93. package/dist/Wipe.d.ts +12 -0
  94. package/dist/Wipe.js +47 -0
  95. package/dist/ZoomControls.d.ts +30 -0
  96. package/dist/ZoomControls.js +16 -0
  97. package/dist/class-name.d.ts +2 -0
  98. package/dist/class-name.js +3 -0
  99. package/dist/clipboard.d.ts +2 -0
  100. package/dist/clipboard.js +16 -0
  101. package/dist/context-menu/index.parts.d.ts +1 -0
  102. package/dist/context-menu/index.parts.js +2 -0
  103. package/dist/dialog/index.parts.d.ts +1 -0
  104. package/dist/dialog/index.parts.js +1 -0
  105. package/dist/dialog/parts.d.ts +36 -0
  106. package/dist/dialog/parts.js +55 -0
  107. package/dist/dialog-layer.d.ts +1 -0
  108. package/dist/dialog-layer.js +27 -0
  109. package/dist/error-boundary.d.ts +4 -0
  110. package/dist/error-boundary.js +34 -0
  111. package/dist/error-message.d.ts +1 -0
  112. package/dist/error-message.js +4 -0
  113. package/dist/field-context.d.ts +7 -0
  114. package/dist/field-context.js +4 -0
  115. package/dist/file-icon.d.ts +4 -0
  116. package/dist/file-icon.js +46 -0
  117. package/dist/floating.d.ts +5 -0
  118. package/dist/floating.js +6 -0
  119. package/dist/format/datetime.d.ts +11 -0
  120. package/dist/format/datetime.js +50 -0
  121. package/dist/format/duration.d.ts +5 -0
  122. package/dist/format/duration.js +76 -0
  123. package/dist/format/index.d.ts +6 -0
  124. package/dist/format/index.js +7 -0
  125. package/dist/format/locale.d.ts +17 -0
  126. package/dist/format/locale.js +105 -0
  127. package/dist/format/number.d.ts +8 -0
  128. package/dist/format/number.js +72 -0
  129. package/dist/format/regions.d.ts +6 -0
  130. package/dist/format/regions.js +15 -0
  131. package/dist/format/time-zone.d.ts +1 -0
  132. package/dist/format/time-zone.js +2 -0
  133. package/dist/icon-button-size.d.ts +3 -0
  134. package/dist/icon-button-size.js +3 -0
  135. package/dist/icon-picker.d.ts +23 -0
  136. package/dist/icon-picker.js +80 -0
  137. package/dist/index.d.ts +74 -0
  138. package/dist/index.js +74 -0
  139. package/dist/lazy.d.ts +12 -0
  140. package/dist/lazy.js +104 -0
  141. package/dist/locales/en.json +59 -0
  142. package/dist/locales/nl.json +59 -0
  143. package/dist/locales.d.ts +4 -0
  144. package/dist/locales.js +17 -0
  145. package/dist/menu/index.parts.d.ts +1 -0
  146. package/dist/menu/index.parts.js +1 -0
  147. package/dist/menu/parts.d.ts +67 -0
  148. package/dist/menu/parts.js +113 -0
  149. package/dist/merge-refs.d.ts +2 -0
  150. package/dist/merge-refs.js +11 -0
  151. package/dist/modality.d.ts +1 -0
  152. package/dist/modality.js +29 -0
  153. package/dist/platform.d.ts +2 -0
  154. package/dist/platform.js +9 -0
  155. package/dist/popover/index.parts.d.ts +1 -0
  156. package/dist/popover/index.parts.js +1 -0
  157. package/dist/popover/parts.d.ts +26 -0
  158. package/dist/popover/parts.js +28 -0
  159. package/dist/prefetch.d.ts +21 -0
  160. package/dist/prefetch.js +99 -0
  161. package/dist/preview-card/index.parts.d.ts +1 -0
  162. package/dist/preview-card/index.parts.js +1 -0
  163. package/dist/project-switcher-row.d.ts +6 -0
  164. package/dist/project-switcher-row.js +16 -0
  165. package/dist/selection.d.ts +2 -0
  166. package/dist/selection.js +22 -0
  167. package/dist/seti-icons.d.ts +5 -0
  168. package/dist/seti-icons.js +445 -0
  169. package/dist/settings/ConfirmDialog.d.ts +9 -0
  170. package/dist/settings/ConfirmDialog.js +9 -0
  171. package/dist/settings/DetailHeader.d.ts +10 -0
  172. package/dist/settings/DetailHeader.js +6 -0
  173. package/dist/settings/MasterDetail.d.ts +24 -0
  174. package/dist/settings/MasterDetail.js +16 -0
  175. package/dist/settings/SettingsDialog.d.ts +46 -0
  176. package/dist/settings/SettingsDialog.js +96 -0
  177. package/dist/settings/SettingsRow.d.ts +22 -0
  178. package/dist/settings/SettingsRow.js +30 -0
  179. package/dist/settings/SettingsSection.d.ts +14 -0
  180. package/dist/settings/SettingsSection.js +8 -0
  181. package/dist/settings/index.d.ts +7 -0
  182. package/dist/settings/index.js +8 -0
  183. package/dist/settings/target.d.ts +7 -0
  184. package/dist/settings/target.js +3 -0
  185. package/dist/shortcut-hints.d.ts +38 -0
  186. package/dist/shortcut-hints.js +81 -0
  187. package/dist/shortcut.d.ts +32 -0
  188. package/dist/shortcut.js +137 -0
  189. package/dist/testing/dedupe.d.ts +1 -0
  190. package/dist/testing/dedupe.js +73 -0
  191. package/dist/testing/fake-source.d.ts +8 -0
  192. package/dist/testing/fake-source.js +15 -0
  193. package/dist/testing/index.d.ts +1 -0
  194. package/dist/testing/index.js +2 -0
  195. package/dist/theme.css +562 -0
  196. package/dist/toast-store.d.ts +38 -0
  197. package/dist/toast-store.js +61 -0
  198. package/dist/useAsyncAction.d.ts +8 -0
  199. package/dist/useAsyncAction.js +23 -0
  200. package/dist/useColumnResize.d.ts +14 -0
  201. package/dist/useColumnResize.js +49 -0
  202. package/dist/useContentSize.d.ts +5 -0
  203. package/dist/useContentSize.js +21 -0
  204. package/dist/useMeasuredWidth.d.ts +1 -0
  205. package/dist/useMeasuredWidth.js +6 -0
  206. package/dist/useNow.d.ts +3 -0
  207. package/dist/useNow.js +41 -0
  208. package/dist/wipe-split.d.ts +7 -0
  209. package/dist/wipe-split.js +20 -0
  210. package/dist/zoom.d.ts +3 -0
  211. package/dist/zoom.js +6 -0
  212. package/package.json +92 -0
  213. package/src/AccentSwatches.tsx +73 -0
  214. package/src/Banner.tsx +43 -0
  215. package/src/Button.tsx +55 -0
  216. package/src/ButtonGroup.tsx +14 -0
  217. package/src/Checkbox.tsx +44 -0
  218. package/src/ChoiceCards.tsx +108 -0
  219. package/src/CloseButton.tsx +21 -0
  220. package/src/ColorSwatch.tsx +41 -0
  221. package/src/ColumnResizeHandle.tsx +23 -0
  222. package/src/DisabledReason.tsx +23 -0
  223. package/src/DockShell.tsx +76 -0
  224. package/src/EmptyState.tsx +48 -0
  225. package/src/ErrorBoundary.tsx +116 -0
  226. package/src/Field.tsx +116 -0
  227. package/src/FileIcon.tsx +27 -0
  228. package/src/Icon.tsx +22 -0
  229. package/src/IconButton.tsx +84 -0
  230. package/src/IconPicker.tsx +257 -0
  231. package/src/Input.tsx +78 -0
  232. package/src/Kbd.tsx +67 -0
  233. package/src/ListRow.tsx +26 -0
  234. package/src/Meter.tsx +41 -0
  235. package/src/PanelEmpty.tsx +37 -0
  236. package/src/PanelHeader.tsx +31 -0
  237. package/src/Pill.tsx +61 -0
  238. package/src/ProjectSwitcher.tsx +127 -0
  239. package/src/PromptDialog.tsx +168 -0
  240. package/src/SectionLabel.tsx +20 -0
  241. package/src/SegmentBar.tsx +128 -0
  242. package/src/Segmented.tsx +51 -0
  243. package/src/Select.tsx +135 -0
  244. package/src/Separator.tsx +15 -0
  245. package/src/ShortcutHints.tsx +118 -0
  246. package/src/Skeleton.tsx +13 -0
  247. package/src/SlidingColumn.tsx +91 -0
  248. package/src/Spinner.tsx +32 -0
  249. package/src/Stepper.tsx +67 -0
  250. package/src/Surface.tsx +20 -0
  251. package/src/Switch.tsx +32 -0
  252. package/src/TextMenu.tsx +59 -0
  253. package/src/Tile.tsx +49 -0
  254. package/src/Toasts.tsx +151 -0
  255. package/src/Tooltip.tsx +70 -0
  256. package/src/UIProvider.tsx +41 -0
  257. package/src/Waveform.tsx +199 -0
  258. package/src/Wipe.tsx +99 -0
  259. package/src/ZoomControls.tsx +99 -0
  260. package/src/class-name.ts +8 -0
  261. package/src/clipboard.ts +17 -0
  262. package/src/context-menu/index.parts.ts +21 -0
  263. package/src/dialog/index.parts.ts +10 -0
  264. package/src/dialog/parts.tsx +112 -0
  265. package/src/dialog-layer.ts +31 -0
  266. package/src/error-boundary.ts +40 -0
  267. package/src/error-message.ts +5 -0
  268. package/src/field-context.ts +12 -0
  269. package/src/file-icon.ts +52 -0
  270. package/src/floating.ts +9 -0
  271. package/src/format/datetime.ts +63 -0
  272. package/src/format/duration.ts +83 -0
  273. package/src/format/index.ts +20 -0
  274. package/src/format/locale.ts +153 -0
  275. package/src/format/number.ts +87 -0
  276. package/src/format/regions.ts +20 -0
  277. package/src/format/time-zone.ts +2 -0
  278. package/src/icon-button-size.ts +6 -0
  279. package/src/icon-picker.ts +112 -0
  280. package/src/index.ts +103 -0
  281. package/src/lazy.tsx +124 -0
  282. package/src/locales/en.json +59 -0
  283. package/src/locales/nl.json +59 -0
  284. package/src/locales.ts +21 -0
  285. package/src/menu/index.parts.ts +20 -0
  286. package/src/menu/parts.tsx +296 -0
  287. package/src/merge-refs.ts +14 -0
  288. package/src/modality.ts +37 -0
  289. package/src/platform.ts +10 -0
  290. package/src/popover/index.parts.ts +8 -0
  291. package/src/popover/parts.tsx +119 -0
  292. package/src/prefetch.ts +113 -0
  293. package/src/preview-card/index.parts.ts +1 -0
  294. package/src/project-switcher-row.tsx +51 -0
  295. package/src/selection.ts +24 -0
  296. package/src/seti-icons.ts +451 -0
  297. package/src/settings/ConfirmDialog.tsx +30 -0
  298. package/src/settings/DetailHeader.tsx +25 -0
  299. package/src/settings/MasterDetail.tsx +67 -0
  300. package/src/settings/SettingsDialog.tsx +342 -0
  301. package/src/settings/SettingsRow.tsx +89 -0
  302. package/src/settings/SettingsSection.tsx +46 -0
  303. package/src/settings/index.ts +16 -0
  304. package/src/settings/target.ts +12 -0
  305. package/src/shortcut-hints.ts +117 -0
  306. package/src/shortcut.ts +170 -0
  307. package/src/testing/dedupe.ts +93 -0
  308. package/src/testing/fake-source.ts +21 -0
  309. package/src/testing/index.ts +3 -0
  310. package/src/theme.css +562 -0
  311. package/src/toast-store.ts +120 -0
  312. package/src/useAsyncAction.ts +32 -0
  313. package/src/useColumnResize.ts +75 -0
  314. package/src/useContentSize.ts +27 -0
  315. package/src/useMeasuredWidth.ts +7 -0
  316. package/src/useNow.ts +49 -0
  317. package/src/wipe-split.ts +24 -0
  318. package/src/zoom.ts +9 -0
@@ -0,0 +1,4 @@
1
+ import { type FileTreeIconConfig } from '@pierre/trees';
2
+ export declare const FILE_TREE_ICONS: FileTreeIconConfig;
3
+ export declare const fileIconFor: (path: string) => string;
4
+ export declare const mountFileIconSprite: () => void;
@@ -0,0 +1,46 @@
1
+ import { createFileTreeIconResolver } from '@pierre/trees';
2
+ import { SETI_BY_FILE_EXTENSION, SETI_BY_FILE_NAME, SETI_BY_FILE_NAME_CONTAINS, SETI_DEFAULT_SYMBOL, SETI_SPRITE_SHEET } from './seti-icons.js';
3
+ const SPRITE_ELEMENT_ID = 'adecore-ui-file-icon-sprite';
4
+ /* The glyphs are Seti's (`seti-icons.ts`), which knows far more file types than the sets the tree
5
+ ships with. The tree only colors its own sets, so every symbol carries its hue in its id and the
6
+ sprite brings the rules that color it. A tree takes all of it as configuration where everything
7
+ drawn beside it calls `fileIconFor`, so a tab, a picker row and a tree row never disagree about
8
+ what a file is. Hand this to the tree. */
9
+ export const FILE_TREE_ICONS = {
10
+ set: 'none',
11
+ spriteSheet: SETI_SPRITE_SHEET,
12
+ remap: { 'file-tree-icon-file': SETI_DEFAULT_SYMBOL },
13
+ byFileName: SETI_BY_FILE_NAME,
14
+ byFileExtension: SETI_BY_FILE_EXTENSION,
15
+ byFileNameContains: SETI_BY_FILE_NAME_CONTAINS
16
+ };
17
+ let resolver = null;
18
+ /* Built on the first icon rather than on import, so a bundle that never draws one never builds it. */
19
+ const resolveIcon = (...args) => {
20
+ resolver ??= createFileTreeIconResolver(FILE_TREE_ICONS);
21
+ return resolver.resolveIcon(...args);
22
+ };
23
+ /*
24
+ * The id of the `<symbol>` the set gives a file, in the sprite `mountFileIconSprite` puts in the
25
+ * document. The path may be absolute or relative. Only the last segment decides, first by exact
26
+ * name (`package.json`, `.gitignore`), then by a part of the name (`Dockerfile.dev`), then by the
27
+ * longest extension that matches (`spec.ts` before `ts`). Anything the set does not know falls back
28
+ * to its generic file icon, and so does a directory. The tree marks a folder with the chevron that
29
+ * turns as it opens, not with a glyph of its own.
30
+ */
31
+ export const fileIconFor = (path) => resolveIcon('file-tree-icon-file', path).name;
32
+ /* The tree carries the sprite into its own shadow root, out of reach of anything that draws an
33
+ icon next to it, so the document gets a copy of the same sheet to point `<use>` at. */
34
+ export const mountFileIconSprite = () => {
35
+ if (document.getElementById(SPRITE_ELEMENT_ID) !== null) {
36
+ return;
37
+ }
38
+ const holder = document.createElement('div');
39
+ holder.innerHTML = SETI_SPRITE_SHEET;
40
+ const sprite = holder.firstElementChild;
41
+ if (sprite === null) {
42
+ return;
43
+ }
44
+ sprite.id = SPRITE_ELEMENT_ID;
45
+ (document.body ?? document.documentElement).append(sprite);
46
+ };
@@ -0,0 +1,5 @@
1
+ export declare const isInFloatingLayer: (target: EventTarget | null) => boolean;
2
+ export declare const cameThroughPortal: (event: {
3
+ currentTarget: Element;
4
+ target: EventTarget;
5
+ }) => boolean;
@@ -0,0 +1,6 @@
1
+ // Every popup, menu, tooltip and dialog of the library is portaled; these are the layers it draws them on.
2
+ const FLOATING_LAYERS = '[data-base-ui-portal], .tooltip-popup, .dialog-popup, .dialog-backdrop';
3
+ /* Whether an event landed in a popup rather than on the surface under it, which a canvas-like surface must not take as a click of its own. */
4
+ export const isInFloatingLayer = (target) => target instanceof Element && target.closest(FLOATING_LAYERS) !== null;
5
+ /* A popup is portaled out of its trigger's element but not out of React's tree, so its events still bubble to that trigger. */
6
+ export const cameThroughPortal = (event) => !(event.target instanceof Node && event.currentTarget.contains(event.target));
@@ -0,0 +1,11 @@
1
+ export declare const formatDateTime: (at: Date | number, options: Intl.DateTimeFormatOptions) => string;
2
+ export declare const formatClock: (at: Date | number) => string;
3
+ export declare const formatWeekdayClock: (at: Date | number) => string;
4
+ export declare const formatHour: (at: Date | number) => string;
5
+ export declare const formatDay: (at: Date | number) => string;
6
+ export declare const formatDayWithYear: (at: Date | number) => string;
7
+ export declare const formatWeekdayDay: (at: Date | number) => string;
8
+ export declare const formatDayClock: (at: Date | number) => string;
9
+ export declare const formatNumericDate: (at: Date | number) => string;
10
+ export declare const isSameDay: (a: Date | number, b: Date | number) => boolean;
11
+ export declare const formatMoment: (at: Date | number, now?: Date | number) => string;
@@ -0,0 +1,50 @@
1
+ import { dateFormatter, wordFormatter } from './locale.js';
2
+ /* The parts that are a word rather than a number, and so come from the language and not the region. */
3
+ const WORD_PARTS = new Set(['month', 'weekday', 'dayPeriod', 'era']);
4
+ /*
5
+ * A date written the way the region writes one, with its words in the language the interface is in.
6
+ * Both formatters get the same options and the same instant, so every part the region asks for has
7
+ * one to lift the word from, whatever order the two put them in: `19 Sep` in English against a Dutch
8
+ * region, `19 sep` once the interface is Dutch too, and a clock that reads `08:05` wherever the
9
+ * region does not say AM.
10
+ */
11
+ export const formatDateTime = (at, options) => {
12
+ const date = typeof at === 'number' ? new Date(at) : at;
13
+ const parts = dateFormatter(options).formatToParts(date);
14
+ if (!parts.some((part) => WORD_PARTS.has(part.type))) {
15
+ return parts.map((part) => part.value).join('');
16
+ }
17
+ const english = wordFormatter(options).formatToParts(date);
18
+ return parts.map((part) => (WORD_PARTS.has(part.type) ? (english.find((word) => word.type === part.type)?.value ?? part.value) : part.value)).join('');
19
+ };
20
+ const CLOCK = { hour: '2-digit', minute: '2-digit' };
21
+ const WEEKDAY_CLOCK = { weekday: 'short', hour: '2-digit', minute: '2-digit' };
22
+ const HOUR = { hour: 'numeric' };
23
+ const DAY = { month: 'short', day: 'numeric' };
24
+ const DAY_WITH_YEAR = { month: 'short', day: 'numeric', year: 'numeric' };
25
+ const WEEKDAY_DAY = { weekday: 'short', month: 'short', day: 'numeric' };
26
+ const DAY_CLOCK = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
27
+ const NUMERIC_DATE = { year: 'numeric', month: 'numeric', day: 'numeric' };
28
+ /* `08:05`, or `08:05 AM` in a region that counts to twelve. */
29
+ export const formatClock = (at) => formatDateTime(at, CLOCK);
30
+ /* A time far enough off that its day is part of the answer: `Sat 08:05`. */
31
+ export const formatWeekdayClock = (at) => formatDateTime(at, WEEKDAY_CLOCK);
32
+ /* An hour of the day on its own, for the foot of a chart: `08`, or `8 AM` where that is the clock. */
33
+ export const formatHour = (at) => formatDateTime(at, HOUR);
34
+ /* `19 Sep`, the short date a row falls back to once "days ago" stops meaning anything. */
35
+ export const formatDay = (at) => formatDateTime(at, DAY);
36
+ /* The same date with the year, for anything older than the one we are in. */
37
+ export const formatDayWithYear = (at) => formatDateTime(at, DAY_WITH_YEAR);
38
+ /* `Sat 19 Sep`. */
39
+ export const formatWeekdayDay = (at) => formatDateTime(at, WEEKDAY_DAY);
40
+ /* `19 Sep, 08:05`: a moment that is not today, to the minute. */
41
+ export const formatDayClock = (at) => formatDateTime(at, DAY_CLOCK);
42
+ /* All numbers, as a form would print it: `19-9-2026` here, `9/19/2026` in the United States. */
43
+ export const formatNumericDate = (at) => formatDateTime(at, NUMERIC_DATE);
44
+ /* Whether two instants fall on the same day of the same year, in the zone of whoever is reading. */
45
+ export const isSameDay = (a, b) => {
46
+ const left = typeof a === 'number' ? new Date(a) : a;
47
+ const right = typeof b === 'number' ? new Date(b) : b;
48
+ return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
49
+ };
50
+ export const formatMoment = (at, now = Date.now()) => (isSameDay(at, now) ? formatClock(at) : formatDayClock(at));
@@ -0,0 +1,5 @@
1
+ export declare const formatDuration: (ms: number) => string;
2
+ export declare const formatCountdown: (ms: number) => string;
3
+ export declare const formatElapsedShort: (ms: number) => string;
4
+ export declare const formatClockDuration: (ms: number) => string;
5
+ export declare const formatAgo: (ms: number) => string;
@@ -0,0 +1,76 @@
1
+ import { relativeFormatter } from './locale.js';
2
+ import { formatDecimal, formatNumber } from './number.js';
3
+ const SECOND = 1000;
4
+ const MINUTE = 60 * SECOND;
5
+ const HOUR = 60 * MINUTE;
6
+ const DAY = 24 * HOUR;
7
+ /*
8
+ * How long something took, at the coarsest unit that still says something. The units are English
9
+ * abbreviations, the way the rest of the interface is written; only the number bends to the region,
10
+ * which is what `1,5 h` against `1.5 h` is about.
11
+ */
12
+ export const formatDuration = (ms) => {
13
+ const seconds = Math.max(0, Math.round(ms / SECOND));
14
+ if (seconds < 60) {
15
+ return `${formatNumber(seconds)} s`;
16
+ }
17
+ const minutes = Math.round(seconds / 60);
18
+ if (minutes < 60) {
19
+ return `${formatNumber(minutes)} min`;
20
+ }
21
+ return `${formatDecimal(minutes / 60)} h`;
22
+ };
23
+ /* How long a window still has, tight enough for the end of a bar: `4d 3h`, `12h 8m`, `9m`. */
24
+ export const formatCountdown = (ms) => {
25
+ const left = Math.max(0, ms);
26
+ if (left >= DAY) {
27
+ return `${formatNumber(Math.floor(left / DAY))}d ${formatNumber(Math.round((left % DAY) / HOUR))}h`;
28
+ }
29
+ if (left >= HOUR) {
30
+ return `${formatNumber(Math.floor(left / HOUR))}h ${formatNumber(Math.round((left % HOUR) / MINUTE))}m`;
31
+ }
32
+ return `${formatNumber(Math.max(1, Math.round(left / MINUTE)))}m`;
33
+ };
34
+ /*
35
+ * How long something has been running, to two units and no space to spare: `12s`, `2m 5s`, `3h 20m`.
36
+ * A call that took a fraction of a second still took some, so anything above zero reads as at least
37
+ * `1s`; only a clock that runs ahead of the start comes out as `0s`.
38
+ */
39
+ export const formatElapsedShort = (ms) => {
40
+ const seconds = ms <= 0 ? 0 : Math.max(1, Math.round(ms / SECOND));
41
+ if (seconds < 60) {
42
+ return `${formatNumber(seconds)}s`;
43
+ }
44
+ if (seconds < 3600) {
45
+ const rest = seconds % 60;
46
+ const minutes = Math.floor(seconds / 60);
47
+ return rest === 0 ? `${formatNumber(minutes)}m` : `${formatNumber(minutes)}m ${formatNumber(rest)}s`;
48
+ }
49
+ const hours = Math.floor(seconds / 3600);
50
+ const minutes = Math.floor((seconds % 3600) / 60);
51
+ return minutes === 0 ? `${formatNumber(hours)}h` : `${formatNumber(hours)}h ${formatNumber(minutes)}m`;
52
+ };
53
+ /* A stopwatch that is watched while it runs: `00:14`, and `1:02:03` once it passes an hour. */
54
+ export const formatClockDuration = (ms) => {
55
+ const seconds = Math.max(0, Math.floor(ms / SECOND));
56
+ const pad = (value) => String(value).padStart(2, '0');
57
+ const clock = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`;
58
+ const hours = Math.floor(seconds / 3600);
59
+ return hours > 0 ? `${formatNumber(hours)}:${clock}` : clock;
60
+ };
61
+ const AGO = { style: 'narrow', numeric: 'always' };
62
+ const NOW = { style: 'narrow', numeric: 'auto' };
63
+ /* How long ago something was, short enough for the right edge of a row: `3m ago`, `3 min. geleden`. */
64
+ export const formatAgo = (ms) => {
65
+ const past = Math.max(0, ms);
66
+ if (past < MINUTE) {
67
+ return relativeFormatter(NOW).format(0, 'second');
68
+ }
69
+ if (past < HOUR) {
70
+ return relativeFormatter(AGO).format(-Math.floor(past / MINUTE), 'minute');
71
+ }
72
+ if (past < DAY) {
73
+ return relativeFormatter(AGO).format(-Math.floor(past / HOUR), 'hour');
74
+ }
75
+ return relativeFormatter(AGO).format(-Math.floor(past / DAY), 'day');
76
+ };
@@ -0,0 +1,6 @@
1
+ export { formatClock, formatDateTime, formatDay, formatDayClock, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.ts';
2
+ export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.ts';
3
+ export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
4
+ export { formatBytes, formatDecimal, formatMoney, formatNumber, formatPercent, formatTokens, formatUsdSignificant } from './number.ts';
5
+ export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.ts';
6
+ export { localTimeZone } from './time-zone.ts';
@@ -0,0 +1,7 @@
1
+ /* Every number, date, time and duration a person reads, written the way the language and the region they set ask for. */
2
+ export { formatClock, formatDateTime, formatDay, formatDayClock, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.js';
3
+ export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.js';
4
+ export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale } from './locale.js';
5
+ export { formatBytes, formatDecimal, formatMoney, formatNumber, formatPercent, formatTokens, formatUsdSignificant } from './number.js';
6
+ export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.js';
7
+ export { localTimeZone } from './time-zone.js';
@@ -0,0 +1,17 @@
1
+ export interface FormatSource {
2
+ language(): string;
3
+ region(): string;
4
+ systemLocale?(): string | undefined;
5
+ subscribe(onChange: () => void): () => void;
6
+ }
7
+ export declare const setFormatSource: (next: FormatSource) => FormatSource;
8
+ export declare const FALLBACK_LOCALE = "en-US";
9
+ export declare const wordLocale: () => string;
10
+ export declare const systemLocale: () => string;
11
+ export declare const formatLocale: () => string;
12
+ export declare const useFormatLocale: () => string;
13
+ export declare const numberFormatter: (options: Intl.NumberFormatOptions) => Intl.NumberFormat;
14
+ export declare const dateFormatter: (options: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat;
15
+ export declare const wordFormatter: (options: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat;
16
+ export declare const relativeFormatter: (options: Intl.RelativeTimeFormatOptions) => Intl.RelativeTimeFormat;
17
+ export declare const labelCollator: () => Intl.Collator;
@@ -0,0 +1,105 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { FORMAT_LANGUAGE, FORMAT_SYSTEM } from './regions.js';
3
+ let source = { language: () => 'en', region: () => FORMAT_LANGUAGE, subscribe: () => () => { } };
4
+ /* Answers the source it replaced, so a test can put that one back. */
5
+ export const setFormatSource = (next) => {
6
+ const previous = source;
7
+ source = next;
8
+ return previous;
9
+ };
10
+ /* Where a language comes from when nothing else says: the country most of its speakers are in. */
11
+ const LANGUAGE_REGIONS = { en: 'en-US', nl: 'nl-NL' };
12
+ /* The one locale to fall back on, so a format never depends on which machine ran the test. */
13
+ export const FALLBACK_LOCALE = 'en-US';
14
+ /* A month and a weekday are words, so they come from the language the interface is in, while the
15
+ order, the separators and the clock come from the region. English words in a Dutch notation is
16
+ what a Dutch Mac running an English app should read like, and both halves are a person's to set. */
17
+ export const wordLocale = () => source.language();
18
+ /* A tag `Intl` will take, or nothing. macOS hands out locales with overrides attached, and a
19
+ formatter built on one it does not know throws where a number was meant to go. */
20
+ const usable = (tag) => {
21
+ if (tag === undefined || tag === '') {
22
+ return null;
23
+ }
24
+ try {
25
+ return Intl.DateTimeFormat.supportedLocalesOf([tag]).length > 0 ? tag : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ };
31
+ /*
32
+ * What the operating system was set to read numbers and dates in. In a desktop shell Chromium's own
33
+ * locale is the language of the app bundle, so an English bundle on a Dutch Mac would still hand back
34
+ * `en-US` and write `08:00 AM`; the shell reads the system's region instead and passes it in
35
+ * (`systemLocale`). A browser has no such thing, so there the language is the best there is.
36
+ */
37
+ export const systemLocale = () => {
38
+ const fromShell = usable(source.systemLocale?.());
39
+ if (fromShell !== null) {
40
+ return fromShell;
41
+ }
42
+ if (typeof navigator === 'undefined') {
43
+ return FALLBACK_LOCALE;
44
+ }
45
+ return usable(navigator.languages?.[0] ?? navigator.language) ?? FALLBACK_LOCALE;
46
+ };
47
+ /*
48
+ * The region a language brings with it. The system's own when it already speaks that language, so
49
+ * Dutch in Belgium keeps writing dates the Belgian way, and the country the language is most spoken
50
+ * in when it does not.
51
+ */
52
+ const regionOfLanguage = () => {
53
+ const language = source.language();
54
+ const system = systemLocale();
55
+ return system.toLowerCase().startsWith(language) ? system : (LANGUAGE_REGIONS[language] ?? language);
56
+ };
57
+ const resolve = (region) => {
58
+ if (region === FORMAT_LANGUAGE) {
59
+ return regionOfLanguage();
60
+ }
61
+ return region === FORMAT_SYSTEM ? systemLocale() : region;
62
+ };
63
+ /* The locale every formatter in this folder is built on. Read outside React as well, so it is a
64
+ plain function over the source rather than a hook. */
65
+ export const formatLocale = () => resolve(source.region());
66
+ /* What a component calls to draw again once the region changes. The value is the locale, which a
67
+ caller may use or ignore; subscribing is the point. */
68
+ const subscribe = (onChange) => source.subscribe(onChange);
69
+ export const useFormatLocale = () => useSyncExternalStore(subscribe, formatLocale, formatLocale);
70
+ const cacheFor = (store, options, locale, build) => {
71
+ const hit = store.get(options);
72
+ if (hit !== undefined && hit.locale === locale) {
73
+ return hit.formatter;
74
+ }
75
+ const formatter = build(locale, options);
76
+ store.set(options, { locale, formatter });
77
+ return formatter;
78
+ };
79
+ const numbers = new WeakMap();
80
+ const dates = new WeakMap();
81
+ const words = new WeakMap();
82
+ const relatives = new WeakMap();
83
+ export const numberFormatter = (options) => cacheFor(numbers, options, formatLocale(), (locale, spec) => new Intl.NumberFormat(locale, spec));
84
+ export const dateFormatter = (options) => cacheFor(dates, options, formatLocale(), (locale, spec) => new Intl.DateTimeFormat(locale, spec));
85
+ /* The same date, written in the language the words come from. Never shown whole: only its month,
86
+ weekday, era and day period are lifted out of it. */
87
+ export const wordFormatter = (options) => cacheFor(words, options, wordLocale(), (locale, spec) => new Intl.DateTimeFormat(locale, spec));
88
+ /* `3 min. geleden` is words as much as a number, so the language writes it, the way it writes a month. */
89
+ export const relativeFormatter = (options) => cacheFor(relatives, options, wordLocale(), (locale, spec) => new Intl.RelativeTimeFormat(locale, spec));
90
+ const collators = new Map();
91
+ /*
92
+ * How two labels a person reads are put in order. Labels are words, so the language writes them and
93
+ * the language sorts them: Dutch puts Tekening after Terminal where English puts Drawing before it.
94
+ * Keyed on the locale rather than on an options object, since there are no options to key on.
95
+ */
96
+ export const labelCollator = () => {
97
+ const locale = wordLocale();
98
+ const held = collators.get(locale);
99
+ if (held !== undefined) {
100
+ return held;
101
+ }
102
+ const collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true });
103
+ collators.set(locale, collator);
104
+ return collator;
105
+ };
@@ -0,0 +1,8 @@
1
+ export declare const formatNumber: (value: number) => string;
2
+ export declare const formatDecimal: (value: number) => string;
3
+ export declare const formatFixed: (value: number, decimals: number) => string;
4
+ export declare const formatPercent: (value: number) => string;
5
+ export declare const formatMoney: (value: number, currency: string, maximumFractionDigits?: number) => string;
6
+ export declare const formatUsdSignificant: (value: number) => string;
7
+ export declare const formatTokens: (value: number) => string;
8
+ export declare const formatBytes: (bytes: number, whole?: boolean) => string;
@@ -0,0 +1,72 @@
1
+ import { numberFormatter } from './locale.js';
2
+ const WHOLE = { maximumFractionDigits: 0 };
3
+ const ONE_DECIMAL = { maximumFractionDigits: 1 };
4
+ /* A count as a region groups one: `1,234,567` there, `1.234.567` here. */
5
+ export const formatNumber = (value) => numberFormatter(WHOLE).format(Math.round(value));
6
+ /* The same, to one decimal, where the fraction is the part that means something. */
7
+ export const formatDecimal = (value) => numberFormatter(ONE_DECIMAL).format(value);
8
+ const fixedSpecs = new Map();
9
+ /* Always `decimals` places, so values that follow each other keep one width: `1,0` and `1,5`, never `1` and `1,5`. */
10
+ export const formatFixed = (value, decimals) => {
11
+ let spec = fixedSpecs.get(decimals);
12
+ if (spec === undefined) {
13
+ spec = { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
14
+ fixedSpecs.set(decimals, spec);
15
+ }
16
+ return numberFormatter(spec).format(value);
17
+ };
18
+ /* Percent as Activity Monitor writes it: a decimal under ten, where the difference still shows. */
19
+ export const formatPercent = (value) => `${value < 10 ? formatDecimal(value) : formatNumber(value)}%`;
20
+ /*
21
+ * The option objects a money formatter is cached on, one per currency and precision. The cache is
22
+ * keyed on the object itself, so building a fresh one per call would build a fresh formatter too.
23
+ */
24
+ const moneySpecs = new Map();
25
+ const moneySpec = (currency, maximumFractionDigits) => {
26
+ const key = `${currency}|${maximumFractionDigits ?? ''}`;
27
+ const known = moneySpecs.get(key);
28
+ if (known !== undefined) {
29
+ return known;
30
+ }
31
+ // Narrow, or a Dutch region writes a dollar amount as `US$ 5.480,96`, which is a currency lesson
32
+ // nobody asked for in a table of prices.
33
+ const spec = {
34
+ style: 'currency',
35
+ currency,
36
+ currencyDisplay: 'narrowSymbol',
37
+ ...(maximumFractionDigits ? { maximumFractionDigits } : {})
38
+ };
39
+ moneySpecs.set(key, spec);
40
+ return spec;
41
+ };
42
+ /* An amount in the currency it is already in, as a bank statement would print it. */
43
+ export const formatMoney = (value, currency, maximumFractionDigits) => numberFormatter(moneySpec(currency, maximumFractionDigits)).format(value);
44
+ const SIGNIFICANT_MONEY = { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol', maximumSignificantDigits: 3 };
45
+ /* Dollars to three significant digits, for amounts that run from a fraction of a cent to tens of dollars in one list. */
46
+ export const formatUsdSignificant = (value) => numberFormatter(SIGNIFICANT_MONEY).format(value);
47
+ /* `1.2M`, `412K`, `640`: a token count is read as a size, not counted. */
48
+ export const formatTokens = (value) => {
49
+ const rounded = Math.round(value);
50
+ if (rounded >= 1_000_000) {
51
+ return `${formatDecimal(rounded >= 10_000_000 ? Math.round(rounded / 1_000_000) : rounded / 1_000_000)}M`;
52
+ }
53
+ if (rounded >= 1_000) {
54
+ return `${formatDecimal(rounded >= 10_000 ? Math.round(rounded / 1_000) : rounded / 1_000)}K`;
55
+ }
56
+ return formatNumber(rounded);
57
+ };
58
+ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
59
+ /*
60
+ * A size the way a person says it, with the number in the region's own notation: whole bytes up to
61
+ * a kilobyte, one decimal above that. `whole` is for a column that only has room for a round number.
62
+ */
63
+ export const formatBytes = (bytes, whole = false) => {
64
+ let value = Math.max(0, bytes);
65
+ let unit = 0;
66
+ while (value >= 1024 && unit < UNITS.length - 1) {
67
+ value /= 1024;
68
+ unit++;
69
+ }
70
+ const decimals = unit > 0 && !whole && value < 10;
71
+ return `${decimals ? formatDecimal(value) : formatNumber(value)} ${UNITS[unit]}`;
72
+ };
@@ -0,0 +1,6 @@
1
+ export declare const FORMAT_LANGUAGE = "language";
2
+ export declare const FORMAT_SYSTEM = "system";
3
+ export declare const FORMAT_REGIONS: readonly ['nl-NL', 'en-US', 'en-GB', 'de-DE', 'fr-FR', 'es-ES', 'sv-SE', 'ja-JP'];
4
+ export declare const FORMAT_REGION_CHOICES: readonly string[];
5
+ export declare const regionName: (region: string, language: string) => string;
6
+ export declare const formatRegionFrom: (stored: unknown) => string;
@@ -0,0 +1,15 @@
1
+ /*
2
+ * The regions a person can read this app in, beside the one its language comes with and the one the
3
+ * operating system was set to. The list is short on purpose. It is here for a machine whose region
4
+ * reads nothing like the person in front of it, not as a country picker.
5
+ */
6
+ export const FORMAT_LANGUAGE = 'language';
7
+ export const FORMAT_SYSTEM = 'system';
8
+ /* Only the tags. A country names itself in the language the interface is in (`Intl.DisplayNames`),
9
+ so no translation file ever carries a list of countries. */
10
+ export const FORMAT_REGIONS = ['nl-NL', 'en-US', 'en-GB', 'de-DE', 'fr-FR', 'es-ES', 'sv-SE', 'ja-JP'];
11
+ export const FORMAT_REGION_CHOICES = [FORMAT_LANGUAGE, FORMAT_SYSTEM, ...FORMAT_REGIONS];
12
+ /* The country of a region tag, named in `language`: `nl-NL` in English is "Netherlands". */
13
+ export const regionName = (region, language) => new Intl.DisplayNames([language], { type: 'region' }).of(region.slice(region.indexOf('-') + 1)) ?? region;
14
+ /* A stored region, or the language's own for anything this version does not offer. */
15
+ export const formatRegionFrom = (stored) => FORMAT_REGION_CHOICES.find((region) => region === stored) ?? FORMAT_LANGUAGE;
@@ -0,0 +1 @@
1
+ export declare const localTimeZone: () => string | null;
@@ -0,0 +1,2 @@
1
+ /* The IANA time zone this machine is set to, or null when the runtime does not say. */
2
+ export const localTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || null;
@@ -0,0 +1,3 @@
1
+ export type IconButtonSize = 'md' | 'sm' | 'xs' | '2xs';
2
+ export declare const ICON_BUTTON_CLASS: Record<IconButtonSize, string | undefined>;
3
+ export declare const ICON_BUTTON_ICON_SIZE: Record<IconButtonSize, number>;
@@ -0,0 +1,3 @@
1
+ /* The size decides the square and the radius (`.icon-btn` in `theme.css`) and the icon inside: 16 in 32, 14 in 28, 12 below. */
2
+ export const ICON_BUTTON_CLASS = { md: undefined, sm: 'icon-btn-sm', xs: 'icon-btn-xs', '2xs': 'icon-btn-2xs' };
3
+ export const ICON_BUTTON_ICON_SIZE = { md: 16, sm: 14, xs: 12, '2xs': 12 };
@@ -0,0 +1,23 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ export interface IconPickerGroup {
3
+ id: string;
4
+ label: string;
5
+ icons: Readonly<Record<string, LucideIcon>>;
6
+ }
7
+ export type IconPickerIcons = Readonly<Record<string, LucideIcon>> | readonly IconPickerGroup[];
8
+ export interface IconSection {
9
+ id: string;
10
+ label: string | null;
11
+ icons: readonly (readonly [name: string, glyph: LucideIcon])[];
12
+ }
13
+ export interface IconCellBox {
14
+ left: number;
15
+ top: number;
16
+ width: number;
17
+ }
18
+ export declare const isGrouped: (icons: IconPickerIcons) => icons is readonly IconPickerGroup[];
19
+ export declare const sectionsOf: (icons: IconPickerIcons) => IconSection[];
20
+ export declare const filterSections: (sections: readonly IconSection[], query: string, keywords?: Readonly<Record<string, readonly string[]>>) => IconSection[];
21
+ export declare const tabStopOf: (visible: readonly string[], focused: string | null, value: string | null) => string | null;
22
+ export declare const cellForKey: (key: string, boxes: readonly IconCellBox[], from: number) => number | null;
23
+ export declare const escapeClearsSearch: (key: string, query: string) => boolean;
@@ -0,0 +1,80 @@
1
+ export const isGrouped = (icons) => Array.isArray(icons);
2
+ export const sectionsOf = (icons) => isGrouped(icons)
3
+ ? icons.map((group) => ({ id: group.id, label: group.label, icons: Object.entries(group.icons) }))
4
+ : [{ id: '', label: null, icons: Object.entries(icons) }];
5
+ /* The icons whose name, keywords or group label hold the query, case-insensitive. A group without one is left out. */
6
+ export const filterSections = (sections, query, keywords = {}) => {
7
+ const needle = query.trim().toLowerCase();
8
+ if (needle === '') {
9
+ return [...sections];
10
+ }
11
+ const holds = (text) => text.toLowerCase().includes(needle);
12
+ return sections.flatMap((section) => {
13
+ if (section.label !== null && holds(section.label)) {
14
+ return [section];
15
+ }
16
+ const icons = section.icons.filter(([name]) => holds(name) || (keywords[name] ?? []).some(holds));
17
+ return icons.length > 0 ? [{ ...section, icons }] : [];
18
+ });
19
+ };
20
+ /* The one icon the grid's tab stop sits on: the one last focused, else the chosen one, else the first, as long as the search shows it. */
21
+ export const tabStopOf = (visible, focused, value) => {
22
+ if (focused !== null && visible.includes(focused)) {
23
+ return focused;
24
+ }
25
+ if (value !== null && visible.includes(value)) {
26
+ return value;
27
+ }
28
+ return visible[0] ?? null;
29
+ };
30
+ /* Rows lie a few pixels apart at least, so a rounding difference between two cells of one row is no new row. */
31
+ const SAME_ROW = 2;
32
+ /*
33
+ * The cell a key moves the focus to, or null for a key the grid leaves alone. Up and down go by what
34
+ * the eye sees: the nearest cell in the next row drawn, which may belong to another group. At an edge
35
+ * the focus stays, so the key never scrolls the grid instead.
36
+ */
37
+ export const cellForKey = (key, boxes, from) => {
38
+ const last = boxes.length - 1;
39
+ switch (key) {
40
+ case 'ArrowRight':
41
+ return Math.min(from + 1, last);
42
+ case 'ArrowLeft':
43
+ return Math.max(from - 1, 0);
44
+ case 'Home':
45
+ return 0;
46
+ case 'End':
47
+ return last;
48
+ case 'ArrowDown':
49
+ case 'ArrowUp':
50
+ break;
51
+ default:
52
+ return null;
53
+ }
54
+ const current = boxes[from];
55
+ if (current === undefined) {
56
+ return null;
57
+ }
58
+ const down = key === 'ArrowDown';
59
+ const tops = boxes.map((box) => box.top).filter((top) => (down ? top > current.top + SAME_ROW : top < current.top - SAME_ROW));
60
+ if (tops.length === 0) {
61
+ return from;
62
+ }
63
+ const row = down ? Math.min(...tops) : Math.max(...tops);
64
+ const center = current.left + current.width / 2;
65
+ let nearest = from;
66
+ let distance = Number.POSITIVE_INFINITY;
67
+ boxes.forEach((box, index) => {
68
+ if (Math.abs(box.top - row) > SAME_ROW) {
69
+ return;
70
+ }
71
+ const gap = Math.abs(box.left + box.width / 2 - center);
72
+ if (gap < distance) {
73
+ nearest = index;
74
+ distance = gap;
75
+ }
76
+ });
77
+ return nearest;
78
+ };
79
+ /* Escape empties a search that holds text and goes no further; on an empty one it is left to whatever else hears it, such as a dialog. */
80
+ export const escapeClearsSearch = (key, query) => key === 'Escape' && query !== '';