@adecore/ui 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/dist/AccentSwatches.js +2 -3
  2. package/dist/Banner.js +1 -6
  3. package/dist/ChoiceCards.js +0 -5
  4. package/dist/ColorSwatch.js +1 -4
  5. package/dist/ColorWheelPicker.js +3 -6
  6. package/dist/DockShell.js +3 -5
  7. package/dist/EmptyState.js +0 -1
  8. package/dist/ErrorBoundary.js +1 -5
  9. package/dist/Field.js +2 -6
  10. package/dist/FileIcon.js +2 -4
  11. package/dist/Icon.js +1 -2
  12. package/dist/IconButton.js +1 -1
  13. package/dist/IconPicker.js +3 -7
  14. package/dist/Input.js +1 -2
  15. package/dist/Kbd.js +1 -3
  16. package/dist/LazyBoundary.js +2 -3
  17. package/dist/Meter.js +0 -1
  18. package/dist/MiddleTruncate.js +2 -6
  19. package/dist/PanelEmpty.js +0 -1
  20. package/dist/PanelHeader.js +1 -4
  21. package/dist/Pill.js +1 -6
  22. package/dist/ProjectSwitcher.js +1 -1
  23. package/dist/PromptDialog.js +5 -8
  24. package/dist/RelativeTime.js +1 -1
  25. package/dist/SegmentBar.js +1 -2
  26. package/dist/Segmented.js +2 -5
  27. package/dist/Select.js +1 -4
  28. package/dist/ShortcutHints.js +2 -3
  29. package/dist/Slider.js +0 -1
  30. package/dist/SlidingColumn.js +2 -4
  31. package/dist/SplitButton.js +1 -5
  32. package/dist/TextMenu.js +1 -5
  33. package/dist/Tile.js +0 -4
  34. package/dist/Toasts.js +1 -9
  35. package/dist/Tooltip.js +1 -1
  36. package/dist/UIProvider.js +0 -5
  37. package/dist/Waveform.js +4 -2
  38. package/dist/Wipe.js +1 -6
  39. package/dist/ZoomControls.js +0 -1
  40. package/dist/clipboard.js +2 -5
  41. package/dist/combobox/parts.js +3 -9
  42. package/dist/command-palette/parts.js +5 -14
  43. package/dist/dialog/parts.js +5 -10
  44. package/dist/dialog-layer.js +2 -3
  45. package/dist/error-boundary.js +4 -20
  46. package/dist/file-icon.js +4 -9
  47. package/dist/file-tree/empty-lines.js +2 -8
  48. package/dist/file-tree/use-empty-lines.js +1 -5
  49. package/dist/file-tree/use-slots.js +6 -10
  50. package/dist/format/datetime.js +32 -29
  51. package/dist/format/duration.js +2 -10
  52. package/dist/format/locale.js +19 -39
  53. package/dist/format/number.js +9 -22
  54. package/dist/format/regions.js +4 -7
  55. package/dist/icon-picker.js +5 -14
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +1 -0
  58. package/dist/key-value-list/parts.js +1 -5
  59. package/dist/lazy.js +4 -19
  60. package/dist/locales.js +2 -6
  61. package/dist/menu/parts.js +61 -133
  62. package/dist/middle-truncate.js +2 -3
  63. package/dist/placement.js +0 -1
  64. package/dist/popover/parts.js +1 -5
  65. package/dist/popup-layer.js +2 -4
  66. package/dist/prefetch.js +2 -5
  67. package/dist/selection.js +1 -5
  68. package/dist/settings/MasterDetail.js +1 -7
  69. package/dist/settings/SettingsDialog.js +1 -2
  70. package/dist/settings/SettingsRow.js +2 -5
  71. package/dist/settings/SettingsSection.js +1 -1
  72. package/dist/shortcut-hints.js +1 -5
  73. package/dist/shortcut.js +1 -2
  74. package/dist/tabs/overflow.js +1 -6
  75. package/dist/tabs/parts.js +69 -95
  76. package/dist/testing/dedupe.js +5 -7
  77. package/dist/tint.d.ts +2 -0
  78. package/dist/tint.js +11 -0
  79. package/dist/toast-store.js +2 -10
  80. package/dist/tree/use-tree-shift.js +2 -6
  81. package/dist/useColumnResize.js +9 -10
  82. package/dist/useNow.js +2 -9
  83. package/dist/workspace/DropPreview.js +1 -5
  84. package/dist/workspace/preview.js +4 -9
  85. package/package.json +9 -9
  86. package/src/AccentSwatches.tsx +9 -3
  87. package/src/Banner.tsx +5 -11
  88. package/src/Button.tsx +4 -0
  89. package/src/Checkbox.tsx +3 -0
  90. package/src/ChoiceCards.tsx +12 -11
  91. package/src/CloseButton.tsx +1 -0
  92. package/src/ColorSwatch.tsx +7 -8
  93. package/src/ColorWheelPicker.tsx +28 -12
  94. package/src/ColumnResizeHandle.tsx +1 -0
  95. package/src/CopyButton.tsx +6 -0
  96. package/src/DisabledReason.tsx +1 -0
  97. package/src/DockShell.tsx +11 -10
  98. package/src/EmptyState.tsx +10 -6
  99. package/src/ErrorBoundary.tsx +13 -10
  100. package/src/Field.tsx +11 -13
  101. package/src/FileIcon.tsx +4 -4
  102. package/src/Icon.tsx +2 -4
  103. package/src/IconButton.tsx +14 -5
  104. package/src/IconPicker.tsx +30 -15
  105. package/src/Input.tsx +11 -9
  106. package/src/Kbd.tsx +8 -7
  107. package/src/LazyBoundary.tsx +3 -3
  108. package/src/Meter.tsx +7 -2
  109. package/src/MiddleTruncate.tsx +15 -6
  110. package/src/PanelEmpty.tsx +9 -7
  111. package/src/PanelHeader.tsx +2 -5
  112. package/src/Pill.tsx +7 -8
  113. package/src/ProjectSwitcher.tsx +16 -6
  114. package/src/PromptDialog.tsx +31 -26
  115. package/src/RelativeTime.tsx +6 -1
  116. package/src/SegmentBar.tsx +22 -11
  117. package/src/Segmented.tsx +16 -11
  118. package/src/Select.tsx +7 -4
  119. package/src/SelectSearch.tsx +3 -0
  120. package/src/ShortcutHints.tsx +24 -3
  121. package/src/Skeleton.tsx +1 -0
  122. package/src/Slider.tsx +4 -1
  123. package/src/SlidingColumn.tsx +18 -14
  124. package/src/Spinner.tsx +1 -0
  125. package/src/SplitButton.tsx +8 -8
  126. package/src/Stepper.tsx +6 -0
  127. package/src/Switch.tsx +2 -0
  128. package/src/TextMenu.tsx +2 -5
  129. package/src/Tile.tsx +2 -6
  130. package/src/Toasts.tsx +8 -12
  131. package/src/Tooltip.tsx +10 -5
  132. package/src/UIProvider.tsx +7 -7
  133. package/src/Waveform.tsx +41 -12
  134. package/src/Wipe.tsx +5 -6
  135. package/src/ZoomControls.tsx +5 -2
  136. package/src/clipboard.ts +4 -5
  137. package/src/combobox/parts.tsx +18 -14
  138. package/src/command-palette/context.ts +2 -0
  139. package/src/command-palette/index.parts.ts +1 -0
  140. package/src/command-palette/parts.tsx +20 -14
  141. package/src/command-palette/virtual.tsx +21 -0
  142. package/src/dialog/parts.tsx +10 -13
  143. package/src/dialog-layer.ts +5 -3
  144. package/src/error-boundary.ts +7 -20
  145. package/src/file-icon.ts +8 -9
  146. package/src/file-tree/empty-lines.ts +24 -8
  147. package/src/file-tree/helpers.ts +34 -0
  148. package/src/file-tree/highlight.ts +19 -0
  149. package/src/file-tree/index.parts.ts +1 -0
  150. package/src/file-tree/parts.tsx +41 -5
  151. package/src/file-tree/use-empty-lines.ts +22 -5
  152. package/src/file-tree/use-file-tree-shift.tsx +10 -0
  153. package/src/file-tree/use-file-tree.ts +2 -0
  154. package/src/file-tree/use-slots.ts +77 -12
  155. package/src/format/datetime.ts +42 -17
  156. package/src/format/duration.ts +28 -10
  157. package/src/format/index.ts +3 -0
  158. package/src/format/locale.ts +33 -42
  159. package/src/format/number.ts +21 -27
  160. package/src/format/regions.ts +4 -7
  161. package/src/icon-picker.ts +23 -16
  162. package/src/index.ts +9 -0
  163. package/src/key-value-list/parts.tsx +1 -5
  164. package/src/lazy-landings.ts +2 -0
  165. package/src/lazy.tsx +23 -19
  166. package/src/locales.ts +2 -6
  167. package/src/match.ts +14 -0
  168. package/src/menu/parts.tsx +30 -55
  169. package/src/middle-truncate.ts +12 -3
  170. package/src/modality.ts +4 -0
  171. package/src/placement.ts +2 -1
  172. package/src/platform.ts +2 -0
  173. package/src/popover/parts.tsx +3 -5
  174. package/src/popup-layer.ts +6 -4
  175. package/src/prefetch.ts +18 -5
  176. package/src/project-switcher-row.tsx +2 -0
  177. package/src/segmented-keys.ts +6 -0
  178. package/src/select-shared.ts +2 -0
  179. package/src/selection.ts +5 -5
  180. package/src/settings/ConfirmDialog.tsx +3 -0
  181. package/src/settings/MasterDetail.tsx +3 -9
  182. package/src/settings/SettingsDialog.tsx +27 -9
  183. package/src/settings/SettingsRow.tsx +16 -10
  184. package/src/settings/SettingsSection.tsx +10 -5
  185. package/src/settings/index.ts +3 -0
  186. package/src/settings/target.ts +1 -0
  187. package/src/shortcut-hints.ts +10 -5
  188. package/src/shortcut.ts +26 -6
  189. package/src/tabs/overflow.ts +8 -6
  190. package/src/tabs/parts.tsx +50 -26
  191. package/src/testing/dedupe.ts +27 -7
  192. package/src/testing/fake-source.ts +1 -0
  193. package/src/tint.ts +13 -0
  194. package/src/toast-store.ts +20 -14
  195. package/src/tree/parts.tsx +8 -0
  196. package/src/tree/shift.ts +6 -0
  197. package/src/tree/use-tree-shift.tsx +23 -7
  198. package/src/useAsyncAction.ts +8 -0
  199. package/src/useColumnResize.ts +24 -15
  200. package/src/useContentSize.ts +5 -0
  201. package/src/useMeasuredWidth.ts +1 -0
  202. package/src/useNow.ts +6 -11
  203. package/src/workspace/DropPreview.tsx +3 -5
  204. package/src/workspace/SplitView.tsx +109 -0
  205. package/src/workspace/TabStrip.tsx +42 -0
  206. package/src/workspace/Workspace.tsx +5 -0
  207. package/src/workspace/context.ts +1 -0
  208. package/src/workspace/geometry.ts +49 -0
  209. package/src/workspace/model.ts +72 -0
  210. package/src/workspace/preview.ts +15 -15
