@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
package/src/locales.ts CHANGED
@@ -3,16 +3,12 @@ import type { i18n as I18n } from 'i18next';
3
3
  import en from './locales/en.ts';
4
4
  import nl from './locales/nl.ts';
5
5
 
6
- /* The words of this library live in a namespace of their own, which the app adds to its i18next beside its own namespaces. */
7
6
  export const UI_NAMESPACE = 'ui';
8
7
 
9
- /*
10
- * Every language the library speaks, by language code. Small enough to ship whole, which is what lets
11
- * `addUiResources` run synchronously before the first render, so no word ever shows as its key.
12
- */
8
+ /* Small enough to ship whole, so `addUiResources` runs before the first render and no word shows as its key. */
13
9
  export const UI_RESOURCES: Readonly<Record<string, Record<string, unknown>>> = { en, nl };
14
10
 
15
- /* Adds the `ui` namespace in every language to an i18next instance. A language the app already filled keeps its words. */
11
+ /* A language the app already filled keeps its words. */
16
12
  export const addUiResources = (i18n: I18n): void => {
17
13
  for (const [language, words] of Object.entries(UI_RESOURCES)) {
18
14
  if (!i18n.hasResourceBundle(language, UI_NAMESPACE)) {
package/src/match.ts CHANGED
@@ -3,24 +3,31 @@ const WORD_START = /[\s\-_./:]/;
3
3
  /* Whether `query` starts the text or one of its words. */
4
4
  const startsWord = (text: string, query: string): boolean => {
5
5
  let at = text.indexOf(query);
6
+
6
7
  while (at !== -1) {
7
8
  if (at === 0 || WORD_START.test(text[at - 1]!)) {
8
9
  return true;
9
10
  }
11
+
10
12
  at = text.indexOf(query, at + 1);
11
13
  }
14
+
12
15
  return false;
13
16
  };
14
17
 
15
18
  const lettersInOrder = (text: string, query: string): boolean => {
16
19
  let at = 0;
20
+
17
21
  for (const letter of query.replace(/\s+/g, '')) {
18
22
  at = text.indexOf(letter, at);
23
+
19
24
  if (at === -1) {
20
25
  return false;
21
26
  }
27
+
22
28
  at += 1;
23
29
  }
30
+
24
31
  return true;
25
32
  };
26
33
 
@@ -32,19 +39,26 @@ const lettersInOrder = (text: string, query: string): boolean => {
32
39
  */
33
40
  export function matchScore(text: string, query: string): number | null {
34
41
  const wanted = query.trim().toLowerCase();
42
+
35
43
  if (wanted === '') {
36
44
  return 0;
37
45
  }
46
+
38
47
  const haystack = text.toLowerCase();
48
+
39
49
  if (haystack.startsWith(wanted)) {
40
50
  return 5;
41
51
  }
52
+
42
53
  if (startsWord(haystack, wanted)) {
43
54
  return 4;
44
55
  }
56
+
45
57
  const words = wanted.split(/\s+/);
58
+
46
59
  if (words.every((word) => haystack.includes(word))) {
47
60
  return haystack.startsWith(words[0]!) ? 3 : 2;
48
61
  }
62
+
49
63
  return lettersInOrder(haystack, wanted) ? 1 : null;
50
64
  }
@@ -20,25 +20,20 @@ type PositionerProps = ComponentProps<typeof BaseMenu.Positioner>;
20
20
 
21
21
  type Placement = Pick<PositionerProps, PlacementKey>;
22
22
 
23
- /*
24
- * A menu hangs under its trigger, lined up with its start, unless there is no room. A context menu
25
- * opens at the pointer, which its positioner already knows. A submenu sits beside its row, pulled
26
- * up by the popup's own padding so its first row lines up with the row that opened it.
27
- */
23
+ /* A context menu opens at the pointer, which its positioner already knows. A submenu is pulled up by the popup's padding so its first row lines up with the row that opened it. */
28
24
  const PLACEMENT: Record<MenuKind, Placement> = {
29
25
  menu: { side: 'bottom', align: 'start', sideOffset: 6 },
30
26
  context: {},
31
27
  submenu: { sideOffset: 4, alignOffset: -4 }
32
28
  };
33
29
 
34
- /* The label above a group of rows. It takes the line height of the row it sits in. */
30
+ /* It takes the line height of the row it sits in. */
35
31
  export const MENU_LABEL = 'px-2.5 pt-1.5 pb-0.5 text-xs/[inherit] text-text-faint';
36
32
 
37
33
  /* A trailing hint in a row: what the item does to something else, never a shortcut. A shortcut stays a `Kbd`, which `.menu-item` already pushes to the right. */
38
34
  const MENU_HINT = 'ml-auto pl-3 text-xs/[inherit] text-text-faint';
39
35
 
40
- /* The hairline between two groups of rows. It runs the whole width of the popup, which is what the
41
- negative margin buys back from its 4px of padding, and it is softer than a border a surface ends with. */
36
+ /* The negative margin buys back the popup's 4px of padding, so the line runs its whole width. */
42
37
  const MENU_SEPARATOR = '-mx-1 my-1 h-px bg-border-soft';
43
38
 
44
39
  export function MenuRoot(props: ComponentProps<typeof BaseMenu.Root>) {
@@ -71,14 +66,10 @@ export const ContextMenuTrigger = BaseContextMenu.Trigger;
71
66
 
72
67
  export type MenuPopupProps = ComponentProps<typeof BaseMenu.Popup> & Placement;
73
68
 
74
- /*
75
- * The shell every menu is drawn in: the portal, the layer it sits on and the gap to its trigger are
76
- * decided once. The popup scrolls inside the room the window leaves it; `className` adds a minimum
77
- * width or a layout of its own, such as a grid of swatches.
78
- */
79
69
  export function MenuPopup({ className, ...props }: MenuPopupProps) {
80
70
  const defaults = PLACEMENT[useContext(MenuKindContext)];
81
71
  const [placement, popupProps] = splitPlacement(props);
72
+
82
73
  return (
83
74
  <BaseMenu.Portal>
84
75
  <BaseMenu.Positioner
@@ -97,11 +88,7 @@ export function MenuPopup({ className, ...props }: MenuPopupProps) {
97
88
  }
98
89
 
99
90
  type ItemLook = {
100
- /*
101
- * Leaves the row out: no padding, highlight or indicator, only what makes it an item (the arrow
102
- * keys, typeahead, closing the menu). For an icon button, a swatch, a link or a segment inside a
103
- * menu, which draws itself and its own highlight.
104
- */
91
+ /* Keeps only the item's behavior, for a swatch, a link or a segment that draws itself and its own highlight. */
105
92
  unstyled?: boolean;
106
93
  };
107
94
 
@@ -114,19 +101,18 @@ export function MenuItem({ unstyled = false, className, ...props }: MenuItemProp
114
101
  export interface MenuCheckProps {
115
102
  /* One of several is a bare tick; on or off is a tick in an outlined box, which says the row can be off. */
116
103
  kind: 'radio' | 'checkbox';
104
+
117
105
  /* Only for a row that is not a radio or checkbox item, and so has no indicator of its own. */
118
106
  checked?: boolean;
107
+
119
108
  className?: string;
120
109
  ref?: Ref<HTMLSpanElement>;
121
110
  }
122
111
 
123
- /*
124
- * The slot before the label of a row that can be checked, in a menu or a list that reads as one. It
125
- * is one line of the row tall, so a row with a second line keeps the tick beside its first. Radio and
126
- * checkbox items draw it themselves; a plain row that shows a state hands it `checked`.
127
- */
112
+ /* One line of the row tall, so a row with a second line keeps the tick beside its first. */
128
113
  export function MenuCheck({ kind, checked, className, ref }: MenuCheckProps) {
129
114
  const tick = <Icon icon={Check} size={kind === 'radio' ? 14 : 12} />;
115
+
130
116
  const indicator =
131
117
  checked !== undefined ? (
132
118
  checked && tick
@@ -135,6 +121,7 @@ export function MenuCheck({ kind, checked, className, ref }: MenuCheckProps) {
135
121
  ) : (
136
122
  <BaseMenu.CheckboxItemIndicator className="flex">{tick}</BaseMenu.CheckboxItemIndicator>
137
123
  );
124
+
138
125
  return (
139
126
  <span ref={ref} className={clsx('grid h-lh w-4 shrink-0 place-items-center', className)}>
140
127
  {kind === 'checkbox' ? <span className="grid h-4 w-4 place-items-center rounded border border-border-strong">{indicator}</span> : indicator}
@@ -149,20 +136,21 @@ type Indicator = {
149
136
 
150
137
  type MenuCheckboxItemProps = ComponentProps<typeof BaseMenu.CheckboxItem> & ItemLook & Indicator & { children?: ReactNode };
151
138
 
152
- /* A row that is on or off. The box before its label is part of it. */
139
+ const checkRow = (kind: MenuCheckProps['kind'], unstyled: boolean, indicator: Indicator['indicator'], children: ReactNode) =>
140
+ unstyled ? (
141
+ children
142
+ ) : (
143
+ <>
144
+ {indicator === 'start' && <MenuCheck kind={kind} />}
145
+ {children}
146
+ {indicator === 'end' && <MenuCheck kind={kind} className="ml-auto" />}
147
+ </>
148
+ );
149
+
153
150
  export function MenuCheckboxItem({ unstyled = false, indicator = 'start', className, children, ...props }: MenuCheckboxItemProps) {
154
- if (unstyled) {
155
- return (
156
- <BaseMenu.CheckboxItem className={className} {...props}>
157
- {children}
158
- </BaseMenu.CheckboxItem>
159
- );
160
- }
161
151
  return (
162
- <BaseMenu.CheckboxItem className={withClass('menu-item', className)} {...props}>
163
- {indicator === 'start' && <MenuCheck kind="checkbox" />}
164
- {children}
165
- {indicator === 'end' && <MenuCheck kind="checkbox" className="ml-auto" />}
152
+ <BaseMenu.CheckboxItem className={unstyled ? className : withClass('menu-item', className)} {...props}>
153
+ {checkRow('checkbox', unstyled, indicator, children)}
166
154
  </BaseMenu.CheckboxItem>
167
155
  );
168
156
  }
@@ -171,26 +159,17 @@ export const MenuRadioGroup = BaseMenu.RadioGroup;
171
159
 
172
160
  type MenuRadioItemProps = ComponentProps<typeof BaseMenu.RadioItem> & ItemLook & Indicator & { children?: ReactNode };
173
161
 
174
- /* One of several. The tick before its label is part of it. */
175
162
  export function MenuRadioItem({ unstyled = false, indicator = 'start', className, children, ...props }: MenuRadioItemProps) {
176
- if (unstyled) {
177
- return (
178
- <BaseMenu.RadioItem className={className} {...props}>
179
- {children}
180
- </BaseMenu.RadioItem>
181
- );
182
- }
183
163
  return (
184
- <BaseMenu.RadioItem className={withClass('menu-item', className)} {...props}>
185
- {indicator === 'start' && <MenuCheck kind="radio" />}
186
- {children}
187
- {indicator === 'end' && <MenuCheck kind="radio" className="ml-auto" />}
164
+ <BaseMenu.RadioItem className={unstyled ? className : withClass('menu-item', className)} {...props}>
165
+ {checkRow('radio', unstyled, indicator, children)}
188
166
  </BaseMenu.RadioItem>
189
167
  );
190
168
  }
191
169
 
192
170
  type SubmenuTriggerProps = ComponentProps<typeof BaseMenu.SubmenuTrigger> & {
193
171
  children?: ReactNode;
172
+
194
173
  /* The chevron at the end of the row that says it opens more. Off for a row that carries a hint there instead. */
195
174
  chevron?: boolean;
196
175
  };
@@ -206,18 +185,17 @@ export function MenuSubmenuTrigger({ className, children, chevron = true, ...pro
206
185
 
207
186
  export type MenuRowProps = useRender.ComponentProps<'div'>;
208
187
 
209
- /*
210
- * A row with buttons at its end that reads as one row. Its first item is the row, every `RowAction`
211
- * and `RowSubmenuTrigger` after it an item of its own, so the arrow keys reach each.
212
- */
188
+ /* Its first item is the row, every `RowAction` and `RowSubmenuTrigger` after it an item of its own, so the arrow keys reach each. */
213
189
  export function MenuRow({ render, className, ref, ...props }: MenuRowProps) {
214
190
  return useRender({ render, ref, defaultTagName: 'div', props: { role: 'group', ...props, className: clsx('menu-row', className) } });
215
191
  }
216
192
 
217
193
  type RowButton = {
218
194
  icon: LucideIcon;
195
+
219
196
  /* The button's accessible name. Unlike the `label` of an item, typing never lands on the button. */
220
197
  label: string;
198
+
221
199
  /* Classes for the icon rather than the button, such as a fill. The size stays the row's. */
222
200
  iconClassName?: string;
223
201
  };
@@ -237,10 +215,7 @@ export function MenuRowAction({ icon, label, iconClassName, className, ...props
237
215
 
238
216
  export type MenuRowSubmenuTriggerProps = Omit<ComponentProps<typeof BaseMenu.SubmenuTrigger>, 'children' | 'label'> & RowButton;
239
217
 
240
- /*
241
- * A button in a `Menu.Row` that opens a submenu, inside a `Menu.SubmenuRoot`. It has no tooltip: the
242
- * submenu opens on the same hover and says what the button holds.
243
- */
218
+ /* No tooltip: the submenu opens on the same hover and says what the button holds. */
244
219
  export function MenuRowSubmenuTrigger({ icon, label, iconClassName, className, ...props }: MenuRowSubmenuTriggerProps) {
245
220
  return (
246
221
  <BaseMenu.SubmenuTrigger className={withClass('menu-item menu-row-action', className)} aria-label={label} {...props}>
@@ -1,41 +1,50 @@
1
1
  export interface MiddleCut {
2
2
  head: string;
3
+
3
4
  /* What the ellipsis stands for; still in the text, so a copy or a find reads it. */
4
5
  middle: string;
6
+
5
7
  tail: string;
6
8
  }
7
9
 
8
10
  export const MIDDLE_ELLIPSIS = '…';
9
11
 
10
12
  /*
11
- * The head and tail of `text` that keep the most of it around an ellipsis within `width`, or null
12
- * when the whole text fits. The tail gets the odd character, since the end of a path or a branch is
13
- * what tells two of them apart. Code points, never UTF-16 units, so a cut never halves a character.
13
+ * Null when the whole text fits. The tail gets the odd character, since the end of a path is what tells
14
+ * two of them apart. Code points, never UTF-16 units, so a cut never halves a character.
14
15
  */
15
16
  export function fitMiddle(text: string, width: number, measure: (text: string) => number): MiddleCut | null {
16
17
  if (measure(text) <= width) {
17
18
  return null;
18
19
  }
20
+
19
21
  const characters = Array.from(text);
22
+
20
23
  const cutAt = (kept: number): MiddleCut => {
21
24
  const headLength = Math.floor(kept / 2);
22
25
  const tailStart = characters.length - (kept - headLength);
26
+
23
27
  return {
24
28
  head: characters.slice(0, headLength).join(''),
25
29
  middle: characters.slice(headLength, tailStart).join(''),
26
30
  tail: characters.slice(tailStart).join('')
27
31
  };
28
32
  };
33
+
29
34
  const fits = (cut: MiddleCut): boolean => measure(cut.head + MIDDLE_ELLIPSIS + cut.tail) <= width;
35
+
30
36
  let low = 0;
31
37
  let high = characters.length - 1;
38
+
32
39
  while (low < high) {
33
40
  const kept = Math.ceil((low + high) / 2);
41
+
34
42
  if (fits(cutAt(kept))) {
35
43
  low = kept;
36
44
  } else {
37
45
  high = kept - 1;
38
46
  }
39
47
  }
48
+
40
49
  return cutAt(low);
41
50
  }
package/src/modality.ts CHANGED
@@ -7,6 +7,7 @@ const set = (modality: Modality): void => {
7
7
  if (current === modality) {
8
8
  return;
9
9
  }
10
+
10
11
  current = modality;
11
12
  document.documentElement.dataset.modality = modality;
12
13
  };
@@ -19,8 +20,10 @@ export function startInputModality(): void {
19
20
  if (started) {
20
21
  return;
21
22
  }
23
+
22
24
  started = true;
23
25
  set('pointer');
26
+
24
27
  window.addEventListener(
25
28
  'keydown',
26
29
  (event) => {
@@ -31,6 +34,7 @@ export function startInputModality(): void {
31
34
  },
32
35
  { capture: true }
33
36
  );
37
+
34
38
  window.addEventListener('pointerdown', () => set('pointer'), { capture: true, passive: true });
35
39
  window.addEventListener('pointermove', () => set('pointer'), { capture: true, passive: true });
36
40
  }
package/src/placement.ts CHANGED
@@ -14,13 +14,14 @@ const PLACEMENT_KEYS = [
14
14
 
15
15
  export type PlacementKey = (typeof PLACEMENT_KEYS)[number];
16
16
 
17
- /* Splits a popup part's props into what goes to the positioner and what goes to the popup. */
18
17
  export function splitPlacement<Props extends Partial<Record<PlacementKey, unknown>>>(props: Props): [Pick<Props, PlacementKey>, Omit<Props, PlacementKey>] {
19
18
  const placement: Record<string, unknown> = {};
20
19
  const rest: Record<string, unknown> = { ...props };
20
+
21
21
  for (const key of PLACEMENT_KEYS) {
22
22
  placement[key] = props[key];
23
23
  delete rest[key];
24
24
  }
25
+
25
26
  return [placement as Pick<Props, PlacementKey>, rest as Omit<Props, PlacementKey>];
26
27
  }
package/src/platform.ts CHANGED
@@ -3,7 +3,9 @@ export const isApplePlatform = (): boolean => {
3
3
  if (typeof navigator === 'undefined') {
4
4
  return false;
5
5
  }
6
+
6
7
  const hints = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData;
8
+
7
9
  return applePlatformFrom(hints?.platform || navigator.platform);
8
10
  };
9
11
 
@@ -5,10 +5,7 @@ import { withClass } from '../class-name.ts';
5
5
  import { splitPlacement, type PlacementKey } from '../placement.ts';
6
6
  import { renderPositioner } from '../popup-layer.ts';
7
7
 
8
- /*
9
- * The surface a popup is drawn on. `menu` is the padded card menus use, `picker` a card whose
10
- * content runs to its edges (a search field on top of a list), and `plain` leaves the surface to the caller.
11
- */
8
+ /* `picker` is a card whose content runs to its edges, `plain` leaves the surface to the caller. */
12
9
  const POPUP_VARIANT = {
13
10
  menu: 'menu-popup',
14
11
  picker: 'picker-popup',
@@ -34,9 +31,9 @@ export type PopoverPopupProps = ComponentProps<typeof BasePopover.Popup> &
34
31
  variant?: PopupVariant;
35
32
  };
36
33
 
37
- /* The portal, the positioner and the popup of a popover: under its trigger and lined up with its start unless it says otherwise. */
38
34
  export function PopoverPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ...props }: PopoverPopupProps) {
39
35
  const [placement, popupProps] = splitPlacement(props);
36
+
40
37
  return (
41
38
  <BasePopover.Portal>
42
39
  <BasePopover.Positioner className="popup-positioner" render={renderPositioner} {...placement} side={side} align={align} sideOffset={sideOffset}>
@@ -58,6 +55,7 @@ export type PreviewCardPopupProps = ComponentProps<typeof BasePreviewCard.Popup>
58
55
  /* A card that opens while the pointer rests on its trigger, drawn like a popover. */
59
56
  export function PreviewCardPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ...props }: PreviewCardPopupProps) {
60
57
  const [placement, popupProps] = splitPlacement(props);
58
+
61
59
  return (
62
60
  <BasePreviewCard.Portal>
63
61
  <BasePreviewCard.Positioner className="z-(--z-popup)" {...placement} side={side} align={align} sideOffset={sideOffset}>
@@ -4,20 +4,22 @@ import { createElement, useLayoutEffect, type ComponentProps } from 'react';
4
4
  const open = new Set<symbol>();
5
5
 
6
6
  /*
7
- * Marks the root with `data-popup-open` while a popup is open, which the theme reads to turn the
8
- * window's drag regions off. An attribute on the root instead of `:root:has(...)`, which made the
9
- * browser restyle the whole page on every DOM insertion.
7
+ * The theme reads `data-popup-open` to turn the window's drag regions off. An attribute instead of
8
+ * `:root:has(...)`, which made the browser restyle the whole page on every DOM insertion.
10
9
  */
11
10
  function usePopupLayer(isOpen: boolean): void {
12
11
  useLayoutEffect(() => {
13
12
  if (!isOpen) {
14
13
  return;
15
14
  }
15
+
16
16
  const self = Symbol('popup');
17
17
  open.add(self);
18
18
  document.documentElement.setAttribute('data-popup-open', '');
19
+
19
20
  return () => {
20
21
  open.delete(self);
22
+
21
23
  if (open.size === 0) {
22
24
  document.documentElement.removeAttribute('data-popup-open');
23
25
  }
@@ -27,8 +29,8 @@ function usePopupLayer(isOpen: boolean): void {
27
29
 
28
30
  function LayerPositioner({ open: isOpen, ...props }: ComponentProps<'div'> & { readonly open: boolean }) {
29
31
  usePopupLayer(isOpen);
32
+
30
33
  return createElement('div', props);
31
34
  }
32
35
 
33
- /* The `render` of a popup's positioner, which knows whether the popup is open and so marks the root for as long as it is. */
34
36
  export const renderPositioner = (props: ComponentProps<'div'>, state: { open: boolean }) => createElement(LayerPositioner, { ...props, open: state.open });
package/src/prefetch.ts CHANGED
@@ -4,9 +4,8 @@ export type Loader = () => Promise<unknown>;
4
4
  export type WhenIdle = (run: () => void) => void;
5
5
 
6
6
  /*
7
- * Loads lazy modules ahead of their first opening, one per idle moment and never two at once, so
8
- * the first open resolves from the module cache. A loader registered while everything is on
9
- * joins the end, which is how a lazy surface inside another lazy one gets its turn.
7
+ * One module per idle moment and never two at once. A loader registered while everything is on joins
8
+ * the end, which is how a lazy surface inside another lazy one gets its turn.
10
9
  */
11
10
  export class Prefetcher {
12
11
  private readonly registered: Loader[] = [];
@@ -16,6 +15,7 @@ export class Prefetcher {
16
15
  private running: Promise<void> | null = null;
17
16
  private loading = false;
18
17
  private readonly whenIdle: WhenIdle;
18
+
19
19
  /* Asked on every ask and again before every load, since a person can turn data saving on while the page runs. */
20
20
  private readonly allowed: () => boolean;
21
21
 
@@ -31,17 +31,19 @@ export class Prefetcher {
31
31
 
32
32
  register(load: Loader): void {
33
33
  this.registered.push(load);
34
+
34
35
  if (this.everythingOn) {
35
36
  this.enqueue(load);
36
37
  }
37
38
  }
38
39
 
39
- /* Loads this one module, ahead of whatever is registered. */
40
40
  prefetch(load: Loader): Promise<void> {
41
41
  if (!this.allowed()) {
42
42
  return Promise.resolve();
43
43
  }
44
+
44
45
  this.enqueue(load);
46
+
45
47
  return this.drain();
46
48
  }
47
49
 
@@ -50,10 +52,13 @@ export class Prefetcher {
50
52
  if (!this.allowed()) {
51
53
  return Promise.resolve();
52
54
  }
55
+
53
56
  this.everythingOn = true;
57
+
54
58
  for (const load of this.registered) {
55
59
  this.enqueue(load);
56
60
  }
61
+
57
62
  return this.drain();
58
63
  }
59
64
 
@@ -61,6 +66,7 @@ export class Prefetcher {
61
66
  if (this.queued.has(load)) {
62
67
  return;
63
68
  }
69
+
64
70
  this.queued.add(load);
65
71
  this.queue.push(load);
66
72
  void this.drain();
@@ -71,6 +77,7 @@ export class Prefetcher {
71
77
  if (this.running === null && this.queue.length > 0) {
72
78
  this.running = this.loadQueue();
73
79
  }
80
+
74
81
  return this.running ?? Promise.resolve();
75
82
  }
76
83
 
@@ -78,12 +85,16 @@ export class Prefetcher {
78
85
  try {
79
86
  for (let load = this.queue.shift(); load; load = this.queue.shift()) {
80
87
  await new Promise<void>((resolve) => this.whenIdle(resolve));
88
+
81
89
  if (!this.allowed()) {
82
90
  // Data saving came on while it waited; a later ask may queue it again.
83
91
  this.queued.delete(load);
92
+
84
93
  continue;
85
94
  }
95
+
86
96
  this.loading = true;
97
+
87
98
  try {
88
99
  await load();
89
100
  } catch {
@@ -100,19 +111,21 @@ export class Prefetcher {
100
111
 
101
112
  /* Safari has no idle callback; the timeout at least lets the frame that asked finish first. */
102
113
  const FALLBACK_DELAY_MS = 50;
114
+
103
115
  /* A canvas that keeps drawing (a blinking cursor, a stream) may never leave an idle moment, and a prefetch that never runs helps nobody. */
104
116
  const IDLE_TIMEOUT_MS = 2000;
105
117
 
106
118
  const whenIdle: WhenIdle = (run) => {
107
119
  if (typeof requestIdleCallback === 'function') {
108
120
  requestIdleCallback(() => run(), { timeout: IDLE_TIMEOUT_MS });
121
+
109
122
  return;
110
123
  }
124
+
111
125
  setTimeout(run, FALLBACK_DELAY_MS);
112
126
  };
113
127
 
114
128
  /* Not in every DOM typing yet, and only Chromium reports it. */
115
129
  const savesData = (): boolean => (navigator as { connection?: { saveData?: boolean } }).connection?.saveData === true;
116
130
 
117
- /* The one every `lazyNamed` and `lazyDialog` registers with. */
118
131
  export const prefetcher = /* @__PURE__ */ new Prefetcher(whenIdle, () => !savesData());
@@ -15,6 +15,7 @@ export function ProjectSwitcherRow<Item extends ProjectSwitcherItem>({
15
15
  }) {
16
16
  const { t } = useTranslation('ui');
17
17
  const hasActions = Boolean(project.actions);
18
+
18
19
  const item = (
19
20
  <Menu.Item
20
21
  className={clsx('min-w-0', project.muted && 'opacity-50')}
@@ -27,6 +28,7 @@ export function ProjectSwitcherRow<Item extends ProjectSwitcherItem>({
27
28
  {project.hint && <span className="ml-auto truncate pl-3 text-xs text-text-faint">{project.hint}</span>}
28
29
  </Menu.Item>
29
30
  );
31
+
30
32
  const row = project.description ? (
31
33
  <Tooltip label={project.description} side="right" sideOffset={hasActions ? 41 : undefined}>
32
34
  {item}
@@ -5,6 +5,7 @@ const STEPS: Readonly<Record<string, number>> = { ArrowRight: 1, ArrowDown: 1, A
5
5
  // The index of the one tab stop: the picked option, or the first that can be picked when that one is missing or disabled.
6
6
  export function segmentedStop<T extends string>(options: readonly SegmentedOption<T>[], value: T): number {
7
7
  const picked = options.findIndex((option) => option.id === value);
8
+
8
9
  return picked !== -1 && !options[picked]!.disabled ? picked : options.findIndex((option) => !option.disabled);
9
10
  }
10
11
 
@@ -13,21 +14,26 @@ export function segmentedTarget(options: readonly SegmentedOption<string>[], ind
13
14
  const nearest = (from: number, step: number): number | null => {
14
15
  for (let offset = 0; offset < options.length; offset++) {
15
16
  const candidate = (((from + step * offset) % options.length) + options.length) % options.length;
17
+
16
18
  if (!options[candidate]!.disabled) {
17
19
  return candidate;
18
20
  }
19
21
  }
22
+
20
23
  return null;
21
24
  };
22
25
 
23
26
  if (key in STEPS) {
24
27
  return nearest(index + STEPS[key]!, STEPS[key]!);
25
28
  }
29
+
26
30
  if (key === 'Home') {
27
31
  return nearest(0, 1);
28
32
  }
33
+
29
34
  if (key === 'End') {
30
35
  return nearest(options.length - 1, -1);
31
36
  }
37
+
32
38
  return null;
33
39
  }
@@ -4,8 +4,10 @@ import clsx from 'clsx';
4
4
  export interface SelectItem<T extends string> {
5
5
  value: T;
6
6
  label: string;
7
+
7
8
  /* A second line under the label, for what the choice does. */
8
9
  description?: string;
10
+
9
11
  icon?: ReactNode;
10
12
  disabled?: boolean;
11
13
  }
package/src/selection.ts CHANGED
@@ -1,22 +1,22 @@
1
- /*
2
- * The text selection, as a context menu has to read it. The app has one selection at a time and a
3
- * menu belongs to the surface the click landed on, so both of these are scoped to an element.
4
- * A selection somewhere else is no selection this menu can copy.
5
- */
1
+ /* A menu belongs to the surface the click landed on, so both of these are scoped to an element: a selection elsewhere is none this menu can copy. */
6
2
 
7
3
  export const selectionWithin = (element: HTMLElement | null): string => {
8
4
  const selection = window.getSelection();
5
+
9
6
  if (!element || !selection || selection.isCollapsed || selection.rangeCount === 0) {
10
7
  return '';
11
8
  }
9
+
12
10
  return element.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.toString() : '';
13
11
  };
14
12
 
15
13
  export const selectAllWithin = (element: HTMLElement | null): void => {
16
14
  const selection = window.getSelection();
15
+
17
16
  if (!element || !selection) {
18
17
  return;
19
18
  }
19
+
20
20
  const range = document.createRange();
21
21
  range.selectNodeContents(element);
22
22
  selection.removeAllRanges();
@@ -7,10 +7,13 @@ export interface ConfirmDialogProps {
7
7
  title: string;
8
8
  description: string;
9
9
  confirmLabel: string;
10
+
10
11
  /* Closes the dialog once it resolves; a rejection stays on screen as the reason. */
11
12
  onConfirm(): Promise<void>;
13
+
12
14
  /* Extra on the popup. */
13
15
  className?: string;
16
+
14
17
  ref?: Ref<HTMLDivElement>;
15
18
  }
16
19