@adecore/ui 0.17.0-beta.2 → 0.18.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/Kbd.d.ts +4 -2
- package/dist/Kbd.js +7 -5
- package/dist/file-tree/use-file-tree-shift.js +10 -108
- package/dist/tabs/parts.d.ts +2 -1
- package/dist/tabs/parts.js +8 -5
- package/dist/theme.css +40 -0
- package/dist/tree/parts.d.ts +4 -2
- package/dist/tree/parts.js +13 -3
- package/dist/tree/use-tree-shift.d.ts +11 -0
- package/dist/tree/use-tree-shift.js +133 -0
- package/package.json +1 -1
- package/src/Kbd.tsx +13 -7
- package/src/file-tree/use-file-tree-shift.tsx +12 -126
- package/src/tabs/parts.tsx +14 -4
- package/src/theme.css +40 -0
- package/src/tree/parts.tsx +20 -4
- package/src/tree/use-tree-shift.tsx +163 -0
package/dist/Kbd.d.ts
CHANGED
|
@@ -10,14 +10,16 @@ export interface KbdProps {
|
|
|
10
10
|
export declare function Kbd({ shortcut, children, variant, className, ref }: KbdProps): import("react").JSX.Element;
|
|
11
11
|
export interface KeyCapProps {
|
|
12
12
|
children: ReactNode;
|
|
13
|
+
size?: 'md' | 'sm';
|
|
13
14
|
className?: string;
|
|
14
15
|
ref?: Ref<HTMLElement>;
|
|
15
16
|
}
|
|
16
|
-
export declare function KeyCap({ children, className, ref }: KeyCapProps): import("react").JSX.Element;
|
|
17
|
+
export declare function KeyCap({ children, size, className, ref }: KeyCapProps): import("react").JSX.Element;
|
|
17
18
|
export interface KeysProps {
|
|
18
19
|
shortcut: Shortcut;
|
|
19
20
|
then?: string;
|
|
21
|
+
size?: 'md' | 'sm';
|
|
20
22
|
className?: string;
|
|
21
23
|
ref?: Ref<HTMLSpanElement>;
|
|
22
24
|
}
|
|
23
|
-
export declare function Keys({ shortcut, then, className, ref }: KeysProps): import("react").JSX.Element;
|
|
25
|
+
export declare function Keys({ shortcut, then, size, className, ref }: KeysProps): import("react").JSX.Element;
|
package/dist/Kbd.js
CHANGED
|
@@ -5,16 +5,18 @@ import { formatShortcut, shortcutParts } from './shortcut.js';
|
|
|
5
5
|
/* A shortcut chip on the surface it sits on: in a tooltip, in a palette's footer, in a line of help. */
|
|
6
6
|
const INLINE_KBD = 'rounded-sm bg-surface-sunken px-[5px] py-px font-sans text-xs/[inherit] text-text-muted';
|
|
7
7
|
/* One key, drawn as a cap. A settings page prints a shortcut key by key, so a gesture can stand beside it. */
|
|
8
|
-
const KEY_CAP = 'inline-grid
|
|
8
|
+
const KEY_CAP = 'inline-grid place-items-center rounded-md border border-b-2 border-border-strong bg-surface-hover bg-clip-padding font-sans text-text';
|
|
9
|
+
/* `sm` fits inside a small field, as the hint of the key that focuses it. */
|
|
10
|
+
const KEY_CAP_SIZES = { md: 'h-6 min-w-6 px-1.5 text-xs', sm: 'h-5 min-w-5 px-1 text-2xs' };
|
|
9
11
|
/* A shortcut as this platform prints it: `⌘K` on macOS, `Ctrl+K` elsewhere. */
|
|
10
12
|
export function Kbd({ shortcut, children, variant = 'menu', className, ref }) {
|
|
11
13
|
return (_jsx("kbd", { ref: ref, className: clsx(variant === 'inline' && INLINE_KBD, className), children: shortcut !== undefined ? formatShortcut(shortcut, isApplePlatform()) : children }));
|
|
12
14
|
}
|
|
13
|
-
export function KeyCap({ children, className, ref }) {
|
|
14
|
-
return (_jsx("kbd", { ref: ref, className: clsx(KEY_CAP, className), children: children }));
|
|
15
|
+
export function KeyCap({ children, size = 'md', className, ref }) {
|
|
16
|
+
return (_jsx("kbd", { ref: ref, className: clsx(KEY_CAP, KEY_CAP_SIZES[size], className), children: children }));
|
|
15
17
|
}
|
|
16
18
|
/* A shortcut as one cap per key, `⌘` and `K`, plus the pointer gesture it goes with. */
|
|
17
|
-
export function Keys({ shortcut, then, className, ref }) {
|
|
19
|
+
export function Keys({ shortcut, then, size = 'md', className, ref }) {
|
|
18
20
|
const parts = [...shortcutParts(shortcut, isApplePlatform()), ...(then ? [then] : [])];
|
|
19
|
-
return (_jsx("span", { ref: ref, className: clsx('flex items-center gap-1', className), children: parts.map((part, index) => (_jsx(KeyCap, { children: part }, `${part}-${index}`))) }));
|
|
21
|
+
return (_jsx("span", { ref: ref, className: clsx('flex items-center', size === 'sm' ? 'gap-0.5' : 'gap-1', className), children: parts.map((part, index) => (_jsx(KeyCap, { size: size, children: part }, `${part}-${index}`))) }));
|
|
20
22
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useTreeShift } from '../tree/use-tree-shift.js';
|
|
3
|
+
import { shiftNeed } from './shift.js';
|
|
4
4
|
const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
|
|
5
|
-
const HIDE_AFTER = 900;
|
|
6
|
-
const NEAR_BOTTOM = 16;
|
|
7
5
|
function boxesOf(element) {
|
|
8
6
|
return element.getClientRects().length > 0 ? [element] : [...element.children].flatMap((child) => boxesOf(child));
|
|
9
7
|
}
|
|
@@ -35,108 +33,12 @@ function needOf(row, shift, gap, range) {
|
|
|
35
33
|
range.selectNodeContents(content);
|
|
36
34
|
return shiftNeed(range.getBoundingClientRect().right, limitOf(row, content, gap), shift);
|
|
37
35
|
}
|
|
36
|
+
/* The file tree draws its rows in a shadow root, and only the name slides: its decorations sit over the row. */
|
|
38
37
|
export function useFileTreeShift(model, resetKey) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
const range = document.createRange();
|
|
47
|
-
let root = null;
|
|
48
|
-
let exact = 0;
|
|
49
|
-
let drawn = 0;
|
|
50
|
-
let max = 0;
|
|
51
|
-
let pending = 0;
|
|
52
|
-
let hideTimer = 0;
|
|
53
|
-
let scrolling = false;
|
|
54
|
-
let near = false;
|
|
55
|
-
const draw = () => {
|
|
56
|
-
const shift = Math.round(exact);
|
|
57
|
-
if (shift !== drawn) {
|
|
58
|
-
drawn = shift;
|
|
59
|
-
frame.style.setProperty(SHIFT_PROPERTY, `${shift}px`);
|
|
60
|
-
}
|
|
61
|
-
const { left, width } = shiftThumb(shift, max, track.clientWidth, frame.clientWidth);
|
|
62
|
-
thumb.style.left = `${left}px`;
|
|
63
|
-
thumb.style.width = `${width}px`;
|
|
64
|
-
track.toggleAttribute('data-visible', max > 0 && (scrolling || near));
|
|
65
|
-
};
|
|
66
|
-
const measure = () => {
|
|
67
|
-
pending = 0;
|
|
68
|
-
if (root === null) {
|
|
69
|
-
root = model.getFileTreeContainer()?.shadowRoot ?? null;
|
|
70
|
-
if (root === null) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
rows.observe(root, { childList: true, subtree: true, characterData: true });
|
|
74
|
-
}
|
|
75
|
-
const drawnRows = [...root.querySelectorAll(ROWS)];
|
|
76
|
-
const gap = drawnRows.length === 0 ? 0 : Number.parseFloat(getComputedStyle(drawnRows[0]).columnGap) || 0;
|
|
77
|
-
max = maxShift(drawnRows.map((row) => needOf(row, drawn, gap, range)).filter((need) => need !== null));
|
|
78
|
-
exact = clampShift(exact, max);
|
|
79
|
-
draw();
|
|
80
|
-
};
|
|
81
|
-
const schedule = () => {
|
|
82
|
-
if (pending === 0) {
|
|
83
|
-
pending = requestAnimationFrame(measure);
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
const rows = new MutationObserver(schedule);
|
|
87
|
-
const mounts = new MutationObserver(schedule);
|
|
88
|
-
mounts.observe(frame, { childList: true, subtree: true });
|
|
89
|
-
const sizes = new ResizeObserver(schedule);
|
|
90
|
-
sizes.observe(frame);
|
|
91
|
-
const onWheel = (event) => {
|
|
92
|
-
const delta = sidewaysDelta(event, frame.clientWidth);
|
|
93
|
-
if (delta === 0 || max === 0) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
scrolling = true;
|
|
97
|
-
window.clearTimeout(hideTimer);
|
|
98
|
-
hideTimer = window.setTimeout(() => {
|
|
99
|
-
scrolling = false;
|
|
100
|
-
draw();
|
|
101
|
-
}, HIDE_AFTER);
|
|
102
|
-
const next = clampShift(exact + delta, max);
|
|
103
|
-
if (next !== exact) {
|
|
104
|
-
event.preventDefault();
|
|
105
|
-
exact = next;
|
|
106
|
-
}
|
|
107
|
-
draw();
|
|
108
|
-
};
|
|
109
|
-
const onPointerMove = (event) => {
|
|
110
|
-
const bottom = track.getBoundingClientRect().bottom;
|
|
111
|
-
const isNear = event.clientY >= bottom - NEAR_BOTTOM;
|
|
112
|
-
if (isNear !== near) {
|
|
113
|
-
near = isNear;
|
|
114
|
-
draw();
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
const onPointerLeave = () => {
|
|
118
|
-
if (near) {
|
|
119
|
-
near = false;
|
|
120
|
-
draw();
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
frame.addEventListener('wheel', onWheel, { passive: false });
|
|
124
|
-
frame.addEventListener('pointermove', onPointerMove);
|
|
125
|
-
frame.addEventListener('pointerleave', onPointerLeave);
|
|
126
|
-
schedule();
|
|
127
|
-
return () => {
|
|
128
|
-
frame.removeEventListener('wheel', onWheel);
|
|
129
|
-
frame.removeEventListener('pointermove', onPointerMove);
|
|
130
|
-
frame.removeEventListener('pointerleave', onPointerLeave);
|
|
131
|
-
rows.disconnect();
|
|
132
|
-
mounts.disconnect();
|
|
133
|
-
sizes.disconnect();
|
|
134
|
-
cancelAnimationFrame(pending);
|
|
135
|
-
window.clearTimeout(hideTimer);
|
|
136
|
-
frame.style.removeProperty(SHIFT_PROPERTY);
|
|
137
|
-
track.removeAttribute('data-visible');
|
|
138
|
-
};
|
|
139
|
-
}, [frame, track, thumb, model, resetKey]);
|
|
140
|
-
const bar = (_jsx("div", { "aria-hidden": true, className: "adecore-tree-shift", children: _jsx("div", { ref: setTrack, className: "adecore-tree-shift-track", children: _jsx("div", { ref: setThumb, className: "adecore-tree-shift-thumb" }) }) }));
|
|
141
|
-
return { attach: setFrame, bar };
|
|
38
|
+
const source = useMemo(() => ({
|
|
39
|
+
root: () => model.getFileTreeContainer()?.shadowRoot ?? null,
|
|
40
|
+
rows: (root) => [...root.querySelectorAll(ROWS)],
|
|
41
|
+
need: needOf
|
|
42
|
+
}), [model]);
|
|
43
|
+
return useTreeShift(source, resetKey);
|
|
142
44
|
}
|
package/dist/tabs/parts.d.ts
CHANGED
|
@@ -8,9 +8,10 @@ export type TabsRootProps = Omit<ComponentProps<typeof BaseTabs.Root>, 'onValueC
|
|
|
8
8
|
export declare function TabsRoot({ value, defaultValue, onValueChange, ...props }: TabsRootProps): import("react").JSX.Element;
|
|
9
9
|
export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, 'className'> & {
|
|
10
10
|
className?: string;
|
|
11
|
+
start?: ReactNode;
|
|
11
12
|
end?: ReactNode;
|
|
12
13
|
};
|
|
13
|
-
export declare function TabsList({ className, children, end, ...props }: TabsListProps): import("react").JSX.Element;
|
|
14
|
+
export declare function TabsList({ className, children, start, end, ...props }: TabsListProps): import("react").JSX.Element;
|
|
14
15
|
type TabProps = ComponentProps<typeof BaseTabs.Tab>;
|
|
15
16
|
export type TabsTabProps = TabProps & {
|
|
16
17
|
onClose?(): void;
|
package/dist/tabs/parts.js
CHANGED
|
@@ -37,11 +37,12 @@ const sameIndices = (left, right) => left !== null && left.length === right.leng
|
|
|
37
37
|
* in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
|
|
38
38
|
* since the menu repeats what a tab holds.
|
|
39
39
|
*/
|
|
40
|
-
export function TabsList({ className, children, end, ...props }) {
|
|
40
|
+
export function TabsList({ className, children, start, end, ...props }) {
|
|
41
41
|
const { t } = useTranslation('ui');
|
|
42
42
|
const { value, select } = useContext(SelectionContext);
|
|
43
43
|
const strip = useRef(null);
|
|
44
44
|
const measure = useRef(null);
|
|
45
|
+
const head = useRef(null);
|
|
45
46
|
const tail = useRef(null);
|
|
46
47
|
// Null until the first measure, which runs before the first paint.
|
|
47
48
|
const [shown, setShown] = useState(null);
|
|
@@ -56,7 +57,7 @@ export function TabsList({ className, children, end, ...props }) {
|
|
|
56
57
|
const fit = () => {
|
|
57
58
|
const style = getComputedStyle(host);
|
|
58
59
|
const gap = parseFloat(style.columnGap) || 0;
|
|
59
|
-
const taken = tail.current === null ?
|
|
60
|
+
const taken = [head.current, tail.current].reduce((sum, part) => (part === null ? sum : sum + part.getBoundingClientRect().width + gap), 0);
|
|
60
61
|
const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - taken;
|
|
61
62
|
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
|
62
63
|
const more = cells.pop() ?? 0;
|
|
@@ -67,14 +68,16 @@ export function TabsList({ className, children, end, ...props }) {
|
|
|
67
68
|
const observer = new ResizeObserver(fit);
|
|
68
69
|
observer.observe(host);
|
|
69
70
|
observer.observe(row);
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
for (const part of [head.current, tail.current]) {
|
|
72
|
+
if (part !== null) {
|
|
73
|
+
observer.observe(part);
|
|
74
|
+
}
|
|
72
75
|
}
|
|
73
76
|
return () => observer.disconnect();
|
|
74
77
|
});
|
|
75
78
|
const visible = shown === null ? tabs : shown.map((index) => tabs[index]);
|
|
76
79
|
const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
|
|
77
|
-
return (_jsxs("div", { ref: strip, className: clsx('relative flex items-center gap-4 border-b border-border', className), children: [_jsx(BaseTabs.List, { className: "flex min-w-0 items-end gap-4", ...props, children: visible }), hidden.length > 0 && (_jsxs(MenuRoot, { children: [_jsxs(MenuTrigger, { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] }), _jsx(MenuPopup, { align: "end", className: "min-w-48", children: _jsx(InMenuContext, { value: true, children: hidden.map((tab) => (_jsx(MenuItem, { disabled: tab.props.disabled, onClick: () => select(tab.props.value), children: tab.props.children }, tab.key))) }) })] })), end !== undefined && end !== null && (_jsx("div", { ref: tail, className: clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto'), children: end })), _jsxs("div", { ref: measure, "aria-hidden": true, inert: true, className: "pointer-events-none invisible absolute top-0 left-0 flex gap-4", children: [tabs.map((tab) => (_jsxs("span", { className: TAB, children: [tab.props.children, tab.props.onClose !== undefined && _jsx("span", { className: "size-5 shrink-0" })] }, tab.key))), _jsxs("span", { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] })] })] }));
|
|
80
|
+
return (_jsxs("div", { ref: strip, className: clsx('relative flex items-center gap-4 border-b border-border', className), children: [start !== undefined && start !== null && (_jsx("div", { ref: head, className: "flex shrink-0 items-center gap-1", children: start })), _jsx(BaseTabs.List, { className: "flex min-w-0 items-end gap-4", ...props, children: visible }), hidden.length > 0 && (_jsxs(MenuRoot, { children: [_jsxs(MenuTrigger, { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] }), _jsx(MenuPopup, { align: "end", className: "min-w-48", children: _jsx(InMenuContext, { value: true, children: hidden.map((tab) => (_jsx(MenuItem, { disabled: tab.props.disabled, onClick: () => select(tab.props.value), children: tab.props.children }, tab.key))) }) })] })), end !== undefined && end !== null && (_jsx("div", { ref: tail, className: clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto'), children: end })), _jsxs("div", { ref: measure, "aria-hidden": true, inert: true, className: "pointer-events-none invisible absolute top-0 left-0 flex gap-4", children: [tabs.map((tab) => (_jsxs("span", { className: TAB, children: [tab.props.children, tab.props.onClose !== undefined && _jsx("span", { className: "size-5 shrink-0" })] }, tab.key))), _jsxs("span", { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] })] })] }));
|
|
78
81
|
}
|
|
79
82
|
/* 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. */
|
|
80
83
|
export function TabsTab({ className, onClose, children, ...props }) {
|
package/dist/theme.css
CHANGED
|
@@ -134,6 +134,13 @@
|
|
|
134
134
|
--file-icon-mauve: #79697b;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/* Code a person reads as code (an editor, a grid of values, a statement) at one size the app may set on
|
|
138
|
+
`<html>`, apart from the interface's own type. Left alone it is `--text-code`. */
|
|
139
|
+
:root {
|
|
140
|
+
--code-font-size: var(--text-code);
|
|
141
|
+
--code-line-height: var(--text-code--line-height);
|
|
142
|
+
}
|
|
143
|
+
|
|
137
144
|
/* Who floats over whom. Whole numbers in one place, so a new popup cannot guess. */
|
|
138
145
|
:root {
|
|
139
146
|
--z-dialog-backdrop: 80;
|
|
@@ -628,6 +635,39 @@
|
|
|
628
635
|
.adecore-tree-decoration {
|
|
629
636
|
font-variant-numeric: tabular-nums;
|
|
630
637
|
}
|
|
638
|
+
/* A tree whose rows slide sideways: every part of a row keeps its width and moves with the shift. */
|
|
639
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-row {
|
|
640
|
+
overflow: clip;
|
|
641
|
+
}
|
|
642
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-row > * {
|
|
643
|
+
flex-shrink: 0;
|
|
644
|
+
}
|
|
645
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-row::after {
|
|
646
|
+
content: "";
|
|
647
|
+
flex: none;
|
|
648
|
+
order: -100;
|
|
649
|
+
margin-inline: calc(var(--adecore-tree-shift, 0px) * -1) calc(var(--tree-row-gap) * -1);
|
|
650
|
+
}
|
|
651
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-guide {
|
|
652
|
+
translate: calc(var(--adecore-tree-shift, 0px) * -1) 0;
|
|
653
|
+
}
|
|
654
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-label {
|
|
655
|
+
overflow: visible;
|
|
656
|
+
}
|
|
657
|
+
/* The bar keeps to the bottom of the tree however few rows there are: a column whose rows keep their
|
|
658
|
+
height, and the bar after them pushed down. Its own room is the space under the last row, so the
|
|
659
|
+
bar sits as far from the bottom edge mid-scroll as at the end. */
|
|
660
|
+
.adecore-tree[data-overflow="scroll"] {
|
|
661
|
+
display: flex;
|
|
662
|
+
flex-direction: column;
|
|
663
|
+
}
|
|
664
|
+
.adecore-tree[data-overflow="scroll"] > * {
|
|
665
|
+
flex-shrink: 0;
|
|
666
|
+
}
|
|
667
|
+
.adecore-tree[data-overflow="scroll"] > .adecore-tree-shift {
|
|
668
|
+
margin-top: auto;
|
|
669
|
+
height: 8px;
|
|
670
|
+
}
|
|
631
671
|
.adecore-file-tree {
|
|
632
672
|
position: relative;
|
|
633
673
|
min-height: 0;
|
package/dist/tree/parts.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useRender } from '@base-ui-components/react/use-render';
|
|
2
2
|
import { type CheckboxProps } from '../Checkbox.tsx';
|
|
3
|
-
export type TreeRootProps = useRender.ComponentProps<'div'
|
|
4
|
-
|
|
3
|
+
export type TreeRootProps = useRender.ComponentProps<'div'> & {
|
|
4
|
+
overflow?: 'truncate' | 'scroll';
|
|
5
|
+
};
|
|
6
|
+
export declare function TreeRoot({ overflow, render, className, ref, children, ...props }: TreeRootProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
5
7
|
export type TreeRowProps = useRender.ComponentProps<'div'> & {
|
|
6
8
|
level?: number;
|
|
7
9
|
selected?: boolean;
|
package/dist/tree/parts.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback } from 'react';
|
|
2
3
|
import clsx from 'clsx';
|
|
3
4
|
import { useRender } from '@base-ui-components/react/use-render';
|
|
4
5
|
import { ChevronRight } from 'lucide-react';
|
|
5
6
|
import { useTranslation } from 'react-i18next';
|
|
6
7
|
import { Checkbox as UICheckbox } from '../Checkbox.js';
|
|
7
8
|
import { Icon } from '../Icon.js';
|
|
9
|
+
import { mergeRefs } from '../merge-refs.js';
|
|
8
10
|
import { treeRowStyle } from './style.js';
|
|
9
|
-
|
|
11
|
+
import { TREE_ROWS, useTreeShift } from './use-tree-shift.js';
|
|
12
|
+
export function TreeRoot({ overflow = 'truncate', render, className, ref, children, ...props }) {
|
|
13
|
+
const { attach, bar } = useTreeShift(TREE_ROWS);
|
|
14
|
+
const scrolls = overflow === 'scroll';
|
|
15
|
+
const rootRef = useCallback((node) => {
|
|
16
|
+
attach(scrolls ? node : null);
|
|
17
|
+
mergeRefs(ref)(node);
|
|
18
|
+
}, [scrolls, ref, attach]);
|
|
10
19
|
return useRender({
|
|
11
20
|
render,
|
|
12
|
-
ref,
|
|
21
|
+
ref: rootRef,
|
|
13
22
|
defaultTagName: 'div',
|
|
14
23
|
props: {
|
|
15
24
|
role: 'tree',
|
|
16
25
|
...props,
|
|
26
|
+
'data-overflow': overflow,
|
|
17
27
|
className: clsx('adecore-tree', className),
|
|
18
|
-
children: (_jsxs(_Fragment, { children: [_jsx("style", { children: treeRowStyle('.adecore-tree-row') }), children] }))
|
|
28
|
+
children: (_jsxs(_Fragment, { children: [_jsx("style", { children: treeRowStyle('.adecore-tree-row') }), children, scrolls && bar] }))
|
|
19
29
|
}
|
|
20
30
|
});
|
|
21
31
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
export interface TreeShiftSource {
|
|
3
|
+
root(frame: HTMLElement): ParentNode | null;
|
|
4
|
+
rows(root: ParentNode): readonly HTMLElement[];
|
|
5
|
+
need(row: HTMLElement, shift: number, gap: number, range: Range): number | null;
|
|
6
|
+
}
|
|
7
|
+
export declare function useTreeShift(source: TreeShiftSource, resetKey?: string): {
|
|
8
|
+
attach(node: HTMLElement | null): void;
|
|
9
|
+
bar: ReactElement;
|
|
10
|
+
};
|
|
11
|
+
export declare const TREE_ROWS: TreeShiftSource;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from '../file-tree/shift.js';
|
|
4
|
+
const HIDE_AFTER = 900;
|
|
5
|
+
const NEAR_BOTTOM = 16;
|
|
6
|
+
/*
|
|
7
|
+
* Rows that are wider than the tree slide sideways together, by a shift every row reads from the
|
|
8
|
+
* frame's `--adecore-tree-shift`, with a thin bar that shows while a person scrolls or nears it. A
|
|
9
|
+
* shift instead of a scroll keeps every row's background and the vertical scroll where they are.
|
|
10
|
+
*/
|
|
11
|
+
export function useTreeShift(source, resetKey) {
|
|
12
|
+
const [frame, setFrame] = useState(null);
|
|
13
|
+
const [track, setTrack] = useState(null);
|
|
14
|
+
const [thumb, setThumb] = useState(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (frame === null || track === null || thumb === null) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const range = document.createRange();
|
|
20
|
+
let root = null;
|
|
21
|
+
let exact = 0;
|
|
22
|
+
let drawn = 0;
|
|
23
|
+
let max = 0;
|
|
24
|
+
let pending = 0;
|
|
25
|
+
let hideTimer = 0;
|
|
26
|
+
let scrolling = false;
|
|
27
|
+
let near = false;
|
|
28
|
+
const draw = () => {
|
|
29
|
+
const shift = Math.round(exact);
|
|
30
|
+
if (shift !== drawn) {
|
|
31
|
+
drawn = shift;
|
|
32
|
+
frame.style.setProperty(SHIFT_PROPERTY, `${shift}px`);
|
|
33
|
+
}
|
|
34
|
+
const { left, width } = shiftThumb(shift, max, track.clientWidth, frame.clientWidth);
|
|
35
|
+
thumb.style.left = `${left}px`;
|
|
36
|
+
thumb.style.width = `${width}px`;
|
|
37
|
+
track.toggleAttribute('data-visible', max > 0 && (scrolling || near));
|
|
38
|
+
};
|
|
39
|
+
const measure = () => {
|
|
40
|
+
pending = 0;
|
|
41
|
+
if (root === null) {
|
|
42
|
+
root = source.root(frame);
|
|
43
|
+
if (root === null) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
rows.observe(root, { childList: true, subtree: true, characterData: true });
|
|
47
|
+
}
|
|
48
|
+
const drawnRows = source.rows(root);
|
|
49
|
+
const gap = drawnRows.length === 0 ? 0 : Number.parseFloat(getComputedStyle(drawnRows[0]).columnGap) || 0;
|
|
50
|
+
max = maxShift(drawnRows.map((row) => source.need(row, drawn, gap, range)).filter((need) => need !== null));
|
|
51
|
+
exact = clampShift(exact, max);
|
|
52
|
+
draw();
|
|
53
|
+
};
|
|
54
|
+
const schedule = () => {
|
|
55
|
+
if (pending === 0) {
|
|
56
|
+
pending = requestAnimationFrame(measure);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const rows = new MutationObserver(schedule);
|
|
60
|
+
const mounts = new MutationObserver(schedule);
|
|
61
|
+
mounts.observe(frame, { childList: true, subtree: true });
|
|
62
|
+
const sizes = new ResizeObserver(schedule);
|
|
63
|
+
sizes.observe(frame);
|
|
64
|
+
const onWheel = (event) => {
|
|
65
|
+
const delta = sidewaysDelta(event, frame.clientWidth);
|
|
66
|
+
if (delta === 0 || max === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
scrolling = true;
|
|
70
|
+
window.clearTimeout(hideTimer);
|
|
71
|
+
hideTimer = window.setTimeout(() => {
|
|
72
|
+
scrolling = false;
|
|
73
|
+
draw();
|
|
74
|
+
}, HIDE_AFTER);
|
|
75
|
+
const next = clampShift(exact + delta, max);
|
|
76
|
+
if (next !== exact) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
exact = next;
|
|
79
|
+
}
|
|
80
|
+
draw();
|
|
81
|
+
};
|
|
82
|
+
const onPointerMove = (event) => {
|
|
83
|
+
const bottom = track.getBoundingClientRect().bottom;
|
|
84
|
+
const isNear = event.clientY >= bottom - NEAR_BOTTOM;
|
|
85
|
+
if (isNear !== near) {
|
|
86
|
+
near = isNear;
|
|
87
|
+
draw();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const onPointerLeave = () => {
|
|
91
|
+
if (near) {
|
|
92
|
+
near = false;
|
|
93
|
+
draw();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
frame.addEventListener('wheel', onWheel, { passive: false });
|
|
97
|
+
frame.addEventListener('pointermove', onPointerMove);
|
|
98
|
+
frame.addEventListener('pointerleave', onPointerLeave);
|
|
99
|
+
schedule();
|
|
100
|
+
return () => {
|
|
101
|
+
frame.removeEventListener('wheel', onWheel);
|
|
102
|
+
frame.removeEventListener('pointermove', onPointerMove);
|
|
103
|
+
frame.removeEventListener('pointerleave', onPointerLeave);
|
|
104
|
+
rows.disconnect();
|
|
105
|
+
mounts.disconnect();
|
|
106
|
+
sizes.disconnect();
|
|
107
|
+
cancelAnimationFrame(pending);
|
|
108
|
+
window.clearTimeout(hideTimer);
|
|
109
|
+
frame.style.removeProperty(SHIFT_PROPERTY);
|
|
110
|
+
track.removeAttribute('data-visible');
|
|
111
|
+
};
|
|
112
|
+
}, [frame, track, thumb, source, resetKey]);
|
|
113
|
+
const bar = (_jsx("div", { "aria-hidden": true, className: "adecore-tree-shift", children: _jsx("div", { ref: setTrack, className: "adecore-tree-shift-track", children: _jsx("div", { ref: setThumb, className: "adecore-tree-shift-thumb" }) }) }));
|
|
114
|
+
return { attach: setFrame, bar };
|
|
115
|
+
}
|
|
116
|
+
/* The rows of `Tree.Root`: everything a row holds slides, so a row reaches as far as its last part. */
|
|
117
|
+
export const TREE_ROWS = {
|
|
118
|
+
root: (frame) => frame,
|
|
119
|
+
rows: (root) => [...root.querySelectorAll('.adecore-tree-row')],
|
|
120
|
+
need: (row, shift) => {
|
|
121
|
+
const box = row.getBoundingClientRect();
|
|
122
|
+
if (box.width === 0) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const parts = [...row.children].filter((child) => !child.classList.contains('adecore-tree-guide'));
|
|
126
|
+
if (parts.length === 0) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const reach = Math.max(...parts.map((part) => part.getBoundingClientRect().right));
|
|
130
|
+
const limit = box.right - (Number.parseFloat(getComputedStyle(row).paddingInlineEnd) || 0);
|
|
131
|
+
return shiftNeed(reach, limit, shift);
|
|
132
|
+
}
|
|
133
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
package/src/Kbd.tsx
CHANGED
|
@@ -7,8 +7,10 @@ import { formatShortcut, shortcutParts, type Shortcut } from './shortcut.ts';
|
|
|
7
7
|
const INLINE_KBD = 'rounded-sm bg-surface-sunken px-[5px] py-px font-sans text-xs/[inherit] text-text-muted';
|
|
8
8
|
|
|
9
9
|
/* One key, drawn as a cap. A settings page prints a shortcut key by key, so a gesture can stand beside it. */
|
|
10
|
-
const KEY_CAP =
|
|
11
|
-
|
|
10
|
+
const KEY_CAP = 'inline-grid place-items-center rounded-md border border-b-2 border-border-strong bg-surface-hover bg-clip-padding font-sans text-text';
|
|
11
|
+
|
|
12
|
+
/* `sm` fits inside a small field, as the hint of the key that focuses it. */
|
|
13
|
+
const KEY_CAP_SIZES = { md: 'h-6 min-w-6 px-1.5 text-xs', sm: 'h-5 min-w-5 px-1 text-2xs' } as const;
|
|
12
14
|
|
|
13
15
|
export interface KbdProps {
|
|
14
16
|
/* Printed the way this platform writes it. Without one, the children are printed as they are (`↑`, `Esc`). */
|
|
@@ -34,13 +36,14 @@ export function Kbd({ shortcut, children, variant = 'menu', className, ref }: Kb
|
|
|
34
36
|
|
|
35
37
|
export interface KeyCapProps {
|
|
36
38
|
children: ReactNode;
|
|
39
|
+
size?: 'md' | 'sm';
|
|
37
40
|
className?: string;
|
|
38
41
|
ref?: Ref<HTMLElement>;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
export function KeyCap({ children, className, ref }: KeyCapProps) {
|
|
44
|
+
export function KeyCap({ children, size = 'md', className, ref }: KeyCapProps) {
|
|
42
45
|
return (
|
|
43
|
-
<kbd ref={ref} className={clsx(KEY_CAP, className)}>
|
|
46
|
+
<kbd ref={ref} className={clsx(KEY_CAP, KEY_CAP_SIZES[size], className)}>
|
|
44
47
|
{children}
|
|
45
48
|
</kbd>
|
|
46
49
|
);
|
|
@@ -50,17 +53,20 @@ export interface KeysProps {
|
|
|
50
53
|
shortcut: Shortcut;
|
|
51
54
|
/* The pointer gesture the keys go with, such as "drag", as one more cap. */
|
|
52
55
|
then?: string;
|
|
56
|
+
size?: 'md' | 'sm';
|
|
53
57
|
className?: string;
|
|
54
58
|
ref?: Ref<HTMLSpanElement>;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/* A shortcut as one cap per key, `⌘` and `K`, plus the pointer gesture it goes with. */
|
|
58
|
-
export function Keys({ shortcut, then, className, ref }: KeysProps) {
|
|
62
|
+
export function Keys({ shortcut, then, size = 'md', className, ref }: KeysProps) {
|
|
59
63
|
const parts = [...shortcutParts(shortcut, isApplePlatform()), ...(then ? [then] : [])];
|
|
60
64
|
return (
|
|
61
|
-
<span ref={ref} className={clsx('flex items-center gap-1', className)}>
|
|
65
|
+
<span ref={ref} className={clsx('flex items-center', size === 'sm' ? 'gap-0.5' : 'gap-1', className)}>
|
|
62
66
|
{parts.map((part, index) => (
|
|
63
|
-
<KeyCap key={`${part}-${index}`}
|
|
67
|
+
<KeyCap key={`${part}-${index}`} size={size}>
|
|
68
|
+
{part}
|
|
69
|
+
</KeyCap>
|
|
64
70
|
))}
|
|
65
71
|
</span>
|
|
66
72
|
);
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, type ReactElement } from 'react';
|
|
2
2
|
import type { FileTree } from '@pierre/trees';
|
|
3
|
-
import {
|
|
3
|
+
import { useTreeShift, type TreeShiftSource } from '../tree/use-tree-shift.tsx';
|
|
4
|
+
import { shiftNeed } from './shift.ts';
|
|
4
5
|
|
|
5
6
|
const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
|
|
6
7
|
|
|
7
|
-
const HIDE_AFTER = 900;
|
|
8
|
-
|
|
9
|
-
const NEAR_BOTTOM = 16;
|
|
10
|
-
|
|
11
8
|
function boxesOf(element: Element): Element[] {
|
|
12
9
|
return element.getClientRects().length > 0 ? [element] : [...element.children].flatMap((child) => boxesOf(child));
|
|
13
10
|
}
|
|
@@ -44,126 +41,15 @@ function needOf(row: HTMLElement, shift: number, gap: number, range: Range): num
|
|
|
44
41
|
return shiftNeed(range.getBoundingClientRect().right, limitOf(row, content, gap), shift);
|
|
45
42
|
}
|
|
46
43
|
|
|
44
|
+
/* The file tree draws its rows in a shadow root, and only the name slides: its decorations sit over the row. */
|
|
47
45
|
export function useFileTreeShift(model: FileTree, resetKey?: string): { attach(node: HTMLElement | null): void; bar: ReactElement } {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
const range = document.createRange();
|
|
57
|
-
let root: ShadowRoot | null = null;
|
|
58
|
-
let exact = 0;
|
|
59
|
-
let drawn = 0;
|
|
60
|
-
let max = 0;
|
|
61
|
-
let pending = 0;
|
|
62
|
-
let hideTimer = 0;
|
|
63
|
-
let scrolling = false;
|
|
64
|
-
let near = false;
|
|
65
|
-
|
|
66
|
-
const draw = (): void => {
|
|
67
|
-
const shift = Math.round(exact);
|
|
68
|
-
if (shift !== drawn) {
|
|
69
|
-
drawn = shift;
|
|
70
|
-
frame.style.setProperty(SHIFT_PROPERTY, `${shift}px`);
|
|
71
|
-
}
|
|
72
|
-
const { left, width } = shiftThumb(shift, max, track.clientWidth, frame.clientWidth);
|
|
73
|
-
thumb.style.left = `${left}px`;
|
|
74
|
-
thumb.style.width = `${width}px`;
|
|
75
|
-
track.toggleAttribute('data-visible', max > 0 && (scrolling || near));
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const measure = (): void => {
|
|
79
|
-
pending = 0;
|
|
80
|
-
if (root === null) {
|
|
81
|
-
root = model.getFileTreeContainer()?.shadowRoot ?? null;
|
|
82
|
-
if (root === null) {
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
rows.observe(root, { childList: true, subtree: true, characterData: true });
|
|
86
|
-
}
|
|
87
|
-
const drawnRows = [...root.querySelectorAll<HTMLElement>(ROWS)];
|
|
88
|
-
const gap = drawnRows.length === 0 ? 0 : Number.parseFloat(getComputedStyle(drawnRows[0]!).columnGap) || 0;
|
|
89
|
-
max = maxShift(drawnRows.map((row) => needOf(row, drawn, gap, range)).filter((need) => need !== null));
|
|
90
|
-
exact = clampShift(exact, max);
|
|
91
|
-
draw();
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const schedule = (): void => {
|
|
95
|
-
if (pending === 0) {
|
|
96
|
-
pending = requestAnimationFrame(measure);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const rows = new MutationObserver(schedule);
|
|
101
|
-
const mounts = new MutationObserver(schedule);
|
|
102
|
-
mounts.observe(frame, { childList: true, subtree: true });
|
|
103
|
-
const sizes = new ResizeObserver(schedule);
|
|
104
|
-
sizes.observe(frame);
|
|
105
|
-
|
|
106
|
-
const onWheel = (event: WheelEvent): void => {
|
|
107
|
-
const delta = sidewaysDelta(event, frame.clientWidth);
|
|
108
|
-
if (delta === 0 || max === 0) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
scrolling = true;
|
|
112
|
-
window.clearTimeout(hideTimer);
|
|
113
|
-
hideTimer = window.setTimeout(() => {
|
|
114
|
-
scrolling = false;
|
|
115
|
-
draw();
|
|
116
|
-
}, HIDE_AFTER);
|
|
117
|
-
const next = clampShift(exact + delta, max);
|
|
118
|
-
if (next !== exact) {
|
|
119
|
-
event.preventDefault();
|
|
120
|
-
exact = next;
|
|
121
|
-
}
|
|
122
|
-
draw();
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const onPointerMove = (event: PointerEvent): void => {
|
|
126
|
-
const bottom = track.getBoundingClientRect().bottom;
|
|
127
|
-
const isNear = event.clientY >= bottom - NEAR_BOTTOM;
|
|
128
|
-
if (isNear !== near) {
|
|
129
|
-
near = isNear;
|
|
130
|
-
draw();
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const onPointerLeave = (): void => {
|
|
135
|
-
if (near) {
|
|
136
|
-
near = false;
|
|
137
|
-
draw();
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
frame.addEventListener('wheel', onWheel, { passive: false });
|
|
142
|
-
frame.addEventListener('pointermove', onPointerMove);
|
|
143
|
-
frame.addEventListener('pointerleave', onPointerLeave);
|
|
144
|
-
schedule();
|
|
145
|
-
|
|
146
|
-
return () => {
|
|
147
|
-
frame.removeEventListener('wheel', onWheel);
|
|
148
|
-
frame.removeEventListener('pointermove', onPointerMove);
|
|
149
|
-
frame.removeEventListener('pointerleave', onPointerLeave);
|
|
150
|
-
rows.disconnect();
|
|
151
|
-
mounts.disconnect();
|
|
152
|
-
sizes.disconnect();
|
|
153
|
-
cancelAnimationFrame(pending);
|
|
154
|
-
window.clearTimeout(hideTimer);
|
|
155
|
-
frame.style.removeProperty(SHIFT_PROPERTY);
|
|
156
|
-
track.removeAttribute('data-visible');
|
|
157
|
-
};
|
|
158
|
-
}, [frame, track, thumb, model, resetKey]);
|
|
159
|
-
|
|
160
|
-
const bar = (
|
|
161
|
-
<div aria-hidden className="adecore-tree-shift">
|
|
162
|
-
<div ref={setTrack} className="adecore-tree-shift-track">
|
|
163
|
-
<div ref={setThumb} className="adecore-tree-shift-thumb" />
|
|
164
|
-
</div>
|
|
165
|
-
</div>
|
|
46
|
+
const source = useMemo(
|
|
47
|
+
(): TreeShiftSource => ({
|
|
48
|
+
root: () => model.getFileTreeContainer()?.shadowRoot ?? null,
|
|
49
|
+
rows: (root) => [...root.querySelectorAll<HTMLElement>(ROWS)],
|
|
50
|
+
need: needOf
|
|
51
|
+
}),
|
|
52
|
+
[model]
|
|
166
53
|
);
|
|
167
|
-
|
|
168
|
-
return { attach: setFrame, bar };
|
|
54
|
+
return useTreeShift(source, resetKey);
|
|
169
55
|
}
|
package/src/tabs/parts.tsx
CHANGED
|
@@ -66,6 +66,8 @@ export function TabsRoot({ value, defaultValue = 0, onValueChange, ...props }: T
|
|
|
66
66
|
export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, 'className'> & {
|
|
67
67
|
/* On the strip, which runs the width of the pane; padding here insets the tabs from its edges. */
|
|
68
68
|
className?: string;
|
|
69
|
+
/* Drawn before the tabs, such as what they are the views of. It keeps its own width, like `end`. */
|
|
70
|
+
start?: ReactNode;
|
|
69
71
|
/* Drawn after the tabs, such as a button that adds one. It keeps its own width: the tabs fit in what is left. */
|
|
70
72
|
end?: ReactNode;
|
|
71
73
|
};
|
|
@@ -78,11 +80,12 @@ const sameIndices = (left: readonly number[] | null, right: readonly number[]):
|
|
|
78
80
|
* in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
|
|
79
81
|
* since the menu repeats what a tab holds.
|
|
80
82
|
*/
|
|
81
|
-
export function TabsList({ className, children, end, ...props }: TabsListProps) {
|
|
83
|
+
export function TabsList({ className, children, start, end, ...props }: TabsListProps) {
|
|
82
84
|
const { t } = useTranslation('ui');
|
|
83
85
|
const { value, select } = useContext(SelectionContext);
|
|
84
86
|
const strip = useRef<HTMLDivElement>(null);
|
|
85
87
|
const measure = useRef<HTMLDivElement>(null);
|
|
88
|
+
const head = useRef<HTMLDivElement>(null);
|
|
86
89
|
const tail = useRef<HTMLDivElement>(null);
|
|
87
90
|
// Null until the first measure, which runs before the first paint.
|
|
88
91
|
const [shown, setShown] = useState<number[] | null>(null);
|
|
@@ -100,7 +103,7 @@ export function TabsList({ className, children, end, ...props }: TabsListProps)
|
|
|
100
103
|
const fit = (): void => {
|
|
101
104
|
const style = getComputedStyle(host);
|
|
102
105
|
const gap = parseFloat(style.columnGap) || 0;
|
|
103
|
-
const taken = tail.current === null ?
|
|
106
|
+
const taken = [head.current, tail.current].reduce((sum, part) => (part === null ? sum : sum + part.getBoundingClientRect().width + gap), 0);
|
|
104
107
|
const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - taken;
|
|
105
108
|
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
|
106
109
|
const more = cells.pop() ?? 0;
|
|
@@ -111,8 +114,10 @@ export function TabsList({ className, children, end, ...props }: TabsListProps)
|
|
|
111
114
|
const observer = new ResizeObserver(fit);
|
|
112
115
|
observer.observe(host);
|
|
113
116
|
observer.observe(row);
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
for (const part of [head.current, tail.current]) {
|
|
118
|
+
if (part !== null) {
|
|
119
|
+
observer.observe(part);
|
|
120
|
+
}
|
|
116
121
|
}
|
|
117
122
|
return () => observer.disconnect();
|
|
118
123
|
});
|
|
@@ -122,6 +127,11 @@ export function TabsList({ className, children, end, ...props }: TabsListProps)
|
|
|
122
127
|
|
|
123
128
|
return (
|
|
124
129
|
<div ref={strip} className={clsx('relative flex items-center gap-4 border-b border-border', className)}>
|
|
130
|
+
{start !== undefined && start !== null && (
|
|
131
|
+
<div ref={head} className="flex shrink-0 items-center gap-1">
|
|
132
|
+
{start}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
125
135
|
<BaseTabs.List className="flex min-w-0 items-end gap-4" {...props}>
|
|
126
136
|
{visible}
|
|
127
137
|
</BaseTabs.List>
|
package/src/theme.css
CHANGED
|
@@ -134,6 +134,13 @@
|
|
|
134
134
|
--file-icon-mauve: #79697b;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/* Code a person reads as code (an editor, a grid of values, a statement) at one size the app may set on
|
|
138
|
+
`<html>`, apart from the interface's own type. Left alone it is `--text-code`. */
|
|
139
|
+
:root {
|
|
140
|
+
--code-font-size: var(--text-code);
|
|
141
|
+
--code-line-height: var(--text-code--line-height);
|
|
142
|
+
}
|
|
143
|
+
|
|
137
144
|
/* Who floats over whom. Whole numbers in one place, so a new popup cannot guess. */
|
|
138
145
|
:root {
|
|
139
146
|
--z-dialog-backdrop: 80;
|
|
@@ -628,6 +635,39 @@
|
|
|
628
635
|
.adecore-tree-decoration {
|
|
629
636
|
font-variant-numeric: tabular-nums;
|
|
630
637
|
}
|
|
638
|
+
/* A tree whose rows slide sideways: every part of a row keeps its width and moves with the shift. */
|
|
639
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-row {
|
|
640
|
+
overflow: clip;
|
|
641
|
+
}
|
|
642
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-row > * {
|
|
643
|
+
flex-shrink: 0;
|
|
644
|
+
}
|
|
645
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-row::after {
|
|
646
|
+
content: "";
|
|
647
|
+
flex: none;
|
|
648
|
+
order: -100;
|
|
649
|
+
margin-inline: calc(var(--adecore-tree-shift, 0px) * -1) calc(var(--tree-row-gap) * -1);
|
|
650
|
+
}
|
|
651
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-guide {
|
|
652
|
+
translate: calc(var(--adecore-tree-shift, 0px) * -1) 0;
|
|
653
|
+
}
|
|
654
|
+
.adecore-tree[data-overflow="scroll"] .adecore-tree-label {
|
|
655
|
+
overflow: visible;
|
|
656
|
+
}
|
|
657
|
+
/* The bar keeps to the bottom of the tree however few rows there are: a column whose rows keep their
|
|
658
|
+
height, and the bar after them pushed down. Its own room is the space under the last row, so the
|
|
659
|
+
bar sits as far from the bottom edge mid-scroll as at the end. */
|
|
660
|
+
.adecore-tree[data-overflow="scroll"] {
|
|
661
|
+
display: flex;
|
|
662
|
+
flex-direction: column;
|
|
663
|
+
}
|
|
664
|
+
.adecore-tree[data-overflow="scroll"] > * {
|
|
665
|
+
flex-shrink: 0;
|
|
666
|
+
}
|
|
667
|
+
.adecore-tree[data-overflow="scroll"] > .adecore-tree-shift {
|
|
668
|
+
margin-top: auto;
|
|
669
|
+
height: 8px;
|
|
670
|
+
}
|
|
631
671
|
.adecore-file-tree {
|
|
632
672
|
position: relative;
|
|
633
673
|
min-height: 0;
|
package/src/tree/parts.tsx
CHANGED
|
@@ -1,27 +1,43 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useCallback, type MouseEvent, type KeyboardEvent, type PointerEvent } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useRender } from '@base-ui-components/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';
|
|
7
7
|
import { Icon } from '../Icon.tsx';
|
|
8
|
+
import { mergeRefs } from '../merge-refs.ts';
|
|
8
9
|
import { treeRowStyle } from './style.ts';
|
|
10
|
+
import { TREE_ROWS, useTreeShift } from './use-tree-shift.tsx';
|
|
9
11
|
|
|
10
|
-
export type TreeRootProps = useRender.ComponentProps<'div'
|
|
12
|
+
export type TreeRootProps = useRender.ComponentProps<'div'> & {
|
|
13
|
+
/* What a row wider than the tree does: cut its name off at the end, or keep the row whole and let every row slide sideways together, as a file tree does. */
|
|
14
|
+
overflow?: 'truncate' | 'scroll';
|
|
15
|
+
};
|
|
11
16
|
|
|
12
|
-
export function TreeRoot({ render, className, ref, children, ...props }: TreeRootProps) {
|
|
17
|
+
export function TreeRoot({ overflow = 'truncate', render, className, ref, children, ...props }: TreeRootProps) {
|
|
18
|
+
const { attach, bar } = useTreeShift(TREE_ROWS);
|
|
19
|
+
const scrolls = overflow === 'scroll';
|
|
20
|
+
const rootRef = useCallback(
|
|
21
|
+
(node: HTMLDivElement | null) => {
|
|
22
|
+
attach(scrolls ? node : null);
|
|
23
|
+
mergeRefs<HTMLDivElement>(ref)(node);
|
|
24
|
+
},
|
|
25
|
+
[scrolls, ref, attach]
|
|
26
|
+
);
|
|
13
27
|
return useRender({
|
|
14
28
|
render,
|
|
15
|
-
ref,
|
|
29
|
+
ref: rootRef,
|
|
16
30
|
defaultTagName: 'div',
|
|
17
31
|
props: {
|
|
18
32
|
role: 'tree',
|
|
19
33
|
...props,
|
|
34
|
+
'data-overflow': overflow,
|
|
20
35
|
className: clsx('adecore-tree', className),
|
|
21
36
|
children: (
|
|
22
37
|
<>
|
|
23
38
|
<style>{treeRowStyle('.adecore-tree-row')}</style>
|
|
24
39
|
{children}
|
|
40
|
+
{scrolls && bar}
|
|
25
41
|
</>
|
|
26
42
|
)
|
|
27
43
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactElement } from 'react';
|
|
2
|
+
import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from '../file-tree/shift.ts';
|
|
3
|
+
|
|
4
|
+
const HIDE_AFTER = 900;
|
|
5
|
+
|
|
6
|
+
const NEAR_BOTTOM = 16;
|
|
7
|
+
|
|
8
|
+
/* Where a tree draws its rows, and how far a row reaches past the edge it may use. */
|
|
9
|
+
export interface TreeShiftSource {
|
|
10
|
+
/* The node whose changes add, remove or rename rows; null while the tree has not drawn it yet. */
|
|
11
|
+
root(frame: HTMLElement): ParentNode | null;
|
|
12
|
+
rows(root: ParentNode): readonly HTMLElement[];
|
|
13
|
+
/* How many pixels of the row lie past its edge at no shift; null for a row that is not laid out. */
|
|
14
|
+
need(row: HTMLElement, shift: number, gap: number, range: Range): number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Rows that are wider than the tree slide sideways together, by a shift every row reads from the
|
|
19
|
+
* frame's `--adecore-tree-shift`, with a thin bar that shows while a person scrolls or nears it. A
|
|
20
|
+
* shift instead of a scroll keeps every row's background and the vertical scroll where they are.
|
|
21
|
+
*/
|
|
22
|
+
export function useTreeShift(source: TreeShiftSource, resetKey?: string): { attach(node: HTMLElement | null): void; bar: ReactElement } {
|
|
23
|
+
const [frame, setFrame] = useState<HTMLElement | null>(null);
|
|
24
|
+
const [track, setTrack] = useState<HTMLDivElement | null>(null);
|
|
25
|
+
const [thumb, setThumb] = useState<HTMLDivElement | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (frame === null || track === null || thumb === null) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const range = document.createRange();
|
|
32
|
+
let root: ParentNode | null = null;
|
|
33
|
+
let exact = 0;
|
|
34
|
+
let drawn = 0;
|
|
35
|
+
let max = 0;
|
|
36
|
+
let pending = 0;
|
|
37
|
+
let hideTimer = 0;
|
|
38
|
+
let scrolling = false;
|
|
39
|
+
let near = false;
|
|
40
|
+
|
|
41
|
+
const draw = (): void => {
|
|
42
|
+
const shift = Math.round(exact);
|
|
43
|
+
if (shift !== drawn) {
|
|
44
|
+
drawn = shift;
|
|
45
|
+
frame.style.setProperty(SHIFT_PROPERTY, `${shift}px`);
|
|
46
|
+
}
|
|
47
|
+
const { left, width } = shiftThumb(shift, max, track.clientWidth, frame.clientWidth);
|
|
48
|
+
thumb.style.left = `${left}px`;
|
|
49
|
+
thumb.style.width = `${width}px`;
|
|
50
|
+
track.toggleAttribute('data-visible', max > 0 && (scrolling || near));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const measure = (): void => {
|
|
54
|
+
pending = 0;
|
|
55
|
+
if (root === null) {
|
|
56
|
+
root = source.root(frame);
|
|
57
|
+
if (root === null) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
rows.observe(root, { childList: true, subtree: true, characterData: true });
|
|
61
|
+
}
|
|
62
|
+
const drawnRows = source.rows(root);
|
|
63
|
+
const gap = drawnRows.length === 0 ? 0 : Number.parseFloat(getComputedStyle(drawnRows[0]!).columnGap) || 0;
|
|
64
|
+
max = maxShift(drawnRows.map((row) => source.need(row, drawn, gap, range)).filter((need) => need !== null));
|
|
65
|
+
exact = clampShift(exact, max);
|
|
66
|
+
draw();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const schedule = (): void => {
|
|
70
|
+
if (pending === 0) {
|
|
71
|
+
pending = requestAnimationFrame(measure);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const rows = new MutationObserver(schedule);
|
|
76
|
+
const mounts = new MutationObserver(schedule);
|
|
77
|
+
mounts.observe(frame, { childList: true, subtree: true });
|
|
78
|
+
const sizes = new ResizeObserver(schedule);
|
|
79
|
+
sizes.observe(frame);
|
|
80
|
+
|
|
81
|
+
const onWheel = (event: WheelEvent): void => {
|
|
82
|
+
const delta = sidewaysDelta(event, frame.clientWidth);
|
|
83
|
+
if (delta === 0 || max === 0) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
scrolling = true;
|
|
87
|
+
window.clearTimeout(hideTimer);
|
|
88
|
+
hideTimer = window.setTimeout(() => {
|
|
89
|
+
scrolling = false;
|
|
90
|
+
draw();
|
|
91
|
+
}, HIDE_AFTER);
|
|
92
|
+
const next = clampShift(exact + delta, max);
|
|
93
|
+
if (next !== exact) {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
exact = next;
|
|
96
|
+
}
|
|
97
|
+
draw();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
101
|
+
const bottom = track.getBoundingClientRect().bottom;
|
|
102
|
+
const isNear = event.clientY >= bottom - NEAR_BOTTOM;
|
|
103
|
+
if (isNear !== near) {
|
|
104
|
+
near = isNear;
|
|
105
|
+
draw();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const onPointerLeave = (): void => {
|
|
110
|
+
if (near) {
|
|
111
|
+
near = false;
|
|
112
|
+
draw();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
frame.addEventListener('wheel', onWheel, { passive: false });
|
|
117
|
+
frame.addEventListener('pointermove', onPointerMove);
|
|
118
|
+
frame.addEventListener('pointerleave', onPointerLeave);
|
|
119
|
+
schedule();
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
frame.removeEventListener('wheel', onWheel);
|
|
123
|
+
frame.removeEventListener('pointermove', onPointerMove);
|
|
124
|
+
frame.removeEventListener('pointerleave', onPointerLeave);
|
|
125
|
+
rows.disconnect();
|
|
126
|
+
mounts.disconnect();
|
|
127
|
+
sizes.disconnect();
|
|
128
|
+
cancelAnimationFrame(pending);
|
|
129
|
+
window.clearTimeout(hideTimer);
|
|
130
|
+
frame.style.removeProperty(SHIFT_PROPERTY);
|
|
131
|
+
track.removeAttribute('data-visible');
|
|
132
|
+
};
|
|
133
|
+
}, [frame, track, thumb, source, resetKey]);
|
|
134
|
+
|
|
135
|
+
const bar = (
|
|
136
|
+
<div aria-hidden className="adecore-tree-shift">
|
|
137
|
+
<div ref={setTrack} className="adecore-tree-shift-track">
|
|
138
|
+
<div ref={setThumb} className="adecore-tree-shift-thumb" />
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return { attach: setFrame, bar };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* The rows of `Tree.Root`: everything a row holds slides, so a row reaches as far as its last part. */
|
|
147
|
+
export const TREE_ROWS: TreeShiftSource = {
|
|
148
|
+
root: (frame) => frame,
|
|
149
|
+
rows: (root) => [...root.querySelectorAll<HTMLElement>('.adecore-tree-row')],
|
|
150
|
+
need: (row, shift) => {
|
|
151
|
+
const box = row.getBoundingClientRect();
|
|
152
|
+
if (box.width === 0) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const parts = [...row.children].filter((child) => !child.classList.contains('adecore-tree-guide'));
|
|
156
|
+
if (parts.length === 0) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const reach = Math.max(...parts.map((part) => part.getBoundingClientRect().right));
|
|
160
|
+
const limit = box.right - (Number.parseFloat(getComputedStyle(row).paddingInlineEnd) || 0);
|
|
161
|
+
return shiftNeed(reach, limit, shift);
|
|
162
|
+
}
|
|
163
|
+
};
|