@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.
- package/dist/ButtonGroup.d.ts +1 -1
- package/dist/ButtonGroup.js +1 -1
- package/dist/Checkbox.js +1 -1
- package/dist/ChoiceCards.js +2 -2
- package/dist/CloseButton.js +1 -1
- package/dist/ColorSwatch.d.ts +1 -1
- package/dist/ColorSwatch.js +1 -1
- package/dist/Field.d.ts +1 -1
- package/dist/Field.js +4 -3
- package/dist/IconButton.d.ts +1 -1
- package/dist/IconButton.js +1 -1
- package/dist/ListRow.d.ts +1 -1
- package/dist/ListRow.js +1 -1
- package/dist/MiddleTruncate.d.ts +8 -0
- package/dist/MiddleTruncate.js +59 -0
- package/dist/PanelHeader.d.ts +1 -1
- package/dist/PanelHeader.js +1 -1
- package/dist/SectionLabel.d.ts +1 -1
- package/dist/SectionLabel.js +1 -1
- package/dist/Select.js +1 -1
- package/dist/SelectSearch.js +1 -1
- package/dist/Slider.js +1 -1
- package/dist/Surface.d.ts +1 -1
- package/dist/Surface.js +1 -1
- package/dist/Switch.js +1 -1
- package/dist/Toasts.d.ts +1 -1
- package/dist/Toasts.js +4 -5
- package/dist/Tooltip.d.ts +1 -1
- package/dist/Tooltip.js +2 -2
- package/dist/clipboard.d.ts +1 -1
- package/dist/clipboard.js +12 -2
- package/dist/dialog/parts.d.ts +3 -3
- package/dist/dialog/parts.js +2 -2
- package/dist/file-tree/helpers.d.ts +1 -0
- package/dist/file-tree/helpers.js +9 -0
- package/dist/file-tree/parts.d.ts +1 -1
- package/dist/file-tree/parts.js +1 -1
- package/dist/file-tree/use-slots.d.ts +8 -0
- package/dist/file-tree/use-slots.js +64 -22
- package/dist/format/datetime.d.ts +1 -0
- package/dist/format/datetime.js +15 -0
- package/dist/format/index.d.ts +1 -1
- package/dist/format/index.js +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -1
- package/dist/key-value-list/parts.d.ts +1 -1
- package/dist/key-value-list/parts.js +1 -1
- package/dist/locales/en.d.ts +83 -0
- package/dist/locales/en.js +82 -0
- package/dist/locales/nl.d.ts +83 -0
- package/dist/locales/nl.js +82 -0
- package/dist/locales.js +3 -2
- package/dist/menu/parts.d.ts +7 -7
- package/dist/menu/parts.js +4 -4
- package/dist/middle-truncate.d.ts +7 -0
- package/dist/middle-truncate.js +34 -0
- package/dist/popover/parts.d.ts +7 -7
- package/dist/popover/parts.js +2 -2
- package/dist/prefetch.js +6 -1
- package/dist/settings/ConfirmDialog.d.ts +4 -1
- package/dist/settings/ConfirmDialog.js +2 -2
- package/dist/settings/SettingsDialog.js +1 -1
- package/dist/tabs/parts.d.ts +8 -4
- package/dist/tabs/parts.js +84 -18
- package/dist/testing/dedupe.js +74 -23
- package/dist/theme.css +24 -8
- package/dist/tree/parts.d.ts +1 -1
- package/dist/tree/parts.js +1 -1
- package/dist/workspace/DropPreview.d.ts +8 -0
- package/dist/workspace/DropPreview.js +14 -0
- package/dist/workspace/SplitView.d.ts +1 -1
- package/dist/workspace/SplitView.js +59 -18
- package/dist/workspace/TabStrip.d.ts +1 -1
- package/dist/workspace/TabStrip.js +1 -1
- package/dist/workspace/Workspace.d.ts +2 -2
- package/dist/workspace/Workspace.js +4 -2
- package/dist/workspace/preview.d.ts +4 -8
- package/dist/workspace/preview.js +17 -2
- package/package.json +2 -2
- package/src/ButtonGroup.tsx +1 -1
- package/src/Checkbox.tsx +1 -1
- package/src/ChoiceCards.tsx +2 -2
- package/src/CloseButton.tsx +1 -1
- package/src/ColorSwatch.tsx +1 -1
- package/src/ErrorBoundary.tsx +4 -4
- package/src/Field.tsx +4 -5
- package/src/IconButton.tsx +1 -1
- package/src/ListRow.tsx +1 -1
- package/src/MiddleTruncate.tsx +84 -0
- package/src/PanelHeader.tsx +1 -1
- package/src/SectionLabel.tsx +1 -1
- package/src/Select.tsx +1 -1
- package/src/SelectSearch.tsx +1 -1
- package/src/Slider.tsx +1 -1
- package/src/Surface.tsx +1 -1
- package/src/Switch.tsx +1 -1
- package/src/Toasts.tsx +5 -6
- package/src/Tooltip.tsx +5 -5
- package/src/clipboard.ts +11 -2
- package/src/dialog/parts.tsx +2 -2
- package/src/file-tree/helpers.ts +10 -0
- package/src/file-tree/parts.tsx +1 -1
- package/src/file-tree/use-slots.ts +68 -23
- package/src/format/datetime.ts +17 -0
- package/src/format/index.ts +1 -0
- package/src/index.ts +6 -3
- package/src/key-value-list/parts.tsx +1 -1
- package/src/locales/en.ts +82 -0
- package/src/locales/nl.ts +82 -0
- package/src/locales.ts +3 -2
- package/src/menu/parts.tsx +4 -4
- package/src/middle-truncate.ts +41 -0
- package/src/popover/parts.tsx +2 -2
- package/src/prefetch.ts +6 -1
- package/src/settings/ConfirmDialog.tsx +7 -1
- package/src/settings/SettingsDialog.tsx +1 -1
- package/src/tabs/parts.tsx +156 -54
- package/src/testing/dedupe.ts +87 -24
- package/src/theme.css +24 -8
- package/src/tree/parts.tsx +1 -1
- package/src/workspace/DropPreview.tsx +26 -0
- package/src/workspace/SplitView.tsx +58 -22
- package/src/workspace/TabStrip.tsx +1 -1
- package/src/workspace/Workspace.tsx +7 -3
- package/src/workspace/preview.ts +22 -9
- package/dist/locales/en.json +0 -82
- package/dist/locales/nl.json +0 -82
- package/src/locales/en.json +0 -82
- package/src/locales/nl.json +0 -82
package/src/tabs/parts.tsx
CHANGED
|
@@ -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
|
|
17
|
-
import { useRender } from '@base-ui
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
onValueChange
|
|
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={
|
|
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(
|
|
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
|
-
<
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
{
|
|
168
|
-
</
|
|
169
|
-
)
|
|
170
|
-
|
|
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
|
-
</
|
|
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(
|
|
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(
|
|
327
|
+
className={withClass(tab, className)}
|
|
226
328
|
onKeyDown={handleKeyDown}
|
|
227
329
|
onMouseDown={handleMouseDown}
|
|
228
330
|
onAuxClick={handleAuxClick}
|
package/src/testing/dedupe.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
if (
|
|
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
|
|
71
|
-
const
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
}
|
package/src/tree/parts.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, type MouseEvent } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
import { useRender } from '@base-ui
|
|
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
|
+
}
|