@adecore/ui 0.23.0 → 0.24.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 (129) hide show
  1. package/dist/ButtonGroup.d.ts +1 -1
  2. package/dist/ButtonGroup.js +1 -1
  3. package/dist/Checkbox.js +1 -1
  4. package/dist/ChoiceCards.js +2 -2
  5. package/dist/CloseButton.js +1 -1
  6. package/dist/ColorSwatch.d.ts +1 -1
  7. package/dist/ColorSwatch.js +1 -1
  8. package/dist/Field.d.ts +1 -1
  9. package/dist/Field.js +4 -3
  10. package/dist/IconButton.d.ts +1 -1
  11. package/dist/IconButton.js +1 -1
  12. package/dist/ListRow.d.ts +1 -1
  13. package/dist/ListRow.js +1 -1
  14. package/dist/MiddleTruncate.d.ts +8 -0
  15. package/dist/MiddleTruncate.js +59 -0
  16. package/dist/PanelHeader.d.ts +1 -1
  17. package/dist/PanelHeader.js +1 -1
  18. package/dist/SectionLabel.d.ts +1 -1
  19. package/dist/SectionLabel.js +1 -1
  20. package/dist/Select.js +1 -1
  21. package/dist/SelectSearch.js +1 -1
  22. package/dist/Slider.js +1 -1
  23. package/dist/Surface.d.ts +1 -1
  24. package/dist/Surface.js +1 -1
  25. package/dist/Switch.js +1 -1
  26. package/dist/Toasts.d.ts +1 -1
  27. package/dist/Toasts.js +4 -5
  28. package/dist/Tooltip.d.ts +1 -1
  29. package/dist/Tooltip.js +2 -2
  30. package/dist/clipboard.d.ts +1 -1
  31. package/dist/clipboard.js +12 -2
  32. package/dist/dialog/parts.d.ts +3 -3
  33. package/dist/dialog/parts.js +2 -2
  34. package/dist/file-tree/helpers.d.ts +1 -0
  35. package/dist/file-tree/helpers.js +9 -0
  36. package/dist/file-tree/parts.d.ts +1 -1
  37. package/dist/file-tree/parts.js +1 -1
  38. package/dist/file-tree/use-slots.d.ts +8 -0
  39. package/dist/file-tree/use-slots.js +64 -22
  40. package/dist/format/datetime.d.ts +1 -0
  41. package/dist/format/datetime.js +15 -0
  42. package/dist/format/index.d.ts +1 -1
  43. package/dist/format/index.js +1 -1
  44. package/dist/index.d.ts +6 -3
  45. package/dist/index.js +5 -1
  46. package/dist/key-value-list/parts.d.ts +1 -1
  47. package/dist/key-value-list/parts.js +1 -1
  48. package/dist/locales/en.d.ts +83 -0
  49. package/dist/locales/en.js +82 -0
  50. package/dist/locales/nl.d.ts +83 -0
  51. package/dist/locales/nl.js +82 -0
  52. package/dist/locales.js +3 -2
  53. package/dist/menu/parts.d.ts +7 -7
  54. package/dist/menu/parts.js +4 -4
  55. package/dist/middle-truncate.d.ts +7 -0
  56. package/dist/middle-truncate.js +34 -0
  57. package/dist/popover/parts.d.ts +7 -7
  58. package/dist/popover/parts.js +2 -2
  59. package/dist/prefetch.js +6 -1
  60. package/dist/settings/ConfirmDialog.d.ts +4 -1
  61. package/dist/settings/ConfirmDialog.js +2 -2
  62. package/dist/settings/SettingsDialog.js +1 -1
  63. package/dist/tabs/parts.d.ts +8 -4
  64. package/dist/tabs/parts.js +84 -18
  65. package/dist/testing/dedupe.js +74 -23
  66. package/dist/theme.css +24 -8
  67. package/dist/tree/parts.d.ts +1 -1
  68. package/dist/tree/parts.js +1 -1
  69. package/dist/workspace/DropPreview.d.ts +8 -0
  70. package/dist/workspace/DropPreview.js +14 -0
  71. package/dist/workspace/SplitView.d.ts +1 -1
  72. package/dist/workspace/SplitView.js +59 -18
  73. package/dist/workspace/TabStrip.d.ts +1 -1
  74. package/dist/workspace/TabStrip.js +1 -1
  75. package/dist/workspace/Workspace.d.ts +2 -2
  76. package/dist/workspace/Workspace.js +4 -2
  77. package/dist/workspace/preview.d.ts +4 -8
  78. package/dist/workspace/preview.js +17 -2
  79. package/package.json +2 -2
  80. package/src/ButtonGroup.tsx +1 -1
  81. package/src/Checkbox.tsx +1 -1
  82. package/src/ChoiceCards.tsx +2 -2
  83. package/src/CloseButton.tsx +1 -1
  84. package/src/ColorSwatch.tsx +1 -1
  85. package/src/ErrorBoundary.tsx +4 -4
  86. package/src/Field.tsx +4 -5
  87. package/src/IconButton.tsx +1 -1
  88. package/src/ListRow.tsx +1 -1
  89. package/src/MiddleTruncate.tsx +84 -0
  90. package/src/PanelHeader.tsx +1 -1
  91. package/src/SectionLabel.tsx +1 -1
  92. package/src/Select.tsx +1 -1
  93. package/src/SelectSearch.tsx +1 -1
  94. package/src/Slider.tsx +1 -1
  95. package/src/Surface.tsx +1 -1
  96. package/src/Switch.tsx +1 -1
  97. package/src/Toasts.tsx +5 -6
  98. package/src/Tooltip.tsx +5 -5
  99. package/src/clipboard.ts +11 -2
  100. package/src/dialog/parts.tsx +2 -2
  101. package/src/file-tree/helpers.ts +10 -0
  102. package/src/file-tree/parts.tsx +1 -1
  103. package/src/file-tree/use-slots.ts +68 -23
  104. package/src/format/datetime.ts +17 -0
  105. package/src/format/index.ts +1 -0
  106. package/src/index.ts +6 -3
  107. package/src/key-value-list/parts.tsx +1 -1
  108. package/src/locales/en.ts +82 -0
  109. package/src/locales/nl.ts +82 -0
  110. package/src/locales.ts +3 -2
  111. package/src/menu/parts.tsx +4 -4
  112. package/src/middle-truncate.ts +41 -0
  113. package/src/popover/parts.tsx +2 -2
  114. package/src/prefetch.ts +6 -1
  115. package/src/settings/ConfirmDialog.tsx +7 -1
  116. package/src/settings/SettingsDialog.tsx +1 -1
  117. package/src/tabs/parts.tsx +156 -54
  118. package/src/testing/dedupe.ts +87 -24
  119. package/src/theme.css +24 -8
  120. package/src/tree/parts.tsx +1 -1
  121. package/src/workspace/DropPreview.tsx +26 -0
  122. package/src/workspace/SplitView.tsx +58 -22
  123. package/src/workspace/TabStrip.tsx +1 -1
  124. package/src/workspace/Workspace.tsx +7 -3
  125. package/src/workspace/preview.ts +22 -9
  126. package/dist/locales/en.json +0 -82
  127. package/dist/locales/nl.json +0 -82
  128. package/src/locales/en.json +0 -82
  129. package/src/locales/nl.json +0 -82
