@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 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 h-6 min-w-6 place-items-center rounded-md border border-b-2 border-border-strong bg-surface-hover bg-clip-padding px-1.5 font-sans text-xs text-text';
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 { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from './shift.js';
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 [frame, setFrame] = useState(null);
40
- const [track, setTrack] = useState(null);
41
- const [thumb, setThumb] = useState(null);
42
- useEffect(() => {
43
- if (frame === null || track === null || thumb === null) {
44
- return;
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
  }
@@ -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;
@@ -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 ? 0 : tail.current.getBoundingClientRect().width + gap;
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
- if (tail.current !== null) {
71
- observer.observe(tail.current);
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;
@@ -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
- export declare function TreeRoot({ render, className, ref, children, ...props }: TreeRootProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
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;
@@ -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
- export function TreeRoot({ render, className, ref, children, ...props }) {
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.17.0-beta.2",
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
- 'inline-grid h-6 min-w-6 place-items-center rounded-md border border-b-2 border-border-strong bg-surface-hover bg-clip-padding px-1.5 font-sans text-xs text-text';
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}`}>{part}</KeyCap>
67
+ <KeyCap key={`${part}-${index}`} size={size}>
68
+ {part}
69
+ </KeyCap>
64
70
  ))}
65
71
  </span>
66
72
  );
@@ -1,13 +1,10 @@
1
- import { useEffect, useState, type ReactElement } from 'react';
1
+ import { useMemo, type ReactElement } from 'react';
2
2
  import type { FileTree } from '@pierre/trees';
3
- import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from './shift.ts';
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 [frame, setFrame] = useState<HTMLElement | null>(null);
49
- const [track, setTrack] = useState<HTMLDivElement | null>(null);
50
- const [thumb, setThumb] = useState<HTMLDivElement | null>(null);
51
-
52
- useEffect(() => {
53
- if (frame === null || track === null || thumb === null) {
54
- return;
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
  }
@@ -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 ? 0 : tail.current.getBoundingClientRect().width + gap;
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
- if (tail.current !== null) {
115
- observer.observe(tail.current);
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;
@@ -1,27 +1,43 @@
1
- import type { MouseEvent, KeyboardEvent, PointerEvent } from 'react';
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
+ };