@@ -11,6 +11,7 @@ export interface RowSlot {
11
11
  row: FileTreeVisibleRow;
12
12
  control: HTMLElement;
13
13
  decoration: HTMLElement;
14
+
14
15
  /* Where a row's own icon goes in place of the engine's glyph; null when the row keeps that glyph. */
15
16
  icon: HTMLElement | null;
16
17
  }
@@ -29,31 +30,30 @@ export interface Port {
29
30
  export type FileTreeRowIcon = (row: FileTreeVisibleRow) => LucideIcon | undefined;
30
31
 
31
32
  /*
32
- * The rows of the mounted window, read from the spacer beta.6 puts above it (its first index times the
33
- * row height), so a scroll frame copies those and not every visible row. The whole list stands in when
34
- * the spacer is missing or the model already moved past what is mounted.
33
+ * Read from the spacer beta.6 puts above the mounted window, so a scroll frame copies those rows and not
34
+ * every visible one. The whole list stands in when the spacer is missing or the model moved past it.
35
35
  */
36
36
  function mountedRows(model: FileTree, shadow: ShadowRoot, elements: readonly HTMLElement[]): FileTreeVisibleRow[] {
37
37
  const spacer = shadow.querySelector<HTMLElement>('[data-file-tree-virtualized-sticky-offset]');
38
38
  const offset = spacer === null ? Number.NaN : Number.parseFloat(spacer.style.height);
39
39
  const itemHeight = model.getItemHeight();
40
+
40
41
  if (!Number.isFinite(offset) || itemHeight <= 0) {
41
42
  return visibleRows(model);
42
43
  }
44
+
43
45
  // A folder pinned to the top leaves the flow, but still takes its index in the window.
44
46
  const pinned = shadow.querySelectorAll('[data-file-tree-sticky-row="true"]').length;
45
47
  const rows = visibleRowsAround(model, Math.round(offset / itemHeight), elements.length + pinned);
46
48
  const paths = new Set(rows.map((row) => row.path));
49
+
47
50
  return elements.every((element) => element.dataset.itemPath === undefined || paths.has(element.dataset.itemPath)) ? rows : visibleRows(model);
48
51
  }
49
52
 
50
- /* Where a row's own decoration goes: between the name and the engine's git status column, or after that column. */
53
+ /* Relative to the engine's git status column. */
51
54
  export type FileTreeDecorationOrder = 'before-status' | 'after-status';
52
55
 
53
- /*
54
- * Lines the ports up with their rows: every size first, then the spaces they reserve, then every offset,
55
- * then the ports, so a pass forces two layouts at most however many rows are mounted.
56
- */
56
+ /* Every size first, then the spaces, then every offset, then the ports, so a pass forces two layouts at most. */
57
57
  export function positionPorts(ports: Iterable<Port>, order: FileTreeDecorationOrder = 'before-status'): void {
58
58
  const spaced = [...ports].map((port) => ({
59
59
  port,
@@ -62,16 +62,19 @@ export function positionPorts(ports: Iterable<Port>, order: FileTreeDecorationOr
62
62
  control: port.lightControl.childNodes.length > 0 ? Math.ceil(port.lightControl.getBoundingClientRect().width) + 6 : 0,
63
63
  decoration: port.lightDecoration.childNodes.length > 0 ? Math.ceil(port.lightDecoration.getBoundingClientRect().width) + 6 : 0
64
64
  }));
65
+
65
66
  for (const { port, status, control, decoration } of spaced) {
66
67
  setSpace(port.rowElement, '--adecore-tree-control-space', control);
67
68
  setSpace(port.rowElement, '--adecore-tree-decoration-space', status === null ? decoration : 0);
68
69
  setSpace(port.rowElement, '--adecore-tree-status-space', status === null ? 0 : decoration);
69
70
  }
71
+
70
72
  const placed = spaced.map(({ port, status, control, decoration }) => {
71
73
  const row = port.rowElement;
72
74
  const before = status ?? row.querySelector<HTMLElement>('[data-item-section="content"]');
73
75
  const icon = row.querySelector<HTMLElement>('[data-item-section="icon"]');
74
76
  const iconLeft = row.offsetLeft + (icon?.offsetLeft ?? 4);
77
+
75
78
  return {
76
79
  port,
77
80
  top: `${row.offsetTop}px`,
@@ -80,11 +83,13 @@ export function positionPorts(ports: Iterable<Port>, order: FileTreeDecorationOr
80
83
  decorationLeft: `${row.offsetLeft + (before === null ? row.offsetWidth - decoration - 2 : before.offsetLeft + before.offsetWidth + 6)}px`
81
84
  };
82
85
  });
86
+
83
87
  for (const { port, top, iconLeft, controlLeft, decorationLeft } of placed) {
84
88
  port.control.style.top = top;
85
89
  port.control.style.left = controlLeft;
86
90
  port.decoration.style.top = top;
87
91
  port.decoration.style.left = decorationLeft;
92
+
88
93
  if (port.icon !== undefined) {
89
94
  port.icon.style.top = top;
90
95
  port.icon.style.left = iconLeft;
@@ -95,6 +100,7 @@ export function positionPorts(ports: Iterable<Port>, order: FileTreeDecorationOr
95
100
  // An unchanged space is left alone, so a pass that only scrolled measures once.
96
101
  function setSpace(row: HTMLElement, name: string, space: number): void {
97
102
  const value = `${space}px`;
103
+
98
104
  if (row.style.getPropertyValue(name) !== value) {
99
105
  row.style.setProperty(name, value);
100
106
  }
@@ -103,8 +109,10 @@ function setSpace(row: HTMLElement, name: string, space: number): void {
103
109
  export interface FileTreeSlotOptions {
104
110
  order: FileTreeDecorationOrder;
105
111
  rowIcon?: FileTreeRowIcon;
106
- /* What to mark in every name; empty marks nothing. */
112
+
113
+ /* Empty marks nothing. */
107
114
  highlight: string;
115
+
108
116
  emptyLabel?: FileTreeEmptyLabel;
109
117
  }
110
118
 
@@ -114,6 +122,7 @@ function markEmptyLine(element: HTMLElement, label: string): void {
114
122
  element.dataset.interactive = 'false';
115
123
  element.setAttribute('aria-hidden', 'true');
116
124
  const content = element.querySelector<HTMLElement>('[data-item-section="content"]');
125
+
117
126
  if (content !== null && content.dataset.emptyLabel !== label) {
118
127
  content.dataset.emptyLabel = label;
119
128
  }
@@ -124,9 +133,11 @@ function unmarkEmptyLine(element: HTMLElement): void {
124
133
  if (element.dataset.emptyLine === undefined) {
125
134
  return;
126
135
  }
136
+
127
137
  delete element.dataset.emptyLine;
128
138
  element.removeAttribute('aria-hidden');
129
139
  const content = element.querySelector<HTMLElement>('[data-item-section="content"]');
140
+
130
141
  if (content !== null) {
131
142
  delete content.dataset.emptyLabel;
132
143
  }
@@ -152,6 +163,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
152
163
  if (frame === null) {
153
164
  return;
154
165
  }
166
+
155
167
  const ports = new Map<HTMLElement, Port>();
156
168
  let shadow: ShadowRoot | null = null;
157
169
  let style: HTMLStyleElement | null = null;
@@ -163,13 +175,17 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
163
175
 
164
176
  const mark = (rows: readonly HTMLElement[], query: string): void => {
165
177
  const matches = matchHighlight();
178
+
166
179
  if (matches === null) {
167
180
  return;
168
181
  }
182
+
169
183
  for (const range of marked) {
170
184
  matches.delete(range);
171
185
  }
186
+
172
187
  marked = rangesOfMatches(rows, query);
188
+
173
189
  for (const range of marked) {
174
190
  matches.add(range);
175
191
  }
@@ -178,6 +194,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
178
194
  const position = (): void => positionPorts(ports.values(), order);
179
195
 
180
196
  const resize = new ResizeObserver(position);
197
+
181
198
  const dropPort = (port: Port): void => {
182
199
  port.control.remove();
183
200
  port.decoration.remove();
@@ -187,13 +204,16 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
187
204
  port.lightDecoration.remove();
188
205
  dropIcon(port);
189
206
  };
207
+
190
208
  const clearPort = (port: Port): void => {
191
- port.rowElement.style.removeProperty('--adecore-tree-control-space');
192
- port.rowElement.style.removeProperty('--adecore-tree-decoration-space');
193
- port.rowElement.style.removeProperty('--adecore-tree-status-space');
209
+ for (const name of ['--adecore-tree-control-space', '--adecore-tree-decoration-space', '--adecore-tree-status-space']) {
210
+ port.rowElement.style.removeProperty(name);
211
+ }
212
+
194
213
  delete port.rowElement.dataset.ownIcon;
195
214
  dropPort(port);
196
215
  };
216
+
197
217
  const dropIcon = (port: Port): void => {
198
218
  port.unhover?.();
199
219
  port.icon?.remove();
@@ -202,6 +222,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
202
222
  port.lightIcon = undefined;
203
223
  port.unhover = undefined;
204
224
  };
225
+
205
226
  // The icon lets every pointer event through to the row under it, which then opens, folds or selects as it would.
206
227
  const makeIcon = (port: Port, host: HTMLElement): void => {
207
228
  const container = document.createElement('span');
@@ -215,24 +236,29 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
215
236
  target.slot = slot.name;
216
237
  target.className = 'adecore-tree-icon';
217
238
  host.append(target);
239
+
218
240
  // A folder under the pointer gives its chevron back, so the row still says it opens.
219
241
  const show = (): void => {
220
242
  if (port.rowElement.dataset.itemType === 'folder') {
221
243
  container.dataset.hover = 'true';
222
244
  }
223
245
  };
246
+
224
247
  const hide = (): void => {
225
248
  delete container.dataset.hover;
226
249
  };
250
+
227
251
  port.rowElement.addEventListener('pointerenter', show);
228
252
  port.rowElement.addEventListener('pointerleave', hide);
229
253
  port.icon = container;
230
254
  port.lightIcon = target;
255
+
231
256
  port.unhover = () => {
232
257
  port.rowElement.removeEventListener('pointerenter', show);
233
258
  port.rowElement.removeEventListener('pointerleave', hide);
234
259
  };
235
260
  };
261
+
236
262
  const makePort = (element: HTMLElement, host: HTMLElement, kind: 'control' | 'decoration'): { container: HTMLElement; target: HTMLElement } => {
237
263
  const container = document.createElement('span');
238
264
  container.dataset.treePort = kind;
@@ -244,80 +270,104 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
244
270
  target.slot = slot.name;
245
271
  target.dataset.treeSlot = kind;
246
272
  target.className = kind === 'control' ? 'adecore-tree-control' : 'adecore-tree-decoration';
273
+
247
274
  // React delegates to this portal target before the event reaches the engine's shadow tree.
248
275
  for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'pointerdown', 'dragstart']) {
249
276
  target.addEventListener(type, (event) => event.stopPropagation());
250
277
  }
278
+
251
279
  host.append(target);
252
280
  resize.observe(target);
281
+
253
282
  return { container, target };
254
283
  };
284
+
255
285
  const scan = (): void => {
256
286
  pending = false;
287
+
257
288
  if (disposed) {
258
289
  return;
259
290
  }
291
+
260
292
  const current = model.getFileTreeContainer();
293
+
261
294
  if (!current?.shadowRoot) {
262
295
  return;
263
296
  }
297
+
264
298
  if (shadow !== current.shadowRoot) {
265
299
  shadow = current.shadowRoot;
266
300
  style = document.createElement('style');
267
301
  style.dataset.adecoreTreeStyle = 'true';
268
302
  style.textContent = FILE_TREE_CSS;
269
303
  shadow.append(style);
304
+
270
305
  changes.observe(shadow, {
271
306
  childList: true,
272
307
  subtree: true,
273
308
  attributes: true,
274
309
  attributeFilter: ['data-item-path', 'aria-selected', 'aria-expanded']
275
310
  });
311
+
276
312
  changes.observe(current, { childList: true, subtree: true });
277
313
  resize.observe(current);
278
314
  }
315
+
279
316
  shadow.querySelector('[role="tree"]')?.setAttribute('aria-label', label);
317
+
280
318
  const elements = [
281
319
  ...shadow.querySelectorAll<HTMLElement>('[data-type="item"]:not([data-item-parked="true"]):not([data-file-tree-sticky-row="true"])')
282
320
  ];
321
+
283
322
  const rows = mountedRows(model, shadow, elements);
284
323
  const byPath = new Map(rows.map((row) => [pathOfRow(row), row]));
285
324
  const byIndex = new Map(rows.map((row) => [row.index, row]));
286
325
  const live = new Set(elements);
326
+
287
327
  for (const [element, port] of ports) {
288
328
  if (!live.has(element)) {
289
329
  dropPort(port);
290
330
  ports.delete(element);
291
331
  }
292
332
  }
333
+
293
334
  const next: RowSlot[] = [];
294
335
  const named: HTMLElement[] = [];
336
+
295
337
  for (const element of elements) {
296
338
  const path = element.dataset.itemPath;
297
339
  const row = path === undefined ? undefined : byPath.get(path);
340
+
298
341
  if (row === undefined) {
299
342
  continue;
300
343
  }
344
+
301
345
  if (isEmptyLine(row.path)) {
302
346
  const recycled = ports.get(element);
347
+
303
348
  if (recycled !== undefined) {
304
349
  clearPort(recycled);
305
350
  ports.delete(element);
306
351
  }
352
+
307
353
  const directory = byPath.get(directoryOfEmptyLine(row.path));
308
354
  markEmptyLine(element, (directory === undefined ? undefined : latestEmpty.current?.(directory)) ?? '');
355
+
309
356
  continue;
310
357
  }
358
+
311
359
  unmarkEmptyLine(element);
312
360
  named.push(element);
313
361
  element.dataset.interactive = 'true';
314
362
  element.dataset.joinedStart = String(row.isSelected && byIndex.get(row.index - 1)?.isSelected === true);
315
363
  element.dataset.joinedEnd = String(row.isSelected && byIndex.get(row.index + 1)?.isSelected === true);
316
364
  let port = ports.get(element);
365
+
317
366
  if (port === undefined) {
318
367
  serial++;
319
368
  const control = makePort(element, current, 'control');
320
369
  const decoration = makePort(element, current, 'decoration');
370
+
321
371
  port = {
322
372
  rowElement: element,
323
373
  control: control.container,
@@ -325,52 +375,65 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
325
375
  lightControl: control.target,
326
376
  lightDecoration: decoration.target
327
377
  };
378
+
328
379
  ports.set(element, port);
329
380
  }
381
+
330
382
  if (port.control.dataset.itemPath !== row.path) {
331
383
  port.control.dataset.itemPath = row.path;
332
384
  }
385
+
333
386
  if (port.decoration.dataset.itemPath !== row.path) {
334
387
  port.decoration.dataset.itemPath = row.path;
335
388
  }
389
+
336
390
  port.lightControl.dataset.itemPath = row.path;
337
391
  port.lightDecoration.dataset.itemPath = row.path;
338
392
  const ownIcon = latestIcon.current?.(row) !== undefined;
393
+
339
394
  if (ownIcon && port.icon === undefined) {
340
395
  makeIcon(port, current);
341
396
  } else if (!ownIcon && port.icon !== undefined) {
342
397
  dropIcon(port);
343
398
  }
399
+
344
400
  if (port.icon !== undefined && port.icon.dataset.itemPath !== row.path) {
345
401
  port.icon.dataset.itemPath = row.path;
346
402
  }
403
+
347
404
  if (ownIcon) {
348
405
  element.dataset.ownIcon = 'true';
349
406
  } else if (element.dataset.ownIcon !== undefined) {
350
407
  delete element.dataset.ownIcon;
351
408
  }
409
+
352
410
  next.push({ row, control: port.lightControl, decoration: port.lightDecoration, icon: port.lightIcon ?? null });
353
411
  }
412
+
354
413
  const key = next
355
414
  .map(
356
415
  ({ row, control, icon }) =>
357
416
  `${control.slot}:${row.path}:${row.isSelected}:${row.isExpanded}:${row.isFocused}:${row.name}:${icon?.slot ?? ''}`
358
417
  )
359
418
  .join('|');
419
+
360
420
  if (snapshot !== key) {
361
421
  snapshot = key;
362
422
  // Recycled rows must paint with their new portal contents in the same frame.
363
423
  flushSync(() => setSlots(next));
364
424
  }
425
+
365
426
  mark(named, latestHighlight.current);
366
427
  position();
367
428
  };
429
+
368
430
  const schedule = (): void => {
369
431
  if (!pending && !disposed) {
370
432
  pending = true;
371
433
  queueMicrotask(scan);
372
434
  }
373
435
  };
436
+
374
437
  rescan.current = schedule;
375
438
  const shifts = new MutationObserver(position);
376
439
  shifts.observe(frame, { attributes: true, attributeFilter: ['style'] });
@@ -378,6 +441,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
378
441
  changes.observe(frame, { childList: true, subtree: true });
379
442
  const unsubscribe = model.subscribe(schedule);
380
443
  schedule();
444
+
381
445
  return () => {
382
446
  disposed = true;
383
447
  rescan.current = undefined;
@@ -387,6 +451,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
387
451
  resize.disconnect();
388
452
  style?.remove();
389
453
  mark([], '');
454
+
390
455
  for (const port of ports.values()) {
391
456
  clearPort(port);
392
457
  }
@@ -7,19 +7,19 @@ const WORD_PARTS = new Set<Intl.DateTimeFormatPartTypes>(['month', 'weekday', 'd
7
7
  const toDate = (at: Date | number): Date => (typeof at === 'number' ? new Date(at) : at);
8
8
 
9
9
  /*
10
- * A date written the way the region writes one, with its words in the language the interface is in.
11
- * Both formatters get the same options and the same instant, so every part the region asks for has
12
- * one to lift the word from, whatever order the two put them in: `19 Sep` in English against a Dutch
13
- * region, `19 sep` once the interface is Dutch too, and a clock that reads `08:05` wherever the
14
- * region does not say AM.
10
+ * The region's notation with the language's words: `19 Sep` in English against a Dutch region. Both
11
+ * formatters get the same options and instant, so every word part has one to lift, whatever the order.
15
12
  */
16
13
  export const formatDateTime = (at: Date | number, options: Intl.DateTimeFormatOptions): string => {
17
14
  const date = toDate(at);
18
15
  const parts = dateFormatter(options).formatToParts(date);
16
+
19
17
  if (!parts.some((part) => WORD_PARTS.has(part.type))) {
20
18
  return parts.map((part) => part.value).join('');
21
19
  }
20
+
22
21
  const words = wordFormatter(options).formatToParts(date);
22
+
23
23
  return parts.map((part) => (WORD_PARTS.has(part.type) ? (words.find((word) => word.type === part.type)?.value ?? part.value) : part.value)).join('');
24
24
  };
25
25
 
@@ -35,11 +35,36 @@ const DAY_CLOCK: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric',
35
35
  const DAY_CLOCK_WITH_YEAR: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' };
36
36
  const NUMERIC_DATE: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'numeric', day: 'numeric' };
37
37
 
38
+ const zonedSpecs = new WeakMap<Intl.DateTimeFormatOptions, Map<string, Intl.DateTimeFormatOptions>>();
39
+
40
+ /* One options object per zone, so the formatter cached on it is built once (`dateFormatter`). */
41
+ const zoned = (options: Intl.DateTimeFormatOptions, timeZone: string | undefined): Intl.DateTimeFormatOptions => {
42
+ if (timeZone === undefined) {
43
+ return options;
44
+ }
45
+
46
+ let byZone = zonedSpecs.get(options);
47
+
48
+ if (byZone === undefined) {
49
+ byZone = new Map();
50
+ zonedSpecs.set(options, byZone);
51
+ }
52
+
53
+ let spec = byZone.get(timeZone);
54
+
55
+ if (spec === undefined) {
56
+ spec = { ...options, timeZone };
57
+ byZone.set(timeZone, spec);
58
+ }
59
+
60
+ return spec;
61
+ };
62
+
38
63
  /*
39
- * `08:05`, or `08:05 AM` in a region that counts to twelve. The clock functions below take the `timeZone`
40
- * to write in, such as `UTC` for a moment whose parts are given and mean the same wall clock anywhere.
64
+ * `08:05`, or `08:05 AM` in a region that counts to twelve. A `timeZone` such as `UTC` writes a moment
65
+ * whose parts are given and mean the same wall clock anywhere.
41
66
  */
42
- export const formatClock = (at: Date | number, timeZone?: string): string => formatDateTime(at, timeZone === undefined ? CLOCK : { ...CLOCK, timeZone });
67
+ export const formatClock = (at: Date | number, timeZone?: string): string => formatDateTime(at, zoned(CLOCK, timeZone));
43
68
 
44
69
  /* A time far enough off that its day is part of the answer: `Sat 08:05`. */
45
70
  export const formatWeekdayClock = (at: Date | number): string => formatDateTime(at, WEEKDAY_CLOCK);
@@ -51,30 +76,28 @@ export const formatHour = (at: Date | number): string => formatDateTime(at, HOUR
51
76
  export const formatWeekday = (at: Date | number, width: 'short' | 'long' = 'short'): string => formatDateTime(at, width === 'long' ? WEEKDAY_LONG : WEEKDAY);
52
77
 
53
78
  /* `19 Sep`, the short date a row falls back to once "days ago" stops meaning anything. */
54
- export const formatDay = (at: Date | number, timeZone?: string): string => formatDateTime(at, timeZone === undefined ? DAY : { ...DAY, timeZone });
79
+ export const formatDay = (at: Date | number, timeZone?: string): string => formatDateTime(at, zoned(DAY, timeZone));
55
80
 
56
81
  /* The same date with the year, for anything older than the one we are in. */
57
- export const formatDayWithYear = (at: Date | number, timeZone?: string): string =>
58
- formatDateTime(at, timeZone === undefined ? DAY_WITH_YEAR : { ...DAY_WITH_YEAR, timeZone });
82
+ export const formatDayWithYear = (at: Date | number, timeZone?: string): string => formatDateTime(at, zoned(DAY_WITH_YEAR, timeZone));
59
83
 
60
84
  /* `Sat 19 Sep`. */
61
85
  export const formatWeekdayDay = (at: Date | number): string => formatDateTime(at, WEEKDAY_DAY);
62
86
 
63
87
  /* `19 Sep, 08:05`: a moment that is not today, to the minute. */
64
- export const formatDayClock = (at: Date | number, timeZone?: string): string =>
65
- formatDateTime(at, timeZone === undefined ? DAY_CLOCK : { ...DAY_CLOCK, timeZone });
88
+ export const formatDayClock = (at: Date | number, timeZone?: string): string => formatDateTime(at, zoned(DAY_CLOCK, timeZone));
66
89
 
67
- /* `19 Sep 2026, 08:05`: the same moment with the year, for one that is not in the year we are in. */
68
- export const formatDayClockWithYear = (at: Date | number, timeZone?: string): string =>
69
- formatDateTime(at, timeZone === undefined ? DAY_CLOCK_WITH_YEAR : { ...DAY_CLOCK_WITH_YEAR, timeZone });
90
+ /* `19 Sep 2026, 08:05`: the same moment with the year. */
91
+ export const formatDayClockWithYear = (at: Date | number, timeZone?: string): string => formatDateTime(at, zoned(DAY_CLOCK_WITH_YEAR, timeZone));
70
92
 
71
93
  /* All numbers, as a form would print it: `19-9-2026` here, `9/19/2026` in the United States. */
72
94
  export const formatNumericDate = (at: Date | number): string => formatDateTime(at, NUMERIC_DATE);
73
95
 
74
- /* Whether two instants fall on the same day of the same year, in the zone of whoever is reading. */
96
+ /* In the zone of whoever is reading. */
75
97
  export const isSameDay = (a: Date | number, b: Date | number): boolean => {
76
98
  const left = toDate(a);
77
99
  const right = toDate(b);
100
+
78
101
  return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
79
102
  };
80
103
 
@@ -90,8 +113,10 @@ export const formatAge = (at: Date | number, now: Date | number = Date.now()): s
90
113
  const date = toDate(at);
91
114
  const today = toDate(now);
92
115
  const past = today.getTime() - date.getTime();
116
+
93
117
  if (past < WEEK) {
94
118
  return formatAgo(past);
95
119
  }
120
+
96
121
  return date.getFullYear() === today.getFullYear() ? formatDay(date) : formatDayWithYear(date);
97
122
  };
@@ -6,20 +6,20 @@ const MINUTE = 60 * SECOND;
6
6
  const HOUR = 60 * MINUTE;
7
7
  const DAY = 24 * HOUR;
8
8
 
9
- /*
10
- * How long something took, at the coarsest unit that still says something. The units are English
11
- * abbreviations, the way the rest of the interface is written; only the number bends to the region,
12
- * which is what `1,5 h` against `1.5 h` is about.
13
- */
9
+ /* At the coarsest unit that still says something. The units stay English; only the number bends to the region (`1,5 h`). */
14
10
  export const formatDuration = (ms: number): string => {
15
11
  const seconds = Math.max(0, Math.round(ms / SECOND));
12
+
16
13
  if (seconds < 60) {
17
14
  return `${formatNumber(seconds)} s`;
18
15
  }
16
+
19
17
  const minutes = Math.round(seconds / 60);
18
+
20
19
  if (minutes < 60) {
21
20
  return `${formatNumber(minutes)} min`;
22
21
  }
22
+
23
23
  return `${formatDecimal(minutes / 60)} h`;
24
24
  };
25
25
 
@@ -30,61 +30,75 @@ export const formatDuration = (ms: number): string => {
30
30
  export const formatPreciseDuration = (ms: number): string => {
31
31
  const value = Math.max(0, ms);
32
32
  const tenths = Math.round(value * 10) / 10;
33
+
33
34
  if (tenths < 10) {
34
35
  return `${formatRounded(tenths, 1)} ms`;
35
36
  }
37
+
36
38
  if (Math.round(value) < SECOND) {
37
39
  return `${formatNumber(Math.round(value))} ms`;
38
40
  }
41
+
39
42
  const hundredths = Math.round(value / 10) / 100;
43
+
40
44
  if (hundredths < 10) {
41
45
  return `${formatRounded(hundredths, 2)} s`;
42
46
  }
47
+
43
48
  const seconds = Math.round(value / 100) / 10;
49
+
44
50
  if (seconds < 60) {
45
51
  return `${formatRounded(seconds, 1)} s`;
46
52
  }
53
+
47
54
  return formatDuration(value);
48
55
  };
49
56
 
50
57
  /* How long a window still has, tight enough for the end of a bar: `4d 3h`, `12h 8m`, `9m`. */
51
58
  export const formatCountdown = (ms: number): string => {
52
59
  const left = Math.max(0, ms);
60
+
53
61
  if (left >= DAY) {
54
62
  return `${formatNumber(Math.floor(left / DAY))}d ${formatNumber(Math.round((left % DAY) / HOUR))}h`;
55
63
  }
64
+
56
65
  if (left >= HOUR) {
57
66
  return `${formatNumber(Math.floor(left / HOUR))}h ${formatNumber(Math.round((left % HOUR) / MINUTE))}m`;
58
67
  }
68
+
59
69
  return `${formatNumber(Math.max(1, Math.round(left / MINUTE)))}m`;
60
70
  };
61
71
 
62
- /*
63
- * How long something has been running, to two units and no space to spare: `12s`, `2m 5s`, `3h 20m`.
64
- * A call that took a fraction of a second still took some, so anything above zero reads as at least
65
- * `1s`; only a clock that runs ahead of the start comes out as `0s`.
66
- */
72
+ /* `12s`, `2m 5s`, `3h 20m`. Anything above zero reads as at least `1s`; only a clock that runs ahead of the start comes out as `0s`. */
67
73
  export const formatElapsedShort = (ms: number): string => {
68
74
  const seconds = ms <= 0 ? 0 : Math.max(1, Math.round(ms / SECOND));
75
+
69
76
  if (seconds < 60) {
70
77
  return `${formatNumber(seconds)}s`;
71
78
  }
79
+
72
80
  if (seconds < 3600) {
73
81
  const rest = seconds % 60;
74
82
  const minutes = Math.floor(seconds / 60);
83
+
75
84
  return rest === 0 ? `${formatNumber(minutes)}m` : `${formatNumber(minutes)}m ${formatNumber(rest)}s`;
76
85
  }
86
+
77
87
  const hours = Math.floor(seconds / 3600);
78
88
  const minutes = Math.floor((seconds % 3600) / 60);
89
+
79
90
  return minutes === 0 ? `${formatNumber(hours)}h` : `${formatNumber(hours)}h ${formatNumber(minutes)}m`;
80
91
  };
81
92
 
82
93
  /* A stopwatch that is watched while it runs: `00:14`, and `1:02:03` once it passes an hour. */
83
94
  export const formatClockDuration = (ms: number): string => {
84
95
  const seconds = Math.max(0, Math.floor(ms / SECOND));
96
+
85
97
  const pad = (value: number): string => String(value).padStart(2, '0');
98
+
86
99
  const clock = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`;
87
100
  const hours = Math.floor(seconds / 3600);
101
+
88
102
  return hours > 0 ? `${formatNumber(hours)}:${clock}` : clock;
89
103
  };
90
104
 
@@ -94,14 +108,18 @@ const NOW: Intl.RelativeTimeFormatOptions = { style: 'narrow', numeric: 'auto' }
94
108
  /* How long ago something was, short enough for the right edge of a row: `3m ago`, `3 min. geleden`. */
95
109
  export const formatAgo = (ms: number): string => {
96
110
  const past = Math.max(0, ms);
111
+
97
112
  if (past < MINUTE) {
98
113
  return relativeFormatter(NOW).format(0, 'second');
99
114
  }
115
+
100
116
  if (past < HOUR) {
101
117
  return relativeFormatter(AGO).format(-Math.floor(past / MINUTE), 'minute');
102
118
  }
119
+
103
120
  if (past < DAY) {
104
121
  return relativeFormatter(AGO).format(-Math.floor(past / HOUR), 'hour');
105
122
  }
123
+
106
124
  return relativeFormatter(AGO).format(-Math.floor(past / DAY), 'day');
107
125
  };
@@ -16,8 +16,10 @@ export {
16
16
  formatWeekdayDay,
17
17
  isSameDay
18
18
  } from './datetime.ts';
19
+
19
20
  export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort, formatPreciseDuration } from './duration.ts';
20
21
  export { formatLocale, labelCollator, nameCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
22
+
21
23
  export {
22
24
  formatBytes,
23
25
  formatDecimal,
@@ -29,5 +31,6 @@ export {
29
31
  formatTokens,
30
32
  formatUsdSignificant
31
33
  } from './number.ts';
34
+
32
35
  export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.ts';
33
36
  export { localTimeZone } from './time-zone.ts';