@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
@@ -1,18 +1,16 @@
1
1
  import { useSyncExternalStore } from 'react';
2
2
  import { FORMAT_LANGUAGE, FORMAT_SYSTEM } from './regions.ts';
3
3
 
4
- /*
5
- * Where the formatters read what a person set. The app owns both settings and hands them over once,
6
- * before the first render; the language writes the words and the region writes the notation.
7
- */
4
+ /* The app hands it over once, before the first render; the language writes the words and the region writes the notation. */
8
5
  export interface FormatSource {
9
- /* The language the interface is written in right now, such as `en` or `nl`. */
10
6
  language(): string;
7
+
11
8
  /* A region tag such as `nl-NL`, or `FORMAT_LANGUAGE` or `FORMAT_SYSTEM`. */
12
9
  region(): string;
10
+
13
11
  /* The system's own locale when a shell can read it; a browser tab falls back on `navigator`. */
14
12
  systemLocale?(): string | undefined;
15
- /* Calls back whenever the language or the region may have changed. */
13
+
16
14
  subscribe(onChange: () => void): () => void;
17
15
  }
18
16
 
@@ -22,26 +20,25 @@ let source: FormatSource = { language: () => 'en', region: () => FORMAT_LANGUAGE
22
20
  export const setFormatSource = (next: FormatSource): FormatSource => {
23
21
  const previous = source;
24
22
  source = next;
23
+
25
24
  return previous;
26
25
  };
27
26
 
28
- /* Where a language comes from when nothing else says: the country most of its speakers are in. */
27
+ /* The country most of a language's speakers are in. */
29
28
  const LANGUAGE_REGIONS: Record<string, string> = { en: 'en-US', nl: 'nl-NL' };
30
29
 
31
- /* The one locale to fall back on, so a format never depends on which machine ran the test. */
30
+ /* So a format never depends on which machine ran the test. */
32
31
  export const FALLBACK_LOCALE = 'en-US';
33
32
 
34
- /* A month and a weekday are words, so they come from the language the interface is in, while the
35
- order, the separators and the clock come from the region. English words in a Dutch notation is
36
- what a Dutch Mac running an English app should read like, and both halves are a person's to set. */
33
+ /* A month and a weekday are words, so they come from the language; the order, the separators and the clock come from the region. */
37
34
  export const wordLocale = (): string => source.language();
38
35
 
39
- /* A tag `Intl` will take, or nothing. macOS hands out locales with overrides attached, and a
40
- formatter built on one it does not know throws where a number was meant to go. */
36
+ /* macOS hands out locales with overrides attached, and a formatter built on one `Intl` does not know throws. */
41
37
  const usable = (tag: string | undefined): string | null => {
42
38
  if (tag === undefined || tag === '') {
43
39
  return null;
44
40
  }
41
+
45
42
  try {
46
43
  return Intl.DateTimeFormat.supportedLocalesOf([tag]).length > 0 ? tag : null;
47
44
  } catch {
@@ -50,30 +47,28 @@ const usable = (tag: string | undefined): string | null => {
50
47
  };
51
48
 
52
49
  /*
53
- * What the operating system was set to read numbers and dates in. In a desktop shell Chromium's own
54
- * locale is the language of the app bundle, so an English bundle on a Dutch Mac would still hand back
55
- * `en-US` and write `08:00 AM`; the shell reads the system's region instead and passes it in
56
- * (`systemLocale`). A browser has no such thing, so there the language is the best there is.
50
+ * In a desktop shell Chromium's locale is the language of the app bundle, so an English bundle on a
51
+ * Dutch Mac would write `08:00 AM`; the shell passes the system's region in instead (`systemLocale`).
57
52
  */
58
53
  export const systemLocale = (): string => {
59
54
  const fromShell = usable(source.systemLocale?.());
55
+
60
56
  if (fromShell !== null) {
61
57
  return fromShell;
62
58
  }
59
+
63
60
  if (typeof navigator === 'undefined') {
64
61
  return FALLBACK_LOCALE;
65
62
  }
63
+
66
64
  return usable(navigator.languages?.[0] ?? navigator.language) ?? FALLBACK_LOCALE;
67
65
  };
68
66
 
69
- /*
70
- * The region a language brings with it. The system's own when it already speaks that language, so
71
- * Dutch in Belgium keeps writing dates the Belgian way, and the country the language is most spoken
72
- * in when it does not.
73
- */
67
+ /* The system's own region when it already speaks the language, so Dutch in Belgium keeps writing dates the Belgian way. */
74
68
  const regionOfLanguage = (): string => {
75
69
  const language = source.language();
76
70
  const system = systemLocale();
71
+
77
72
  return system.toLowerCase().startsWith(language) ? system : (LANGUAGE_REGIONS[language] ?? language);
78
73
  };
79
74
 
@@ -81,17 +76,16 @@ const resolve = (region: string): string => {
81
76
  if (region === FORMAT_LANGUAGE) {
82
77
  return regionOfLanguage();
83
78
  }
79
+
84
80
  return region === FORMAT_SYSTEM ? systemLocale() : region;
85
81
  };
86
82
 
87
- /* The locale every formatter in this folder is built on. Read outside React as well, so it is a
88
- plain function over the source rather than a hook. */
83
+ /* Read outside React as well, so it is a plain function rather than a hook. */
89
84
  export const formatLocale = (): string => resolve(source.region());
90
85
 
91
86
  const subscribe = (onChange: () => void): (() => void) => source.subscribe(onChange);
92
87
 
93
- /* What a component calls to draw again once the region changes. The value is the locale, which a
94
- caller may use or ignore; subscribing is the point. */
88
+ /* Subscribing is the point: a component draws again once the region changes. */
95
89
  export const useFormatLocale = (): string => useSyncExternalStore(subscribe, formatLocale, formatLocale);
96
90
 
97
91
  interface Cached<T> {
@@ -107,11 +101,14 @@ const cacheFor = <O extends object, T>(
107
101
  build: (locale: string | undefined, options: O) => T
108
102
  ): T => {
109
103
  const hit = store.get(options);
104
+
110
105
  if (hit !== undefined && hit.locale === locale) {
111
106
  return hit.formatter;
112
107
  }
108
+
113
109
  const formatter = build(locale, options);
114
110
  store.set(options, { locale, formatter });
111
+
115
112
  return formatter;
116
113
  };
117
114
 
@@ -127,8 +124,7 @@ export const numberFormatter = (options: Intl.NumberFormatOptions): Intl.NumberF
127
124
  export const dateFormatter = (options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat =>
128
125
  cacheFor(dates, options, formatLocale(), (locale, spec) => new Intl.DateTimeFormat(locale, spec));
129
126
 
130
- /* The same date, written in the language the words come from. Never shown whole: only its month,
131
- weekday, era and day period are lifted out of it. */
127
+ /* Never shown whole: only its month, weekday, era and day period are lifted out of it. */
132
128
  export const wordFormatter = (options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat =>
133
129
  cacheFor(words, options, wordLocale(), (locale, spec) => new Intl.DateTimeFormat(locale, spec));
134
130
 
@@ -142,29 +138,24 @@ export const nameCollator = (options: Intl.CollatorOptions): Intl.Collator =>
142
138
 
143
139
  const collators = new Map<string, Intl.Collator>();
144
140
 
145
- /*
146
- * How two labels a person reads are put in order. Labels are words, so the language writes them and
147
- * the language sorts them: Dutch puts Tekening after Terminal where English puts Drawing before it.
148
- * Keyed on the locale rather than on an options object, since there are no options to key on.
149
- */
141
+ /* Labels are words, so the language sorts them: Dutch puts Tekening after Terminal where English puts Drawing before it. */
150
142
  export const labelCollator = (): Intl.Collator => {
151
143
  const locale = wordLocale();
152
- const held = collators.get(locale);
153
- if (held !== undefined) {
154
- return held;
144
+ let collator = collators.get(locale);
145
+
146
+ if (collator === undefined) {
147
+ collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true });
148
+ collators.set(locale, collator);
155
149
  }
156
- const collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true });
157
- collators.set(locale, collator);
150
+
158
151
  return collator;
159
152
  };
160
153
 
161
154
  let nameCollators: { loose: Intl.Collator; exact: Intl.Collator } | null = null;
162
155
 
163
- /*
164
- * The order of names that are no words, such as files: the runtime's own locale, numbers by value and
165
- * case only to break a tie. It is what `localeCompare` did, which built a collator on every call.
166
- */
156
+ /* For names that are no words, such as files: what `localeCompare` did, without a collator built on every call. */
167
157
  export const nameOrder = (left: string, right: string): number => {
168
158
  nameCollators ??= { loose: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), exact: new Intl.Collator() };
159
+
169
160
  return nameCollators.loose.compare(left, right) || nameCollators.exact.compare(left, right);
170
161
  };
@@ -6,18 +6,20 @@ const ONE_DECIMAL: Intl.NumberFormatOptions = { maximumFractionDigits: 1 };
6
6
  /* A count as a region groups one: `1,234,567` there, `1.234.567` here. */
7
7
  export const formatNumber = (value: number): string => numberFormatter(WHOLE).format(Math.round(value));
8
8
 
9
- /* The same, to one decimal, where the fraction is the part that means something. */
10
9
  export const formatDecimal = (value: number): string => numberFormatter(ONE_DECIMAL).format(value);
11
10
 
12
11
  /* One options object per key, so the formatter cached on it is built once (`numberFormatter`). */
13
12
  const specCache = <Key>(build: (key: Key) => Intl.NumberFormatOptions): ((key: Key) => Intl.NumberFormatOptions) => {
14
13
  const specs = new Map<Key, Intl.NumberFormatOptions>();
14
+
15
15
  return (key) => {
16
16
  let spec = specs.get(key);
17
+
17
18
  if (spec === undefined) {
18
19
  spec = build(key);
19
20
  specs.set(key, spec);
20
21
  }
22
+
21
23
  return spec;
22
24
  };
23
25
  };
@@ -38,17 +40,19 @@ const MAX_FRACTION_DIGITS = 100;
38
40
  const NUMERAL = /^[+-]?\d+(?:\.(\d+))?$/;
39
41
 
40
42
  /*
41
- * A numeral as a database writes it (`12900`, `-0.50`, `9007199254740993`), in the region's notation
42
- * with every digit kept: a decimal or a big integer arrives as text, and a float would round it.
43
- * Text that is no plain numeral, such as `NaN` or `1e999`, comes back as it is.
43
+ * A numeral as a database writes it (`12900`, `-0.50`, `9007199254740993`) with every digit kept: a
44
+ * decimal or a big integer arrives as text, and a float would round it. Anything else comes back as it is.
44
45
  */
45
46
  export const formatNumeral = (numeral: string | number): string => {
46
47
  const text = typeof numeral === 'number' ? String(numeral) : numeral.trim();
47
48
  const match = NUMERAL.exec(text);
49
+
48
50
  if (match === null) {
49
51
  return text;
50
52
  }
53
+
51
54
  const decimals = Math.min(match[1]?.length ?? 0, MAX_FRACTION_DIGITS);
55
+
52
56
  // A string goes to Intl as it is, so it formats the exact decimal and not the nearest double.
53
57
  return numberFormatter(fixedSpec(decimals)).format(text as Intl.StringNumericLiteral);
54
58
  };
@@ -56,33 +60,17 @@ export const formatNumeral = (numeral: string | number): string => {
56
60
  /* Percent as Activity Monitor writes it: a decimal under ten, where the difference still shows. */
57
61
  export const formatPercent = (value: number): string => `${value < 10 ? formatDecimal(value) : formatNumber(value)}%`;
58
62
 
59
- /*
60
- * The option objects a money formatter is cached on, one per currency and precision. The cache is
61
- * keyed on the object itself, so building a fresh one per call would build a fresh formatter too.
62
- */
63
- const moneySpecs = new Map<string, Intl.NumberFormatOptions>();
63
+ /* Narrow, or a Dutch region writes a dollar amount as `US$ 5.480,96`. */
64
+ const moneySpec = specCache((key: `${string}|${number | ''}`) => {
65
+ const [currency, digits] = key.split('|');
66
+ const maximumFractionDigits = Number(digits);
64
67
 
65
- const moneySpec = (currency: string, maximumFractionDigits: number | undefined): Intl.NumberFormatOptions => {
66
- const key = `${currency}|${maximumFractionDigits ?? ''}`;
67
- const known = moneySpecs.get(key);
68
- if (known !== undefined) {
69
- return known;
70
- }
71
- // Narrow, or a Dutch region writes a dollar amount as `US$ 5.480,96`, which is a currency lesson
72
- // nobody asked for in a table of prices.
73
- const spec: Intl.NumberFormatOptions = {
74
- style: 'currency',
75
- currency,
76
- currencyDisplay: 'narrowSymbol',
77
- ...(maximumFractionDigits ? { maximumFractionDigits } : {})
78
- };
79
- moneySpecs.set(key, spec);
80
- return spec;
81
- };
68
+ return { style: 'currency', currency, currencyDisplay: 'narrowSymbol', ...(maximumFractionDigits ? { maximumFractionDigits } : {}) };
69
+ });
82
70
 
83
71
  /* An amount in the currency it is already in, as a bank statement would print it. */
84
72
  export const formatMoney = (value: number, currency: string, maximumFractionDigits?: number): string =>
85
- numberFormatter(moneySpec(currency, maximumFractionDigits)).format(value);
73
+ numberFormatter(moneySpec(`${currency}|${maximumFractionDigits ?? ''}`)).format(value);
86
74
 
87
75
  const SIGNIFICANT_MONEY: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol', maximumSignificantDigits: 3 };
88
76
 
@@ -92,12 +80,15 @@ export const formatUsdSignificant = (value: number): string => numberFormatter(S
92
80
  /* `1.2M`, `412K`, `640`: a token count is read as a size, not counted. */
93
81
  export const formatTokens = (value: number): string => {
94
82
  const rounded = Math.round(value);
83
+
95
84
  if (rounded >= 1_000_000) {
96
85
  return `${formatDecimal(rounded >= 10_000_000 ? Math.round(rounded / 1_000_000) : rounded / 1_000_000)}M`;
97
86
  }
87
+
98
88
  if (rounded >= 1_000) {
99
89
  return `${formatDecimal(rounded >= 10_000 ? Math.round(rounded / 1_000) : rounded / 1_000)}K`;
100
90
  }
91
+
101
92
  return formatNumber(rounded);
102
93
  };
103
94
 
@@ -110,10 +101,13 @@ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
110
101
  export const formatBytes = (bytes: number, whole = false): string => {
111
102
  let value = Math.max(0, bytes);
112
103
  let unit = 0;
104
+
113
105
  while (value >= 1024 && unit < UNITS.length - 1) {
114
106
  value /= 1024;
115
107
  unit++;
116
108
  }
109
+
117
110
  const decimals = unit > 0 && !whole && value < 10;
111
+
118
112
  return `${decimals ? formatDecimal(value) : formatNumber(value)} ${UNITS[unit]}`;
119
113
  };
@@ -1,13 +1,10 @@
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
1
  export const FORMAT_LANGUAGE = 'language';
7
2
  export const FORMAT_SYSTEM = 'system';
8
3
 
9
- /* Only the tags. A country names itself in the language the interface is in (`Intl.DisplayNames`),
10
- so no translation file ever carries a list of countries. */
4
+ /*
5
+ * Short on purpose: for a machine whose region reads nothing like the person in front of it, not a
6
+ * country picker. A country names itself through `Intl.DisplayNames`, so no translation file lists them.
7
+ */
11
8
  export const FORMAT_REGIONS = ['nl-NL', 'en-US', 'en-GB', 'de-DE', 'fr-FR', 'es-ES', 'sv-SE', 'ja-JP'] as const;
12
9
 
13
10
  export const FORMAT_REGION_CHOICES: readonly string[] = [FORMAT_LANGUAGE, FORMAT_SYSTEM, ...FORMAT_REGIONS];
@@ -9,14 +9,14 @@ export interface IconPickerGroup {
9
9
 
10
10
  export type IconPickerIcons = Readonly<Record<string, LucideIcon>> | readonly IconPickerGroup[];
11
11
 
12
- /* A group as the grid draws it: a flat set of icons is one group without a heading. */
12
+ /* A flat set of icons is one section without a heading. */
13
13
  export interface IconSection {
14
14
  id: string;
15
15
  label: string | null;
16
16
  icons: readonly (readonly [name: string, glyph: LucideIcon])[];
17
17
  }
18
18
 
19
- /* Where a cell sits on the screen, in the units of `getBoundingClientRect`. */
19
+ /* In the units of `getBoundingClientRect`. */
20
20
  export interface IconCellBox {
21
21
  left: number;
22
22
  top: number;
@@ -33,40 +33,38 @@ export const sectionsOf = (icons: IconPickerIcons): IconSection[] =>
33
33
  /* The icons whose name, keywords or group label hold the query, case-insensitive. A group without one is left out. */
34
34
  export const filterSections = (sections: readonly IconSection[], query: string, keywords: Readonly<Record<string, readonly string[]>> = {}): IconSection[] => {
35
35
  const needle = query.trim().toLowerCase();
36
+
36
37
  if (needle === '') {
37
38
  return [...sections];
38
39
  }
40
+
39
41
  const holds = (text: string): boolean => text.toLowerCase().includes(needle);
42
+
40
43
  return sections.flatMap((section) => {
41
44
  if (section.label !== null && holds(section.label)) {
42
45
  return [section];
43
46
  }
47
+
44
48
  const icons = section.icons.filter(([name]) => holds(name) || (keywords[name] ?? []).some(holds));
49
+
45
50
  return icons.length > 0 ? [{ ...section, icons }] : [];
46
51
  });
47
52
  };
48
53
 
49
- /* 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. */
50
- export const tabStopOf = (visible: readonly string[], focused: string | null, value: string | null): string | null => {
51
- if (focused !== null && visible.includes(focused)) {
52
- return focused;
53
- }
54
- if (value !== null && visible.includes(value)) {
55
- return value;
56
- }
57
- return visible[0] ?? null;
58
- };
54
+ /* The one last focused, else the chosen one, else the first, as long as the search shows it. */
55
+ export const tabStopOf = (visible: readonly string[], focused: string | null, value: string | null): string | null =>
56
+ [focused, value].find((name) => name !== null && visible.includes(name)) ?? visible[0] ?? null;
59
57
 
60
58
  /* Rows lie a few pixels apart at least, so a rounding difference between two cells of one row is no new row. */
61
59
  const SAME_ROW = 2;
62
60
 
63
61
  /*
64
- * The cell a key moves the focus to, or null for a key the grid leaves alone. Up and down go by what
65
- * the eye sees: the nearest cell in the next row drawn, which may belong to another group. At an edge
66
- * the focus stays, so the key never scrolls the grid instead.
62
+ * Null for a key the grid leaves alone. Up and down go to the nearest cell in the next row drawn, which
63
+ * may belong to another group. At an edge the focus stays, so the key never scrolls the grid instead.
67
64
  */
68
65
  export const cellForKey = (key: string, boxes: readonly IconCellBox[], from: number): number | null => {
69
66
  const last = boxes.length - 1;
67
+
70
68
  switch (key) {
71
69
  case 'ArrowRight':
72
70
  return Math.min(from + 1, last);
@@ -82,31 +80,40 @@ export const cellForKey = (key: string, boxes: readonly IconCellBox[], from: num
82
80
  default:
83
81
  return null;
84
82
  }
83
+
85
84
  const current = boxes[from];
85
+
86
86
  if (current === undefined) {
87
87
  return null;
88
88
  }
89
+
89
90
  const down = key === 'ArrowDown';
90
91
  const tops = boxes.map((box) => box.top).filter((top) => (down ? top > current.top + SAME_ROW : top < current.top - SAME_ROW));
92
+
91
93
  if (tops.length === 0) {
92
94
  return from;
93
95
  }
96
+
94
97
  const row = down ? Math.min(...tops) : Math.max(...tops);
95
98
  const center = current.left + current.width / 2;
96
99
  let nearest = from;
97
100
  let distance = Number.POSITIVE_INFINITY;
101
+
98
102
  boxes.forEach((box, index) => {
99
103
  if (Math.abs(box.top - row) > SAME_ROW) {
100
104
  return;
101
105
  }
106
+
102
107
  const gap = Math.abs(box.left + box.width / 2 - center);
108
+
103
109
  if (gap < distance) {
104
110
  nearest = index;
105
111
  distance = gap;
106
112
  }
107
113
  });
114
+
108
115
  return nearest;
109
116
  };
110
117
 
111
- /* 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. */
118
+ /* On an empty search Escape is left to whatever else hears it, such as a dialog. */
112
119
  export const escapeClearsSearch = (key: string, query: string): boolean => key === 'Escape' && query !== '';
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  export * as Combobox from './combobox/index.parts.ts';
7
+
7
8
  export * as CommandPalette from './command-palette/index.parts.ts';
8
9
  export * as ContextMenu from './context-menu/index.parts.ts';
9
10
  export * as FileTree from './file-tree/index.parts.ts';
@@ -18,6 +19,7 @@ export * as Tabs from './tabs/index.parts.ts';
18
19
  export type { ComboboxInputProps, ComboboxItemProps, ComboboxPopupProps } from './combobox/parts.tsx';
19
20
  export type { CommandPaletteFooterProps, CommandPaletteInputProps, CommandPalettePopupProps, CommandPaletteRootProps } from './command-palette/parts.tsx';
20
21
  export type { DialogDescriptionProps, DialogFooterProps, DialogPopupProps, DialogTextProps, DialogTitleProps } from './dialog/parts.tsx';
22
+
21
23
  export type {
22
24
  MenuCheckProps,
23
25
  MenuHintProps,
@@ -27,6 +29,7 @@ export type {
27
29
  MenuRowProps,
28
30
  MenuRowSubmenuTriggerProps
29
31
  } from './menu/parts.tsx';
32
+
30
33
  export type { KeyValueListItemProps, KeyValueListNameProps, KeyValueListRootProps, KeyValueListValueProps } from './key-value-list/parts.tsx';
31
34
  export type { PopoverPopupProps, PopupVariant, PreviewCardPopupProps } from './popover/parts.tsx';
32
35
  export type { TabsCountProps, TabsListProps, TabsRootProps, TabsTabProps, TabsValue, TabsVariant } from './tabs/parts.tsx';
@@ -101,6 +104,7 @@ export { prefetcher, type Loader, type WhenIdle } from './prefetch.ts';
101
104
  export { tintStyle } from './tint.ts';
102
105
  export { selectAllWithin, selectionWithin } from './selection.ts';
103
106
  export { EDIT_SHORTCUTS, KEY_SHORTCUTS, formatShortcut, isModHeld, matchesShortcut, shortcut, shortcutParts, type KeyLike, type Shortcut } from './shortcut.ts';
107
+
104
108
  export {
105
109
  UNDO_MS,
106
110
  createToastStore,
@@ -113,6 +117,7 @@ export {
113
117
  type ToastStore,
114
118
  type ToastStoreHook
115
119
  } from './toast-store.ts';
120
+
116
121
  export { useAsyncAction, type AsyncAction } from './useAsyncAction.ts';
117
122
  export { clampColumnSize, useColumnResize, type ColumnEdge, type ColumnResize, type ColumnResizeOptions } from './useColumnResize.ts';
118
123
  export { useContentSize, type ContentSize } from './useContentSize.ts';
@@ -123,6 +128,7 @@ export { useFileTree, type FileTreeOptions } from './file-tree/use-file-tree.ts'
123
128
  export type { FileTreeRootProps } from './file-tree/parts.tsx';
124
129
  export type { FileTree as FileTreeModel, FileTreeVisibleRow, FileTreeDragAndDropConfig, FileTreeDropContext, FileTreeDropResult } from '@pierre/trees';
125
130
  export type { FoldKeyOf, SortRow } from './file-tree/helpers.ts';
131
+
126
132
  export type {
127
133
  TreeRootProps,
128
134
  TreeRowProps,
@@ -140,6 +146,7 @@ export { DocumentTab, TabStrip, type DocumentTabProps, type TabStripProps, type
140
146
  export { SplitView, type SplitViewProps, type SplitViewInfo, type SplitViewBounds, type SplitDropTarget, type SplitDrag } from './workspace/SplitView.tsx';
141
147
  export { DropPreview, type DropPreviewProps } from './workspace/DropPreview.tsx';
142
148
  export { rectDropPreview, sameDropPreview, tabDropPreview, type DropPreviewShape, type TabDrop } from './workspace/preview.ts';
149
+
143
150
  export {
144
151
  createSplitLayout,
145
152
  updateSplitLayout,
@@ -152,6 +159,7 @@ export {
152
159
  type SplitLayout,
153
160
  type SplitCommand
154
161
  } from './workspace/model.ts';
162
+
155
163
  export { tabGapAt, type SplitRect, type SplitEdges, type SplitMinimum } from './workspace/geometry.ts';
156
164
 
157
165
  export type { CommandPaletteVirtualListProps, CommandPaletteVirtualItemProps } from './command-palette/virtual.tsx';
@@ -6,11 +6,7 @@ export type KeyValueListRootProps = useRender.ComponentProps<'dl'> & {
6
6
  divided?: boolean;
7
7
  };
8
8
 
9
- /*
10
- * The names take the width of the widest one, up to two fifths of the list, and a longer name wraps
11
- * there. Every item is a subgrid of the list, so the names line up across rows and a hairline runs
12
- * under both columns.
13
- */
9
+ /* Every item is a subgrid of the list, so the names line up across rows and a hairline runs under both columns. */
14
10
  export function KeyValueListRoot({ divided = false, render, className, ref, ...props }: KeyValueListRootProps) {
15
11
  return useRender({
16
12
  render,
@@ -4,6 +4,7 @@ const listeners = new Set<() => void>();
4
4
  /* Called once per module, when a `LoadedComponent` first has it. */
5
5
  export function noteLanding(): void {
6
6
  landings += 1;
7
+
7
8
  for (const listener of listeners) {
8
9
  listener();
9
10
  }
@@ -11,6 +12,7 @@ export function noteLanding(): void {
11
12
 
12
13
  export function subscribeLandings(listener: () => void): () => void {
13
14
  listeners.add(listener);
15
+
14
16
  return () => {
15
17
  listeners.delete(listener);
16
18
  };
package/src/lazy.tsx CHANGED
@@ -2,11 +2,7 @@ import { Suspense, lazy, useState, useSyncExternalStore, type ComponentProps, ty
2
2
  import { noteLanding } from './lazy-landings.ts';
3
3
  import { prefetcher } from './prefetch.ts';
4
4
 
5
- /*
6
- * A module's component, loaded once and kept. `React.lazy` suspends for a tick even on a module the
7
- * prefetcher already has, which shows the fallback and mounts a dialog already open; a component
8
- * that is here is drawn straight away instead.
9
- */
5
+ /* `React.lazy` suspends for a tick even on a module the prefetcher already has, which mounts a dialog already open; a component here draws at once. */
10
6
  export class LoadedComponent<Component> {
11
7
  current: Component | null = null;
12
8
  private readonly listeners = new Set<() => void>();
@@ -18,18 +14,23 @@ export class LoadedComponent<Component> {
18
14
 
19
15
  async load(): Promise<Component> {
20
16
  const component = await this.loader();
17
+
21
18
  if (this.current === null) {
22
19
  this.current = component;
20
+
23
21
  for (const listener of this.listeners) {
24
22
  listener();
25
23
  }
24
+
26
25
  noteLanding();
27
26
  }
27
+
28
28
  return component;
29
29
  }
30
30
 
31
31
  subscribe(listener: () => void): () => void {
32
32
  this.listeners.add(listener);
33
+
33
34
  return () => {
34
35
  this.listeners.delete(listener);
35
36
  };
@@ -38,13 +39,10 @@ export class LoadedComponent<Component> {
38
39
 
39
40
  const openErrorListeners = new Set<(error: unknown) => void>();
40
41
 
41
- /*
42
- * Tells `listener` when a module that `lazyNamed` or `lazyDialog` loads for a render fails to load,
43
- * which is a load a person waits on. A failed prefetch never reaches it, not even one of the same
44
- * module at the same moment, so an app can answer the one without being woken by the other.
45
- */
42
+ /* Only a load for a render, which a person waits on; a failed prefetch never reaches it, not even one of the same module at the same moment. */
46
43
  export function onLazyOpenError(listener: (error: unknown) => void): () => void {
47
44
  openErrorListeners.add(listener);
45
+
48
46
  return () => {
49
47
  openErrorListeners.delete(listener);
50
48
  };
@@ -58,6 +56,7 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
58
56
  for (const listener of openErrorListeners) {
59
57
  listener(error);
60
58
  }
59
+
61
60
  throw error;
62
61
  }
63
62
  }
@@ -65,11 +64,7 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
65
64
  /* A lazy component, with `preload` to have its module before the first render, so that render never suspends. */
66
65
  export type LazyComponent<Props> = ComponentType<Props> & { readonly preload: () => Promise<void> };
67
66
 
68
- /*
69
- * `React.lazy` for a module that exports its component by name, `default` included. Every module
70
- * loaded through here, or through `lazyDialog`, registers with `prefetcher`, which loads it ahead of
71
- * its first opening once the app calls `prefetcher.prefetchEverything()`.
72
- */
67
+ /* Registers with `prefetcher`, like `lazyDialog`, so `prefetcher.prefetchEverything()` loads it ahead of its first opening. */
73
68
  export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(
74
69
  load: () => Promise<Module>,
75
70
  name: Name
@@ -77,13 +72,16 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
77
72
  const loaded = new LoadedComponent(async () => (await load())[name]);
78
73
  prefetcher.register(() => loaded.load());
79
74
  const Lazy = lazy(() => open(loaded));
75
+
80
76
  // Once `loaded` has it, `Lazy` never committed anything, so switching over remounts nothing.
81
77
  const LazyNamed = (props: ComponentProps<Module[Name]>) => {
82
78
  // `loaded` fills in place; compiled, the element drawn before the module landed would stay.
83
79
  'use no memo';
84
80
  const Component: ComponentType<any> = loaded.current ?? Lazy;
81
+
85
82
  return <Component {...props} />;
86
83
  };
84
+
87
85
  return Object.assign(LazyNamed, {
88
86
  preload: async () => {
89
87
  await loaded.load();
@@ -94,16 +92,15 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
94
92
  /* Stays true after the first opening, so every close after it still has something mounted to animate. */
95
93
  function useOpenedOnce(open: boolean): boolean {
96
94
  const [opened, setOpened] = useState(open);
95
+
97
96
  if (open && !opened) {
98
97
  setOpened(true);
99
98
  }
99
+
100
100
  return opened || open;
101
101
  }
102
102
 
103
- /*
104
- * A dialog that keeps its own open state. Once its module is here, prefetched or opened, it stays
105
- * mounted closed, so its first opening animates like every other; before that, opening loads it.
106
- */
103
+ /* Once its module is here it stays mounted closed, so its first opening animates like every other. */
107
104
  export function lazyDialog<Module extends Record<Name, ComponentType>, Name extends keyof Module, State>(
108
105
  load: () => Promise<Module>,
109
106
  name: Name,
@@ -113,25 +110,32 @@ export function lazyDialog<Module extends Record<Name, ComponentType>, Name exte
113
110
  const loaded = new LoadedComponent(async (): Promise<ComponentType> => (await load())[name]);
114
111
  prefetcher.register(() => loaded.load());
115
112
  const Dialog = lazy(() => open(loaded));
113
+
116
114
  const subscribe = (listener: () => void) => loaded.subscribe(listener);
115
+
117
116
  const isLoaded = () => loaded.current !== null;
117
+
118
118
  const LazyDialog = () => {
119
119
  // As in `lazyNamed`: what `loaded` holds is read again on every render.
120
120
  'use no memo';
121
121
  const opened = useOpenedOnce(useStore(isOpen));
122
122
  useSyncExternalStore(subscribe, isLoaded);
123
123
  const Loaded: ComponentType | null = loaded.current;
124
+
124
125
  if (Loaded) {
125
126
  return <Loaded />;
126
127
  }
128
+
127
129
  if (!opened) {
128
130
  return null;
129
131
  }
132
+
130
133
  return (
131
134
  <Suspense fallback={null}>
132
135
  <Dialog />
133
136
  </Suspense>
134
137
  );
135
138
  };
139
+
136
140
  return LazyDialog;
137
141
  }