@adecore/ui 0.39.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 (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 +9 -9
  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
@@ -31,22 +31,28 @@ function virtualRowsOf<ItemValue, Group extends { items: readonly ItemValue[] }>
31
31
  const rows: VirtualPaletteRow<ItemValue>[] = [];
32
32
  const itemRows: number[] = [];
33
33
  let count = 0;
34
+
34
35
  for (const value of values) {
35
36
  const grouped = value !== null && typeof value === 'object' && 'items' in value && Array.isArray(value.items);
36
37
  const group = grouped ? (value as Group) : null;
38
+
37
39
  if (group !== null && group.items.length === 0) {
38
40
  continue;
39
41
  }
42
+
40
43
  const groupIndex = groups.length;
41
44
  groups.push(group);
45
+
42
46
  if (group !== null && labelled) {
43
47
  rows.push({ group: groupIndex, index: -1 });
44
48
  }
49
+
45
50
  for (const item of group === null ? [value as ItemValue] : group.items) {
46
51
  itemRows.push(rows.length);
47
52
  rows.push({ group: groupIndex, index: count++, item });
48
53
  }
49
54
  }
55
+
50
56
  return { groups, rows, itemRows, count };
51
57
  }
52
58
 
@@ -64,6 +70,7 @@ export function CommandPaletteVirtualList<ItemValue, Group extends { items: read
64
70
  const scrollRef = useRef<HTMLDivElement | null>(null);
65
71
  const labelled = groupLabel !== undefined;
66
72
  const { groups, rows, itemRows, count } = useMemo(() => virtualRowsOf<ItemValue, Group>(filtered, labelled), [filtered, labelled]);
73
+
67
74
  // oxlint-disable-next-line react/incompatible-library
68
75
  const virtualizer = useVirtualizer({
69
76
  count: rows.length,
@@ -74,38 +81,48 @@ export function CommandPaletteVirtualList<ItemValue, Group extends { items: read
74
81
  scrollPaddingStart: 6,
75
82
  scrollPaddingEnd: 6
76
83
  });
84
+
77
85
  useLayoutEffect(() => {
78
86
  if (scroll === null) {
79
87
  return;
80
88
  }
89
+
81
90
  const highlight = (index: number, reason: string): void => {
82
91
  const row = itemRows[index];
92
+
83
93
  if (row !== undefined && reason !== 'pointer') {
84
94
  virtualizer.scrollToIndex(row, { align: reason === 'none' ? 'start' : 'auto' });
85
95
  }
86
96
  };
97
+
87
98
  scroll.current = highlight;
99
+
88
100
  return () => {
89
101
  if (scroll.current === highlight) {
90
102
  scroll.current = null;
91
103
  }
92
104
  };
93
105
  }, [scroll, virtualizer, itemRows]);
106
+
94
107
  const visible = virtualizer.getVirtualItems();
95
108
  const visibleGroups = new Map<number, typeof visible>();
109
+
96
110
  for (const item of visible) {
97
111
  const group = rows[item.index]!.group;
98
112
  const current = visibleGroups.get(group);
113
+
99
114
  if (current === undefined) {
100
115
  visibleGroups.set(group, [item]);
101
116
  } else {
102
117
  current.push(item);
103
118
  }
104
119
  }
120
+
105
121
  const renderRows = (items: typeof visible): ReactNode =>
106
122
  items.map((item) => {
107
123
  const row = rows[item.index]!;
108
124
  const group = groups[row.group];
125
+
109
126
  return (
110
127
  <div
111
128
  key={item.key}
@@ -123,15 +140,19 @@ export function CommandPaletteVirtualList<ItemValue, Group extends { items: read
123
140
  </div>
124
141
  );
125
142
  });
143
+
126
144
  return (
127
145
  <CommandPaletteList {...props} ref={mergeRefs(ref, scrollRef)}>
128
146
  <div role="presentation" className="relative" style={{ height: virtualizer.getTotalSize() }}>
129
147
  {[...visibleGroups].map(([index, items]) => {
130
148
  const group = groups[index];
149
+
131
150
  if (group === null || group === undefined) {
132
151
  return <Fragment key={index}>{renderRows(items)}</Fragment>;
133
152
  }
153
+
134
154
  const labelVisible = items.some((item) => rows[item.index]!.index === -1);
155
+
135
156
  return (
136
157
  <Autocomplete.Group key={index} items={group.items}>
137
158
  {!labelVisible && groupLabel !== undefined && (
@@ -5,10 +5,9 @@ import { useRender } from '@base-ui/react/use-render';
5
5
  import { withClass } from '../class-name.ts';
6
6
  import { useDialogLayer } from '../dialog-layer.ts';
7
7
 
8
- /* Whether the dialog is open, for its popup to decide whether it stacks over another one. */
9
8
  const DialogOpenContext = createContext(false);
10
9
 
11
- /* A question or a form of a few fields. Every one is as wide as the others, whatever it asks. */
10
+ /* Every question is as wide as the others, whatever it asks. */
12
11
  const SIZE = {
13
12
  sm: 'w-[420px] p-5'
14
13
  } as const;
@@ -16,6 +15,7 @@ const SIZE = {
16
15
  export function DialogRoot({ open, defaultOpen, onOpenChange, ...props }: ComponentProps<typeof BaseDialog.Root>) {
17
16
  // Followed rather than taken over, so a dialog that keeps its own state still does.
18
17
  const [seen, setSeen] = useState(defaultOpen ?? false);
18
+
19
19
  return (
20
20
  <DialogOpenContext value={open ?? seen}>
21
21
  <BaseDialog.Root
@@ -23,6 +23,7 @@ export function DialogRoot({ open, defaultOpen, onOpenChange, ...props }: Compon
23
23
  defaultOpen={defaultOpen}
24
24
  onOpenChange={(next, details) => {
25
25
  onOpenChange?.(next, details);
26
+
26
27
  if (!details.isCanceled) {
27
28
  setSeen(next);
28
29
  }
@@ -40,23 +41,19 @@ export const DialogClose = BaseDialog.Close;
40
41
  export type DialogPopupProps = ComponentProps<typeof BaseDialog.Popup> & {
41
42
  /* `sm` is the dialog of a question or a few fields. Without a size the popup is as wide as its `className` says. */
42
43
  size?: keyof typeof SIZE;
44
+
43
45
  /* Always over another dialog. A dialog that opens while another one is up stacks by itself. */
44
46
  nested?: boolean;
45
- /* The dim behind the dialog. Off for a dialog that leaves what is under it as it is. */
47
+
46
48
  backdrop?: boolean;
47
- /* Extra on the backdrop, such as the heavier dim a picture needs. */
48
49
  backdropClassName?: string;
49
- /* Keeps the popup in the document while it is closed. */
50
50
  keepMounted?: boolean;
51
51
  };
52
52
 
53
- /*
54
- * The portal, the backdrop and the popup of a dialog in one part. A dialog that opens while another
55
- * one is up, such as one from the menu bar over a settings dialog, takes the nested steps and a
56
- * backdrop of its own, or the one underneath stays on top and keeps taking clicks.
57
- */
53
+ /* The portal, the backdrop and the popup of a dialog in one part; `useDialogLayer` says whether it stacks. */
58
54
  export function DialogPopup({ size, nested = false, backdrop = true, backdropClassName, keepMounted, className, ...props }: DialogPopupProps) {
59
55
  const stacked = useDialogLayer(useContext(DialogOpenContext)) || nested;
56
+
60
57
  return (
61
58
  <BaseDialog.Portal keepMounted={keepMounted}>
62
59
  {backdrop && (
@@ -85,7 +82,7 @@ export type DialogDescriptionProps = ComponentProps<typeof BaseDialog.Descriptio
85
82
  size?: keyof typeof DESCRIPTION;
86
83
  };
87
84
 
88
- /* The sentence under the title, which is also what a screen reader reads as the dialog's description. It carries no margin. */
85
+ /* What a screen reader reads as the dialog's description. It carries no margin. */
89
86
  export function DialogDescription({ size = 'sm', className, ...props }: DialogDescriptionProps) {
90
87
  return <BaseDialog.Description className={withClass(DESCRIPTION[size], className)} {...props} />;
91
88
  }
@@ -94,14 +91,14 @@ export type DialogTextProps = useRender.ComponentProps<'p'> & {
94
91
  size?: keyof typeof DESCRIPTION;
95
92
  };
96
93
 
97
- /* A further line in the style of the description that is not the dialog's description: a warning, a note under a choice. */
94
+ /* Styled as the description without being the dialog's description. */
98
95
  export function DialogText({ size = 'sm', render, className, ref, ...props }: DialogTextProps) {
99
96
  return useRender({ render, ref, defaultTagName: 'p', props: { ...props, className: clsx(DESCRIPTION[size], className) } });
100
97
  }
101
98
 
102
99
  export type DialogFooterProps = useRender.ComponentProps<'div'>;
103
100
 
104
- /* The buttons at the foot of a dialog, the main action last and so on the right. */
101
+ /* The main action goes last, and so on the right. */
105
102
  export function DialogFooter({ render, className, ref, ...props }: DialogFooterProps) {
106
103
  return useRender({
107
104
  render,
@@ -4,14 +4,14 @@ import { useLayoutEffect, useRef, useState } from 'react';
4
4
  const open = new Set<symbol>();
5
5
 
6
6
  /*
7
- * Whether a dialog opened while another app-level dialog was already up, such as one opened from the
8
- * menu bar over a settings dialog. Such a dialog takes the nested steps (`dialog-backdrop-nested`,
9
- * `dialog-popup-nested`, a forced backdrop), or the one underneath stays on top and keeps taking clicks.
7
+ * Whether a dialog opened over another app-level dialog. Such a dialog takes the nested steps
8
+ * (`dialog-backdrop-nested`, `dialog-popup-nested`, a forced backdrop), or the one underneath stays on top and keeps taking clicks.
10
9
  */
11
10
  export function useDialogLayer(isOpen: boolean): boolean {
12
11
  const id = useRef(Symbol('dialog'));
13
12
  // Decided in render at the moment the dialog opens, so its first frame is already on the right step.
14
13
  const [seen, setSeen] = useState({ open: false, stacked: false });
14
+
15
15
  if (seen.open !== isOpen) {
16
16
  setSeen({ open: isOpen, stacked: isOpen && open.size > 0 });
17
17
  }
@@ -20,8 +20,10 @@ export function useDialogLayer(isOpen: boolean): boolean {
20
20
  if (!isOpen) {
21
21
  return;
22
22
  }
23
+
23
24
  const self = id.current;
24
25
  open.add(self);
26
+
25
27
  return () => {
26
28
  open.delete(self);
27
29
  };
@@ -1,40 +1,27 @@
1
- /*
2
- * The pure half of `ErrorBoundary`: when a failed subtree gets another try, and what a person copies
3
- * out of the message. Kept apart so it can be tested without a DOM.
4
- */
1
+ /* The pure half of `ErrorBoundary`, kept apart so it can be tested without a DOM. */
5
2
 
6
3
  /* Anything the subtree draws from. A change in any of them means the data may be whole again. */
7
4
  export type ResetKeys = readonly unknown[];
8
5
 
9
- /*
10
- * Whether a boundary that holds an error should draw its children again. Only a boundary that
11
- * failed resets. Comparing keys while the children are fine would remount a terminal or an embedded
12
- * page every time the data under it changes.
13
- */
6
+ /* Only a failed boundary resets: comparing keys while the children are fine would remount a terminal or an embedded page on every change. */
14
7
  export const shouldReset = (failed: boolean, previous: ResetKeys, next: ResetKeys): boolean => {
15
8
  if (!failed) {
16
9
  return false;
17
10
  }
11
+
18
12
  if (previous.length !== next.length) {
19
13
  return true;
20
14
  }
15
+
21
16
  return previous.some((key, i) => !Object.is(key, next[i]));
22
17
  };
23
18
 
24
19
  /* A thrown value is not always an Error, and the message has to say something either way. */
25
- export const errorMessageOf = (error: unknown): string => {
26
- if (error instanceof Error) {
27
- return error.message || error.name;
28
- }
29
- return typeof error === 'string' ? error : String(error);
30
- };
20
+ export const errorMessageOf = (error: unknown): string => (error instanceof Error ? error.message || error.name : String(error));
31
21
 
32
22
  /* The text behind the copy button: what a bug report needs, the component stack included. */
33
23
  export const errorReport = (label: string, error: unknown, componentStack: string | null): string => {
34
24
  const stack = error instanceof Error && error.stack ? error.stack : errorMessageOf(error);
35
- const parts = [label, stack];
36
- if (componentStack) {
37
- parts.push(`Component stack:${componentStack}`);
38
- }
39
- return parts.join('\n\n');
25
+
26
+ return [label, stack, ...(componentStack ? [`Component stack:${componentStack}`] : [])].join('\n\n');
40
27
  };
package/src/file-icon.ts CHANGED
@@ -3,10 +3,8 @@ import { SETI_BY_FILE_EXTENSION, SETI_BY_FILE_NAME, SETI_BY_FILE_NAME_CONTAINS,
3
3
 
4
4
  const SPRITE_ELEMENT_ID = 'adecore-ui-file-icon-sprite';
5
5
 
6
- /* Hand this to the tree. The glyphs are Seti's (`seti-icons.ts`), which knows far more file types than
7
- the tree's own sets. The tree colors only those, so every symbol carries its hue in its id and the
8
- sprite brings the rules that color it. Everything drawn beside a tree calls `fileIconFor`, so a tab,
9
- a picker row and a tree row never disagree about what a file is. */
6
+ /* Seti knows far more file types than the tree's own sets. The tree colors only those, so every symbol
7
+ carries its hue in its id and the sprite brings the rules that color it. */
10
8
  export const FILE_TREE_ICONS: FileTreeIconConfig = {
11
9
  set: 'none',
12
10
  spriteSheet: SETI_SPRITE_SHEET,
@@ -21,15 +19,13 @@ let resolver: ReturnType<typeof createFileTreeIconResolver> | null = null;
21
19
  /* Built on the first icon rather than on import, so a bundle that never draws one never builds it. */
22
20
  const resolveIcon: ReturnType<typeof createFileTreeIconResolver>['resolveIcon'] = (...args) => {
23
21
  resolver ??= createFileTreeIconResolver(FILE_TREE_ICONS);
22
+
24
23
  return resolver.resolveIcon(...args);
25
24
  };
26
25
 
27
26
  /*
28
- * The id of the `<symbol>` the set gives a file, in the sprite `mountFileIconSprite` puts in the
29
- * document. Only the last segment of the path decides, first by exact name (`package.json`), then by
30
- * a part of the name (`Dockerfile.dev`), then by the longest extension that matches (`spec.ts` before
31
- * `ts`). Anything else falls back to the generic file icon, a directory included: the tree marks a
32
- * folder with its chevron, not a glyph.
27
+ * The id of a `<symbol>` in the sprite `mountFileIconSprite` puts in the document. A directory gets the
28
+ * generic file icon: the tree marks a folder with its chevron, not a glyph.
33
29
  */
34
30
  export const fileIconFor = (path: string): string => resolveIcon('file-tree-icon-file', path).name;
35
31
 
@@ -39,12 +35,15 @@ export const mountFileIconSprite = (): void => {
39
35
  if (document.getElementById(SPRITE_ELEMENT_ID) !== null) {
40
36
  return;
41
37
  }
38
+
42
39
  const holder = document.createElement('div');
43
40
  holder.innerHTML = SETI_SPRITE_SHEET;
44
41
  const sprite = holder.firstElementChild;
42
+
45
43
  if (sprite === null) {
46
44
  return;
47
45
  }
46
+
48
47
  sprite.id = SPRITE_ELEMENT_ID;
49
48
  (document.body ?? document.documentElement).append(sprite);
50
49
  };
@@ -21,10 +21,7 @@ export function directoryOfEmptyLine(path: string): string {
21
21
  return path.slice(0, -EMPTY_LINE_NAME.length);
22
22
  }
23
23
 
24
- /*
25
- * The lines to add and the ones to take away: an open directory whose label is not undefined and
26
- * that holds nothing besides its own line gets one, every other line the wrapper added goes.
27
- */
24
+ /* An open, labeled directory that holds nothing besides its own line gets one; every other line the wrapper added goes. */
28
25
  export function emptyLineChanges(
29
26
  rows: readonly FileTreeVisibleRow[],
30
27
  labelOf: FileTreeEmptyLabel,
@@ -32,74 +29,93 @@ export function emptyLineChanges(
32
29
  added: Iterable<string>
33
30
  ): { add: string[]; remove: string[] } {
34
31
  const wanted = new Set<string>();
32
+
35
33
  rows.forEach((row, index) => {
36
34
  if (row.kind !== 'directory' || !row.isExpanded) {
37
35
  return;
38
36
  }
37
+
39
38
  const line = emptyLineOf(row.path);
39
+
40
40
  for (let next = index + 1; next < rows.length && rows[next]!.depth > row.depth; next++) {
41
41
  if (rows[next]!.path !== line) {
42
42
  return;
43
43
  }
44
44
  }
45
+
45
46
  if (labelOf(row) !== undefined) {
46
47
  wanted.add(line);
47
48
  }
48
49
  });
50
+
49
51
  return {
50
52
  add: [...wanted].filter((line) => !has(line)),
51
53
  remove: [...added].filter((line) => !wanted.has(line) && has(line))
52
54
  };
53
55
  }
54
56
 
55
- /*
56
- * Where an arrow, Home or End goes instead when the row it would land on is a line: past it, or
57
- * nowhere (the focused path itself) where nothing lies past it. Null when no line is in the way.
58
- */
57
+ /* Past the line the key would land on, or the focused path itself where nothing lies past it. Null when no line is in the way. */
59
58
  export function pastEmptyLine(model: FileTree, key: string): string | null {
60
59
  const index = model.getFocusedIndex();
61
60
  const focused = model.getFocusedPath();
61
+
62
62
  if (index < 0 || focused === null) {
63
63
  return null;
64
64
  }
65
+
65
66
  const count = model.getVisibleCount();
67
+
66
68
  const at = (position: number): FileTreeVisibleRow | null =>
67
69
  position >= 0 && position < count ? (model.getVisibleRows(position, position)[0] ?? null) : null;
70
+
68
71
  const lineAt = (position: number): boolean => {
69
72
  const row = at(position);
73
+
70
74
  return row !== null && isEmptyLine(row.path);
71
75
  };
76
+
72
77
  const pathAt = (position: number): string => {
73
78
  const row = at(position);
79
+
74
80
  return row === null ? focused : pathOfRow(row);
75
81
  };
82
+
76
83
  if ((key === 'ArrowDown' || key === 'ArrowRight') && lineAt(index + 1)) {
77
84
  return key === 'ArrowDown' ? pathAt(index + 2) : focused;
78
85
  }
86
+
79
87
  if (key === 'ArrowUp' && lineAt(index - 1)) {
80
88
  return pathAt(index - 2);
81
89
  }
90
+
82
91
  if (key === 'End' && lineAt(count - 1)) {
83
92
  return pathAt(count - 2);
84
93
  }
94
+
85
95
  return null;
86
96
  }
87
97
 
88
98
  /* Focus that still lands on a line, through a key with a modifier or the engine giving focus back to the line's element, goes on the way the last key went. */
89
99
  export function skipEmptyLine(model: FileTree, key: string): boolean {
90
100
  const path = model.getFocusedPath();
101
+
91
102
  if (path === null || !isEmptyLine(path)) {
92
103
  return false;
93
104
  }
105
+
94
106
  if (key === 'ArrowUp' || key === 'PageUp') {
95
107
  model.focusPreviousItem();
108
+
96
109
  return true;
97
110
  }
111
+
98
112
  if (key === 'ArrowDown' || key === 'PageDown' || key === 'End') {
99
113
  model.focusNextItem();
100
114
  }
115
+
101
116
  if (model.getFocusedPath() === path) {
102
117
  model.focusPath(directoryOfEmptyLine(path));
103
118
  }
119
+
104
120
  return true;
105
121
  }
@@ -11,6 +11,7 @@ export interface SortRow {
11
11
 
12
12
  export function directoryHandle(model: FileTree, path: string): FileTreeDirectoryHandle | null {
13
13
  const item = model.getItem(path);
14
+
14
15
  return item?.isDirectory() ? (item as FileTreeDirectoryHandle) : null;
15
16
  }
16
17
 
@@ -26,21 +27,26 @@ export function visibleRows(model: FileTree): FileTreeVisibleRow[] {
26
27
  export function visibleRowsAround(model: FileTree, start: number, count: number): FileTreeVisibleRow[] {
27
28
  const first = Math.max(0, start - 1);
28
29
  const last = Math.min(model.getVisibleCount() - 1, start + count);
30
+
29
31
  if (last < first) {
30
32
  return [];
31
33
  }
34
+
32
35
  return model.getVisibleRows(first, last).map((row) => ({ ...row, path: pathOfRow(row) }));
33
36
  }
34
37
 
35
38
  export function applyExpansion(model: FileTree, collapsed: ReadonlySet<string>, keyOf?: FoldKeyOf): void {
36
39
  for (let pass = 0; pass < EXPANSION_PASSES; pass++) {
37
40
  const { collapse, expand } = expansionChanges(visibleRows(model), collapsed, keyOf);
41
+
38
42
  if (collapse.length === 0 && expand.length === 0) {
39
43
  return;
40
44
  }
45
+
41
46
  for (const path of collapse) {
42
47
  directoryHandle(model, path)?.collapse();
43
48
  }
49
+
44
50
  for (const path of expand) {
45
51
  directoryHandle(model, path)?.expand();
46
52
  }
@@ -49,6 +55,7 @@ export function applyExpansion(model: FileTree, collapsed: ReadonlySet<string>,
49
55
 
50
56
  export function resetExpandedPaths(model: FileTree, paths: readonly string[], expanded: ReadonlySet<string>): void {
51
57
  model.resetPaths(paths, { initialExpandedPaths: [...expanded] });
58
+
52
59
  // @pierre/trees beta.6 restores expansion with its default sort, so custom sorting needs a second pass.
53
60
  for (const path of expanded) {
54
61
  directoryHandle(model, path)?.expand();
@@ -58,10 +65,12 @@ export function resetExpandedPaths(model: FileTree, paths: readonly string[], ex
58
65
  export function rowPathOf(event: { nativeEvent: Event }): string | null {
59
66
  for (const node of event.nativeEvent.composedPath()) {
60
67
  const path = node instanceof HTMLElement ? node.dataset.itemPath : undefined;
68
+
61
69
  if (path) {
62
70
  return path;
63
71
  }
64
72
  }
73
+
65
74
  return null;
66
75
  }
67
76
 
@@ -85,7 +94,9 @@ export function selectOnly(model: FileTree, path: string | null): void {
85
94
  model.getItem(selected)?.deselect();
86
95
  }
87
96
  }
97
+
88
98
  const item = path === null ? null : model.getItem(path);
99
+
89
100
  if (item && !item.isSelected()) {
90
101
  item.select();
91
102
  }
@@ -93,13 +104,16 @@ export function selectOnly(model: FileTree, path: string | null): void {
93
104
 
94
105
  export function followFocus(model: FileTree, onMoved?: (path: string) => void): () => void {
95
106
  const from = model.getFocusedPath();
107
+
96
108
  const timer = window.setTimeout(() => {
97
109
  const path = model.getFocusedPath();
98
110
  selectOnly(model, path);
111
+
99
112
  if (path !== null && path !== from) {
100
113
  onMoved?.(path);
101
114
  }
102
115
  }, 0);
116
+
103
117
  return () => window.clearTimeout(timer);
104
118
  }
105
119
 
@@ -107,6 +121,7 @@ export function focusRow(model: FileTree, path: string): boolean {
107
121
  const selector = `[data-type="item"][data-item-path="${CSS.escape(path)}"]:not([data-item-parked="true"])`;
108
122
  const row = model.getFileTreeContainer()?.shadowRoot?.querySelector<HTMLElement>(selector) ?? null;
109
123
  row?.focus();
124
+
110
125
  return row !== null;
111
126
  }
112
127
 
@@ -115,10 +130,12 @@ export function ancestorDirsOf(treePath: string): string[] {
115
130
  segments.pop();
116
131
  const dirs: string[] = [];
117
132
  let prefix = '';
133
+
118
134
  for (const segment of segments) {
119
135
  prefix = `${prefix}${segment}/`;
120
136
  dirs.push(prefix);
121
137
  }
138
+
122
139
  return dirs;
123
140
  }
124
141
 
@@ -132,29 +149,37 @@ export function newlyExpanded(before: ReadonlySet<string>, after: ReadonlySet<st
132
149
 
133
150
  export function mergeExpanded(remembered: ReadonlySet<string>, reported: ReadonlySet<string>, known: ReadonlySet<string>): Set<string> {
134
151
  const merged = new Set(reported);
152
+
135
153
  for (const path of remembered) {
136
154
  if (!known.has(path)) {
137
155
  merged.add(path);
138
156
  }
139
157
  }
158
+
140
159
  return merged;
141
160
  }
142
161
 
143
162
  export function compareRows(left: SortRow, right: SortRow): number {
144
163
  const shared = Math.min(left.segments.length, right.segments.length);
164
+
145
165
  for (let i = 0; i < shared; i++) {
146
166
  const leftSegment = left.segments[i]!;
147
167
  const rightSegment = right.segments[i]!;
168
+
148
169
  if (leftSegment === rightSegment) {
149
170
  continue;
150
171
  }
172
+
151
173
  const leftIsDirectory = left.isDirectory || i < left.segments.length - 1;
152
174
  const rightIsDirectory = right.isDirectory || i < right.segments.length - 1;
175
+
153
176
  if (leftIsDirectory !== rightIsDirectory) {
154
177
  return leftIsDirectory ? -1 : 1;
155
178
  }
179
+
156
180
  return nameOrder(leftSegment, rightSegment);
157
181
  }
182
+
158
183
  // One path is the head of the other, so the shorter one is the directory the longer one sits in.
159
184
  return left.segments.length - right.segments.length;
160
185
  }
@@ -172,6 +197,7 @@ function plainKey(rowPath: string): string | null {
172
197
  export function collapsedPathsOf(rows: readonly FileTreeVisibleRow[], keyOf: FoldKeyOf = plainKey): string[] {
173
198
  return rows.flatMap((row) => {
174
199
  const key = row.kind === 'directory' && !row.isExpanded ? keyOf(row.path) : null;
200
+
175
201
  return key === null ? [] : [key];
176
202
  });
177
203
  }
@@ -181,11 +207,13 @@ export function mergeCollapsedPaths(current: string[], rows: readonly FileTreeVi
181
207
  const folded = new Set(collapsedPathsOf(rows, keyOf));
182
208
  const next = current.filter((dir) => !known.has(dir) || folded.has(dir));
183
209
  const existing = new Set(current);
210
+
184
211
  for (const dir of folded) {
185
212
  if (!existing.has(dir)) {
186
213
  next.push(dir);
187
214
  }
188
215
  }
216
+
189
217
  // Selection also notifies subscribers; unchanged folds must not restart the shared-state cycle.
190
218
  return next.length === current.length && next.every((dir, index) => dir === current[index]) ? current : next;
191
219
  }
@@ -196,18 +224,24 @@ export function expansionChanges(
196
224
  keyOf: FoldKeyOf = plainKey
197
225
  ): { collapse: string[]; expand: string[] } {
198
226
  const changes: { collapse: string[]; expand: string[] } = { collapse: [], expand: [] };
227
+
199
228
  for (const row of rows) {
200
229
  const key = row.kind === 'directory' ? keyOf(row.path) : null;
230
+
201
231
  if (key === null) {
202
232
  continue;
203
233
  }
234
+
204
235
  const folded = collapsed.has(key);
236
+
205
237
  if (folded && row.isExpanded) {
206
238
  changes.collapse.push(row.path);
207
239
  }
240
+
208
241
  if (!folded && !row.isExpanded) {
209
242
  changes.expand.push(row.path);
210
243
  }
211
244
  }
245
+
212
246
  return changes;
213
247
  }