@@ -3,7 +3,9 @@ import {
3
3
  createContext,
4
4
  isValidElement,
5
5
  useContext,
6
+ useEffect,
6
7
  useLayoutEffect,
8
+ useMemo,
7
9
  useRef,
8
10
  useState,
9
11
  type ComponentProps,
@@ -13,8 +15,8 @@ import {
13
15
  import clsx from 'clsx';
14
16
  import { ChevronDown, X } from 'lucide-react';
15
17
  import { useTranslation } from 'react-i18next';
16
- import { Tabs as BaseTabs } from '@base-ui-components/react/tabs';
17
- import { useRender } from '@base-ui-components/react/use-render';
18
+ import { Tabs as BaseTabs } from '@base-ui/react/tabs';
19
+ import { useRender } from '@base-ui/react/use-render';
18
20
  import { withClass } from '../class-name.ts';
19
21
  import { formatNumber } from '../format/number.ts';
20
22
  import { Icon } from '../Icon.tsx';
@@ -42,6 +44,21 @@ const TAB =
42
44
  /* The button that opens the tabs that do not fit reads as one more tab, at the very end of the strip. */
43
45
  const MORE = clsx(TAB, 'ml-auto data-[popup-open]:text-text');
44
46
 
47
+ /* A tab as a key in a bar, for tabs that share a toolbar with buttons and must not read as the tabs of a pane under it. */
48
+ const PILL =
49
+ 'relative flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-2 text-xs font-medium text-text-muted hover:bg-surface-hover hover:text-text data-[active]:bg-surface-active data-[active]:text-text data-[disabled]:opacity-50 has-[button]:pr-1';
50
+
51
+ const PILL_MORE = clsx(PILL, 'ml-auto data-[popup-open]:bg-surface-active data-[popup-open]:text-text');
52
+
53
+ export type TabsVariant = 'line' | 'pill';
54
+
55
+ const VariantContext = createContext<TabsVariant>('line');
56
+
57
+ const STYLES = {
58
+ line: { strip: 'gap-4 border-b border-border', list: 'items-end gap-4', row: 'gap-4', tab: TAB, more: MORE },
59
+ pill: { strip: 'gap-1', list: 'items-center gap-1', row: 'gap-1', tab: PILL, more: PILL_MORE }
60
+ } as const;
61
+
45
62
  export type TabsRootProps = Omit<ComponentProps<typeof BaseTabs.Root>, 'onValueChange'> & {
46
63
  onValueChange?(value: TabsValue): void;
47
64
  };
@@ -49,16 +66,25 @@ export type TabsRootProps = Omit<ComponentProps<typeof BaseTabs.Root>, 'onValueC
49
66
  /* Holds the picked tab itself, uncontrolled or not, so a tab picked from the menu of the strip changes it too. */
50
67
  export function TabsRoot({ value, defaultValue = 0, onValueChange, ...props }: TabsRootProps) {
51
68
  const [own, setOwn] = useState<TabsValue>(defaultValue);
52
- const current = value === undefined ? own : value;
53
- const select = (next: TabsValue): void => {
54
- if (value === undefined) {
55
- setOwn(next);
56
- }
57
- onValueChange?.(next);
58
- };
69
+ const controlled = value !== undefined;
70
+ const current = controlled ? value : own;
71
+ // The latest handler without a new context for every render of a parent that writes it inline.
72
+ const latest = useRef(onValueChange);
73
+ useLayoutEffect(() => {
74
+ latest.current = onValueChange;
75
+ });
76
+ const selection = useMemo(() => {
77
+ const select = (next: TabsValue): void => {
78
+ if (!controlled) {
79
+ setOwn(next);
80
+ }
81
+ latest.current?.(next);
82
+ };
83
+ return { value: current, select };
84
+ }, [controlled, current]);
59
85
  return (
60
- <SelectionContext value={{ value: current, select }}>
61
- <BaseTabs.Root value={current} onValueChange={(next) => select(next)} {...props} />
86
+ <SelectionContext value={selection}>
87
+ <BaseTabs.Root value={current} onValueChange={(next) => selection.select(next)} {...props} />
62
88
  </SelectionContext>
63
89
  );
64
90
  }
@@ -70,6 +96,12 @@ export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, 'classNam
70
96
  start?: ReactNode;
71
97
  /* Drawn after the tabs, such as a button that adds one. It keeps its own width: the tabs fit in what is left. */
72
98
  end?: ReactNode;
99
+ /* Like `end`, but right beside the last tab instead of at the end of the strip, so it moves with the tabs. */
100
+ after?: ReactNode;
101
+ /* `line` underlines the picked tab over the strip's border; `pill` fills it, for tabs in a toolbar. */
102
+ variant?: TabsVariant;
103
+ /* What happens to tabs that do not fit: a "More" menu at the end of the strip, or a row that scrolls sideways. */
104
+ overflow?: 'menu' | 'scroll';
73
105
  };
74
106
 
75
107
  const sameIndices = (left: readonly number[] | null, right: readonly number[]): boolean =>
@@ -80,13 +112,19 @@ const sameIndices = (left: readonly number[] | null, right: readonly number[]):
80
112
  * in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
81
113
  * since the menu repeats what a tab holds.
82
114
  */
83
- export function TabsList({ className, children, start, end, ...props }: TabsListProps) {
115
+ export function TabsList({ className, children, start, end, after, variant = 'line', overflow = 'menu', ...props }: TabsListProps) {
116
+ const style = STYLES[variant];
84
117
  const { t } = useTranslation('ui');
85
118
  const { value, select } = useContext(SelectionContext);
86
119
  const strip = useRef<HTMLDivElement>(null);
87
120
  const measure = useRef<HTMLDivElement>(null);
88
121
  const head = useRef<HTMLDivElement>(null);
89
122
  const tail = useRef<HTMLDivElement>(null);
123
+ const beside = useRef<HTMLDivElement>(null);
124
+ const list = useRef<HTMLDivElement>(null);
125
+ const scrolls = overflow === 'scroll';
126
+ // Which edges of a scrolling row still hide a tab, where it fades out.
127
+ const [edges, setEdges] = useState({ start: false, end: false });
90
128
  // Null until the first measure, which runs before the first paint.
91
129
  const [shown, setShown] = useState<number[] | null>(null);
92
130
  const tabs = Children.toArray(children).filter(
@@ -97,13 +135,16 @@ export function TabsList({ className, children, start, end, ...props }: TabsList
97
135
  useLayoutEffect(() => {
98
136
  const host = strip.current;
99
137
  const row = measure.current;
100
- if (!host || !row) {
138
+ if (scrolls || !host || !row) {
101
139
  return;
102
140
  }
103
141
  const fit = (): void => {
104
142
  const style = getComputedStyle(host);
105
143
  const gap = parseFloat(style.columnGap) || 0;
106
- const taken = [head.current, tail.current].reduce((sum, part) => (part === null ? sum : sum + part.getBoundingClientRect().width + gap), 0);
144
+ const taken = [head.current, beside.current, tail.current].reduce(
145
+ (sum, part) => (part === null ? sum : sum + part.getBoundingClientRect().width + gap),
146
+ 0
147
+ );
107
148
  const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - taken;
108
149
  const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
109
150
  const more = cells.pop() ?? 0;
@@ -114,7 +155,7 @@ export function TabsList({ className, children, start, end, ...props }: TabsList
114
155
  const observer = new ResizeObserver(fit);
115
156
  observer.observe(host);
116
157
  observer.observe(row);
117
- for (const part of [head.current, tail.current]) {
158
+ for (const part of [head.current, beside.current, tail.current]) {
118
159
  if (part !== null) {
119
160
  observer.observe(part);
120
161
  }
@@ -122,6 +163,51 @@ export function TabsList({ className, children, start, end, ...props }: TabsList
122
163
  return () => observer.disconnect();
123
164
  });
124
165
 
166
+ // A picked tab that scrolled out of sight comes back into it, wherever it was picked from.
167
+ useEffect(() => {
168
+ if (scrolls) {
169
+ list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
170
+ }
171
+ }, [scrolls, value]);
172
+
173
+ useEffect(() => {
174
+ const row = list.current;
175
+ if (!scrolls || !row) {
176
+ return;
177
+ }
178
+ const measure = (): void => {
179
+ const start = row.scrollLeft > 0;
180
+ const end = row.scrollLeft + row.clientWidth < row.scrollWidth - 1;
181
+ setEdges((current) => (current.start === start && current.end === end ? current : { start, end }));
182
+ };
183
+ measure();
184
+ row.addEventListener('scroll', measure, { passive: true });
185
+ const observer = new ResizeObserver(measure);
186
+ observer.observe(row);
187
+ for (const tab of row.children) {
188
+ observer.observe(tab);
189
+ }
190
+ return () => {
191
+ row.removeEventListener('scroll', measure);
192
+ observer.disconnect();
193
+ };
194
+ });
195
+
196
+ // A mouse wheel only turns vertically, and on a row that scrolls sideways that is the way it should turn.
197
+ useEffect(() => {
198
+ const row = list.current;
199
+ if (!scrolls || !row) {
200
+ return;
201
+ }
202
+ const onWheel = (event: WheelEvent): void => {
203
+ if (!event.deltaX && event.deltaY) {
204
+ row.scrollLeft += event.deltaY;
205
+ }
206
+ };
207
+ row.addEventListener('wheel', onWheel, { passive: true });
208
+ return () => row.removeEventListener('wheel', onWheel);
209
+ }, [scrolls]);
210
+
125
211
  const visible = shown === null ? tabs : shown.map((index) => tabs[index]!);
126
212
  const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
127
213
  const more = (
@@ -132,44 +218,59 @@ export function TabsList({ className, children, start, end, ...props }: TabsList
132
218
  );
133
219
 
134
220
  return (
135
- <div ref={strip} className={clsx('relative flex items-center gap-4 border-b border-border', className)}>
136
- {start !== undefined && start !== null && (
137
- <div ref={head} className="flex shrink-0 items-center gap-1">
138
- {start}
139
- </div>
140
- )}
141
- <BaseTabs.List className="flex min-w-0 items-end gap-4" {...props}>
142
- {visible}
143
- </BaseTabs.List>
144
- {hidden.length > 0 && (
145
- <MenuRoot>
146
- <MenuTrigger className={MORE}>{more}</MenuTrigger>
147
- <MenuPopup align="end" className="min-w-48">
148
- <InMenuContext value>
149
- {hidden.map((tab) => (
150
- <MenuItem key={tab.key} disabled={tab.props.disabled} onClick={() => select(tab.props.value)}>
151
- {tab.props.children}
152
- </MenuItem>
153
- ))}
154
- </InMenuContext>
155
- </MenuPopup>
156
- </MenuRoot>
157
- )}
158
- {end !== undefined && end !== null && (
159
- <div ref={tail} className={clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto')}>
160
- {end}
161
- </div>
162
- )}
163
- <div ref={measure} aria-hidden inert className="pointer-events-none invisible absolute top-0 left-0 flex gap-4">
164
- {tabs.map((tab) => (
165
- <span key={tab.key} className={TAB}>
166
- {tab.props.children}
167
- {tab.props.onClose !== undefined && <span className="size-5 shrink-0" />}
168
- </span>
169
- ))}
170
- <span className={MORE}>{more}</span>
221
+ <VariantContext value={variant}>
222
+ <div ref={strip} className={clsx('relative flex items-center', style.strip, className)}>
223
+ {start !== undefined && start !== null && (
224
+ <div ref={head} className="flex shrink-0 items-center gap-1">
225
+ {start}
226
+ </div>
227
+ )}
228
+ <BaseTabs.List
229
+ ref={list}
230
+ className={clsx('flex min-w-0', style.list, scrolls && 'tabs-scroll scroll-fade-x')}
231
+ data-fade-start={scrolls && edges.start ? '' : undefined}
232
+ data-fade-end={scrolls && edges.end ? '' : undefined}
233
+ {...props}
234
+ >
235
+ {visible}
236
+ </BaseTabs.List>
237
+ {hidden.length > 0 && (
238
+ <MenuRoot>
239
+ <MenuTrigger className={style.more}>{more}</MenuTrigger>
240
+ <MenuPopup align="end" className="min-w-48">
241
+ <InMenuContext value>
242
+ {hidden.map((tab) => (
243
+ <MenuItem key={tab.key} disabled={tab.props.disabled} onClick={() => select(tab.props.value)}>
244
+ {tab.props.children}
245
+ </MenuItem>
246
+ ))}
247
+ </InMenuContext>
248
+ </MenuPopup>
249
+ </MenuRoot>
250
+ )}
251
+ {after !== undefined && after !== null && (
252
+ <div ref={beside} className="flex shrink-0 items-center gap-1">
253
+ {after}
254
+ </div>
255
+ )}
256
+ {end !== undefined && end !== null && (
257
+ <div ref={tail} className={clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto')}>
258
+ {end}
259
+ </div>
260
+ )}
261
+ {!scrolls && (
262
+ <div ref={measure} aria-hidden inert className={clsx('pointer-events-none invisible absolute top-0 left-0 flex', style.row)}>
263
+ {tabs.map((tab) => (
264
+ <span key={tab.key} className={style.tab}>
265
+ {tab.props.children}
266
+ {tab.props.onClose !== undefined && <span className="size-5 shrink-0" />}
267
+ </span>
268
+ ))}
269
+ <span className={style.more}>{more}</span>
270
+ </div>
271
+ )}
171
272
  </div>
172
- </div>
273
+ </VariantContext>
173
274
  );
174
275
  }
175
276
 
@@ -183,10 +284,11 @@ export type TabsTabProps = TabProps & {
183
284
  /* The close button stays out of the tab order, so a closable tab is still one stop; the keys close it from the tab itself. */
184
285
  export function TabsTab({ className, onClose, children, ...props }: TabsTabProps) {
185
286
  const { t } = useTranslation('ui');
287
+ const tab = STYLES[useContext(VariantContext)].tab;
186
288
 
187
289
  if (onClose === undefined) {
188
290
  return (
189
- <BaseTabs.Tab className={withClass(TAB, className)} {...props}>
291
+ <BaseTabs.Tab className={withClass(tab, className)} {...props}>
190
292
  {children}
191
293
  </BaseTabs.Tab>
192
294
  );
@@ -222,7 +324,7 @@ export function TabsTab({ className, onClose, children, ...props }: TabsTabProps
222
324
  <BaseTabs.Tab
223
325
  nativeButton={false}
224
326
  render={<div />}
225
- className={withClass(TAB, className)}
327
+ className={withClass(tab, className)}
226
328
  onKeyDown={handleKeyDown}
227
329
  onMouseDown={handleMouseDown}
228
330
  onAuxClick={handleAuxClick}
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { dirname, join, sep } from 'node:path';
3
3
  import { Glob, plugin } from 'bun';
4
4
 
5
5
  /*
@@ -7,10 +7,12 @@ import { Glob, plugin } from 'bun';
7
7
  * does for Vite. The checkout has node_modules of its own, and a second React, i18next or Base UI
8
8
  * breaks every hook, every word and every compound part. Bun has no dedupe and its resolve hook never
9
9
  * sees a package import, so every file of these packages in the checkout loads as a stand-in for the
10
- * same file in the app's copy. Does nothing for a package the checkout has no copy of, which is every
11
- * package once the library is installed from the registry.
10
+ * same file in the app's copy. Does nothing once the library is installed from the registry.
11
+ *
12
+ * CodeMirror and Lezer reject an extension, a facet or a node prop made by another copy, and the chat
13
+ * views take both from the app; shiki is in the list for the highlighter an app hands them.
12
14
  */
13
- const SHARED = ['react', 'react-dom', 'i18next', 'react-i18next', '@base-ui-components/react'];
15
+ const SHARED = ['react', 'react-dom', 'i18next', 'react-i18next', '@base-ui/react', '@codemirror/*', '@lezer/*', 'shiki'];
14
16
 
15
17
  const NAME = '@adecore/ui';
16
18
 
@@ -22,6 +24,12 @@ interface Manifest {
22
24
  dependencies?: Record<string, string>;
23
25
  devDependencies?: Record<string, string>;
24
26
  peerDependencies?: Record<string, string>;
27
+ adecore?: { dedupe?: unknown };
28
+ }
29
+
30
+ interface Copy {
31
+ theirs: string;
32
+ ours: string;
25
33
  }
26
34
 
27
35
  const manifestOf = (folder: string): Manifest | null => {
@@ -32,19 +40,47 @@ const manifestOf = (folder: string): Manifest | null => {
32
40
  const dependsOnLibrary = (manifest: Manifest | null): boolean =>
33
41
  manifest !== null && [manifest.dependencies, manifest.devDependencies, manifest.peerDependencies].some((deps) => deps !== undefined && NAME in deps);
34
42
 
43
+ const membersOf = (root: string, manifest: Manifest | null): string[] => {
44
+ const workspaces = manifest?.workspaces;
45
+ const patterns = (Array.isArray(workspaces) ? workspaces : workspaces?.packages) ?? [];
46
+ return patterns
47
+ .flatMap((pattern) => [...new Glob(`${pattern}/package.json`).scanSync({ cwd: root, onlyFiles: true })])
48
+ .map((path) => join(root, dirname(path)));
49
+ };
50
+
35
51
  /*
36
52
  * Where the app's copies are found: the folder the tests run in, then each workspace of it that
37
53
  * depends on the library. With an isolated install the root of a monorepo has no React of its own.
38
54
  */
39
55
  const appFolders = (): string[] => {
40
56
  const root = process.cwd();
41
- const workspaces = manifestOf(root)?.workspaces;
42
- const patterns = (Array.isArray(workspaces) ? workspaces : workspaces?.packages) ?? [];
43
- const members = patterns
44
- .flatMap((pattern) => [...new Glob(`${pattern}/package.json`).scanSync({ cwd: root, onlyFiles: true })])
45
- .map((path) => join(root, dirname(path)))
46
- .filter((folder) => dependsOnLibrary(manifestOf(folder)));
47
- return [root, ...members];
57
+ return [root, ...membersOf(root, manifestOf(root)).filter((folder) => dependsOnLibrary(manifestOf(folder)))];
58
+ };
59
+
60
+ /* Where the checkout keeps its copies: the library's other packages in the same monorepo bring their own. */
61
+ const checkoutFolders = (): string[] => {
62
+ for (let folder = dirname(library); dirname(folder) !== folder; folder = dirname(folder)) {
63
+ const manifest = manifestOf(folder);
64
+ if (manifest?.workspaces !== undefined) {
65
+ const members = membersOf(folder, manifest);
66
+ return members.includes(library) ? [folder, ...members] : [library];
67
+ }
68
+ }
69
+ return [library];
70
+ };
71
+
72
+ /* The packages a name of the list stands for in a folder: itself, or every package of the scope for `@scope/*`. */
73
+ const namesIn = (folder: string, pattern: string): string[] => {
74
+ if (!pattern.endsWith('/*')) {
75
+ return [pattern];
76
+ }
77
+ const scope = pattern.slice(0, -2);
78
+ const path = join(folder, 'node_modules', scope);
79
+ return existsSync(path)
80
+ ? readdirSync(path)
81
+ .filter((entry) => !entry.startsWith('.'))
82
+ .map((entry) => `${scope}/${entry}`)
83
+ : [];
48
84
  };
49
85
 
50
86
  /* The copy of a package that `from` resolves to, walking up the node_modules folders as Node does. */
@@ -60,24 +96,51 @@ const copyFrom = (from: string, name: string): string | null => {
60
96
  }
61
97
  };
62
98
 
63
- const folders = appFolders();
64
-
65
- const copies = SHARED.flatMap((name) => {
66
- const theirs = join(library, 'node_modules', name);
67
- if (!existsSync(theirs)) {
99
+ const findCopies = (): Copy[] => {
100
+ const checkout = checkoutFolders();
101
+ const inside = (path: string): boolean => path === checkout[0] || path.startsWith(`${checkout[0]}/`);
102
+ // A run inside the checkout is the library's own; a node_modules above it belongs to no app.
103
+ if (inside(realpathSync(process.cwd()))) {
68
104
  return [];
69
105
  }
70
- const real = realpathSync(theirs);
71
- const ours = folders.map((folder) => copyFrom(folder, name)).find((copy): copy is string => copy !== null && !copy.startsWith(`${library}/`));
72
- return ours === undefined || ours === real ? [] : [{ theirs: `${real}/`, ours: `${ours}/` }];
73
- });
106
+ const folders = appFolders();
107
+ const added = folders.flatMap((folder) => {
108
+ const names = manifestOf(folder)?.adecore?.dedupe;
109
+ return Array.isArray(names) ? names.filter((name): name is string => typeof name === 'string') : [];
110
+ });
111
+ const names = new Set([...SHARED, ...added].flatMap((pattern) => checkout.flatMap((folder) => namesIn(folder, pattern))));
112
+ return [...names].flatMap((name) => {
113
+ const ours = folders.map((folder) => copyFrom(folder, name)).find((copy): copy is string => copy !== null && !inside(copy));
114
+ if (ours === undefined) {
115
+ return [];
116
+ }
117
+ const theirs = new Set(
118
+ checkout
119
+ .map((folder) => join(folder, 'node_modules', name))
120
+ .filter((path) => existsSync(path))
121
+ .map((path) => realpathSync(path))
122
+ );
123
+ return [...theirs].filter((real) => real !== ours).map((real) => ({ theirs: `${real}/`, ours: `${ours}/` }));
124
+ });
125
+ };
126
+
127
+ // From the registry the package manager already decided what to share; a copy nested in the library is on purpose.
128
+ const copies = library.split(sep).includes('node_modules') ? [] : findCopies();
74
129
 
75
130
  const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
76
131
 
77
- // Bun refuses `export default` beside `export *`, so the stand-in names every export of the real file.
132
+ let transpiler: Bun.Transpiler | null = null;
133
+
134
+ /*
135
+ * Re-exports the app's file without running it: `require` refuses an ES module of the app that is
136
+ * still loading. `export *` skips the default, which CommonJS always has and an ES module only when it says so.
137
+ */
78
138
  const standIn = (path: string): string => {
79
- const names = Object.keys(require(path) as object).filter((name) => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name));
80
- return [`import * as real from ${JSON.stringify(path)};`, 'export default real.default;', `export const { ${names.join(', ')} } = real;`].join('\n');
139
+ transpiler ??= new Bun.Transpiler({ loader: 'js' });
140
+ const { exports, imports } = transpiler.scan(readFileSync(path, 'utf8'));
141
+ const esm = exports.length > 0 || imports.some((entry) => entry.kind === 'import-statement');
142
+ const target = JSON.stringify(path);
143
+ return [`export * from ${target};`, ...(!esm || exports.includes('default') ? [`export { default } from ${target};`] : [])].join('\n');
81
144
  };
82
145
 
83
146
  if (copies.length > 0) {
package/src/theme.css CHANGED
@@ -473,18 +473,16 @@
473
473
  }
474
474
  /* A picture holds the eye better against a heavier dim than a dialog of controls needs. */
475
475
  .lightbox-backdrop { background: color-mix(in srgb, var(--bg) 80%, transparent); }
476
- /* Tooltips. The positioner animates its position, so a tooltip slides to the next button
477
- instead of blinking; the popup fades and scales from its anchor side. Both drop the motion
478
- while Base UI says the tooltip is instant. */
476
+ /* Tooltips. The popup fades and scales from its anchor side, and drops the motion while Base UI
477
+ says the tooltip is instant. The positioner does not animate its place: Base UI parks it at the
478
+ window's origin until it is measured, so it would slide in from the corner. */
479
479
  /* As wide as its label up to the viewport's max width, so a short label near the edge of the
480
480
  window does not wrap to the room left of it before the positioner moves it back in. */
481
481
  .tooltip-positioner {
482
482
  width: max-content;
483
483
  z-index: var(--z-tooltip);
484
484
  pointer-events: none;
485
- transition: top 150ms ease, left 150ms ease, right 150ms ease, bottom 150ms ease;
486
485
  }
487
- .tooltip-positioner[data-instant] { transition: none; }
488
486
  .tooltip-popup {
489
487
  display: flex;
490
488
  border-radius: 7px;
@@ -824,14 +822,14 @@
824
822
  .ade-workspace[data-layout="roomy"][data-toolbar][data-sidebar] > .ade-workspace-center > .ade-workspace-content { border-top-left-radius: 8px; }
825
823
  .ade-workspace[data-layout="roomy"][data-toolbar][data-side-panel] > .ade-workspace-center > .ade-workspace-content { border-top-right-radius: 8px; }
826
824
  .ade-split-divider:is(:hover, :focus-visible, [data-resizing])::after { background: var(--accent); }
827
- .ade-split-preview {
825
+ .ade-drop-preview {
828
826
  position: absolute;
829
827
  inset: 0;
830
828
  z-index: 4;
831
829
  pointer-events: none;
832
830
  overflow: visible;
833
831
  }
834
- .ade-split-preview path {
832
+ .ade-drop-preview path {
835
833
  fill: color-mix(in srgb, var(--accent) 8%, transparent);
836
834
  stroke: var(--accent);
837
835
  stroke-width: 2px;
@@ -848,6 +846,15 @@
848
846
  scrollbar-width: none;
849
847
  }
850
848
  .ade-tab-strip::-webkit-scrollbar { display: none; }
849
+ /* Tabs that scroll sideways instead of handing the rest to a menu. The pixel at the bottom holds a
850
+ line tab's underline, which the scroll box would clip otherwise. */
851
+ .tabs-scroll {
852
+ overflow-x: auto;
853
+ overflow-y: hidden;
854
+ padding-bottom: 1px;
855
+ scrollbar-width: none;
856
+ }
857
+ .tabs-scroll::-webkit-scrollbar { display: none; }
851
858
  .ade-document-tab {
852
859
  position: relative;
853
860
  display: inline-flex;
@@ -885,5 +892,14 @@
885
892
  .ade-document-tab[data-detail] .ade-document-tab-open { max-width: 200px; }
886
893
  .ade-tab-unsaved { width: 6px; height: 6px; flex: none; border-radius: 999px; background: var(--text-muted); }
887
894
  .ade-tab-insertion { position: absolute; top: 4px; bottom: 4px; width: 2px; pointer-events: none; background: var(--accent); }
888
- @media (prefers-reduced-motion: reduce) { .ade-split-preview path { transition: none; } }
895
+ @media (prefers-reduced-motion: reduce) { .ade-drop-preview path { transition: none; } }
896
+ }
897
+
898
+ @layer components {
899
+ /* `MiddleTruncate`. The text again, unseen and without height, so the element asks for the width of all of it while it draws a part. */
900
+ .middle-truncate { display: block; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
901
+ .middle-truncate::before { content: attr(data-text) / ""; display: block; height: 0; visibility: hidden; }
902
+ .middle-truncate-head::after { content: "\2026" / ""; }
903
+ /* The cut part stays in the text at no width, so a selection copies it and a find matches across the cut. */
904
+ .middle-truncate-middle { display: inline-block; width: 0; overflow: hidden; vertical-align: top; }
889
905
  }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, type MouseEvent } from 'react';
2
2
  import clsx from 'clsx';
3
- import { useRender } from '@base-ui-components/react/use-render';
3
+ import { useRender } from '@base-ui/react/use-render';
4
4
  import { ChevronRight } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { Checkbox as UICheckbox, type CheckboxProps } from '../Checkbox.tsx';
@@ -0,0 +1,26 @@
1
+ import type { Ref } from 'react';
2
+ import clsx from 'clsx';
3
+ import { dropPreviewPath, type DropPreviewShape } from './preview.ts';
4
+
5
+ export interface DropPreviewProps {
6
+ /* Where the drop would land, in the coordinates of the box the preview fills; null draws nothing. */
7
+ shape: DropPreviewShape | null;
8
+ className?: string;
9
+ ref?: Ref<SVGSVGElement>;
10
+ }
11
+
12
+ /*
13
+ * The outline `SplitView` draws where a drag would land, for an app with a drag layer of its own. It
14
+ * fills its nearest positioned ancestor. One shape moves into the next as a transition of its path,
15
+ * and the first shape after a null appears in place.
16
+ */
17
+ export function DropPreview({ shape, className, ref }: DropPreviewProps) {
18
+ if (shape === null) {
19
+ return null;
20
+ }
21
+ return (
22
+ <svg ref={ref} className={clsx('ade-drop-preview', className)} width="100%" height="100%" aria-hidden="true">
23
+ <path style={{ d: `path("${dropPreviewPath(shape)}")` }} />
24
+ </svg>
25
+ );
26
+ }