@adecore/ui 0.24.0 → 0.24.2
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/LazyBoundary.d.ts +6 -0
- package/dist/LazyBoundary.js +12 -0
- package/dist/file-tree/helpers.js +2 -2
- package/dist/format/locale.d.ts +1 -0
- package/dist/format/locale.js +9 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/lazy-landings.d.ts +3 -0
- package/dist/lazy-landings.js +18 -0
- package/dist/lazy.d.ts +4 -1
- package/dist/lazy.js +7 -1
- package/dist/settings/SettingsDialog.js +3 -2
- package/dist/theme.css +3 -2
- package/dist/workspace/TabStrip.js +42 -7
- package/dist/workspace/geometry.d.ts +11 -0
- package/dist/workspace/geometry.js +19 -0
- package/package.json +1 -1
- package/src/LazyBoundary.tsx +17 -0
- package/src/file-tree/helpers.ts +2 -2
- package/src/format/locale.ts +11 -0
- package/src/index.ts +3 -0
- package/src/lazy-landings.ts +21 -0
- package/src/lazy.tsx +11 -2
- package/src/settings/SettingsDialog.tsx +5 -4
- package/src/theme.css +3 -2
- package/src/workspace/TabStrip.tsx +43 -7
- package/src/workspace/geometry.ts +26 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Suspense, useSyncExternalStore } from 'react';
|
|
3
|
+
import { readLandings, subscribeLandings } from './lazy-landings.js';
|
|
4
|
+
/*
|
|
5
|
+
* `Suspense` for what `lazyNamed` loads. React holds content that comes in as a retry back until
|
|
6
|
+
* 300 ms after its fallback showed; this boundary draws again as an ordinary update once a module
|
|
7
|
+
* lands, so a chunk that takes 20 ms shows after 20 ms.
|
|
8
|
+
*/
|
|
9
|
+
export function LazyBoundary({ fallback = null, children }) {
|
|
10
|
+
useSyncExternalStore(subscribeLandings, readLandings, readLandings);
|
|
11
|
+
return _jsx(Suspense, { fallback: fallback, children: children });
|
|
12
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nameOrder } from '../format/locale.js';
|
|
1
2
|
// Expanded folders can reveal more flattened rows; cap custom-key cycles.
|
|
2
3
|
const EXPANSION_PASSES = 32;
|
|
3
4
|
export function directoryHandle(model, path) {
|
|
@@ -126,8 +127,7 @@ export function compareRows(left, right) {
|
|
|
126
127
|
if (leftIsDirectory !== rightIsDirectory) {
|
|
127
128
|
return leftIsDirectory ? -1 : 1;
|
|
128
129
|
}
|
|
129
|
-
|
|
130
|
-
return leftSegment.localeCompare(rightSegment, undefined, { numeric: true, sensitivity: 'base' }) || leftSegment.localeCompare(rightSegment);
|
|
130
|
+
return nameOrder(leftSegment, rightSegment);
|
|
131
131
|
}
|
|
132
132
|
// One path is the head of the other, so the shorter one is the directory the longer one sits in.
|
|
133
133
|
return left.segments.length - right.segments.length;
|
package/dist/format/locale.d.ts
CHANGED
|
@@ -15,3 +15,4 @@ export declare const dateFormatter: (options: Intl.DateTimeFormatOptions) => Int
|
|
|
15
15
|
export declare const wordFormatter: (options: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat;
|
|
16
16
|
export declare const relativeFormatter: (options: Intl.RelativeTimeFormatOptions) => Intl.RelativeTimeFormat;
|
|
17
17
|
export declare const labelCollator: () => Intl.Collator;
|
|
18
|
+
export declare const nameOrder: (left: string, right: string) => number;
|
package/dist/format/locale.js
CHANGED
|
@@ -108,3 +108,12 @@ export const labelCollator = () => {
|
|
|
108
108
|
collators.set(locale, collator);
|
|
109
109
|
return collator;
|
|
110
110
|
};
|
|
111
|
+
let nameCollators = null;
|
|
112
|
+
/*
|
|
113
|
+
* The order of names that are no words, such as files: the runtime's own locale, numbers by value and
|
|
114
|
+
* case only to break a tie. It is what `localeCompare` did, which built a collator on every call.
|
|
115
|
+
*/
|
|
116
|
+
export const nameOrder = (left, right) => {
|
|
117
|
+
nameCollators ??= { loose: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), exact: new Intl.Collator() };
|
|
118
|
+
return nameCollators.loose.compare(left, right) || nameCollators.exact.compare(left, right);
|
|
119
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -67,6 +67,9 @@ export type { ResetKeys } from './error-boundary.ts';
|
|
|
67
67
|
export { FILE_TREE_ICONS } from './file-icon.ts';
|
|
68
68
|
export { cameThroughPortal, isInFloatingLayer } from './floating.ts';
|
|
69
69
|
export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.tsx';
|
|
70
|
+
export type { LazyComponent } from './lazy.tsx';
|
|
71
|
+
export { LazyBoundary } from './LazyBoundary.tsx';
|
|
72
|
+
export type { LazyBoundaryProps } from './LazyBoundary.tsx';
|
|
70
73
|
export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.ts';
|
|
71
74
|
export { startInputModality } from './modality.ts';
|
|
72
75
|
export { isApplePlatform } from './platform.ts';
|
package/dist/index.js
CHANGED
|
@@ -65,6 +65,7 @@ export { messageOf } from './error-message.js';
|
|
|
65
65
|
export { FILE_TREE_ICONS } from './file-icon.js';
|
|
66
66
|
export { cameThroughPortal, isInFloatingLayer } from './floating.js';
|
|
67
67
|
export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.js';
|
|
68
|
+
export { LazyBoundary } from './LazyBoundary.js';
|
|
68
69
|
export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.js';
|
|
69
70
|
export { startInputModality } from './modality.js';
|
|
70
71
|
export { isApplePlatform } from './platform.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
let landings = 0;
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
/* Called once per module, when a `LoadedComponent` first has it. */
|
|
4
|
+
export function noteLanding() {
|
|
5
|
+
landings += 1;
|
|
6
|
+
for (const listener of listeners) {
|
|
7
|
+
listener();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function subscribeLandings(listener) {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(listener);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function readLandings() {
|
|
17
|
+
return landings;
|
|
18
|
+
}
|
package/dist/lazy.d.ts
CHANGED
|
@@ -8,5 +8,8 @@ export declare class LoadedComponent<Component> {
|
|
|
8
8
|
subscribe(listener: () => void): () => void;
|
|
9
9
|
}
|
|
10
10
|
export declare function onLazyOpenError(listener: (error: unknown) => void): () => void;
|
|
11
|
-
export
|
|
11
|
+
export type LazyComponent<Props> = ComponentType<Props> & {
|
|
12
|
+
readonly preload: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export declare function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(load: () => Promise<Module>, name: Name): LazyComponent<ComponentProps<Module[Name]>>;
|
|
12
15
|
export declare function lazyDialog<Module extends Record<Name, ComponentType>, Name extends keyof Module, State>(load: () => Promise<Module>, name: Name, useStore: (select: (state: State) => boolean) => boolean, isOpen: (state: State) => boolean): ComponentType;
|
package/dist/lazy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Suspense, lazy, useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import { noteLanding } from './lazy-landings.js';
|
|
3
4
|
import { prefetcher } from './prefetch.js';
|
|
4
5
|
/*
|
|
5
6
|
* A module's component, loaded once and kept. `React.lazy` suspends for a tick even on a module the
|
|
@@ -20,6 +21,7 @@ export class LoadedComponent {
|
|
|
20
21
|
for (const listener of this.listeners) {
|
|
21
22
|
listener();
|
|
22
23
|
}
|
|
24
|
+
noteLanding();
|
|
23
25
|
}
|
|
24
26
|
return component;
|
|
25
27
|
}
|
|
@@ -68,7 +70,11 @@ export function lazyNamed(load, name) {
|
|
|
68
70
|
const Component = loaded.current ?? Lazy;
|
|
69
71
|
return _jsx(Component, { ...props });
|
|
70
72
|
};
|
|
71
|
-
return LazyNamed
|
|
73
|
+
return Object.assign(LazyNamed, {
|
|
74
|
+
preload: async () => {
|
|
75
|
+
await loaded.load();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
72
78
|
}
|
|
73
79
|
/* Stays true after the first opening, so every close after it still has something mounted to animate. */
|
|
74
80
|
function useOpenedOnce(open) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { Tabs } from '@base-ui/react/tabs';
|
|
@@ -9,6 +9,7 @@ import { DialogDescription, DialogPopup, DialogRoot, DialogTitle } from '../dial
|
|
|
9
9
|
import { ErrorBoundary } from '../ErrorBoundary.js';
|
|
10
10
|
import { Icon } from '../Icon.js';
|
|
11
11
|
import { IconButton } from '../IconButton.js';
|
|
12
|
+
import { LazyBoundary } from '../LazyBoundary.js';
|
|
12
13
|
import { Input } from '../Input.js';
|
|
13
14
|
import { Select } from '../Select.js';
|
|
14
15
|
import { SettingsTargetContext } from './target.js';
|
|
@@ -91,6 +92,6 @@ export function SettingsDialog({ open, onOpenChange, section, onNavigate, groups
|
|
|
91
92
|
const top = event.currentTarget.scrollTop;
|
|
92
93
|
event.currentTarget.toggleAttribute('data-fade-start', top > 0);
|
|
93
94
|
barFill.current?.style.setProperty('opacity', String(Math.min(top / BAR_FILL_DISTANCE, 1)));
|
|
94
|
-
}, children: _jsx(ErrorBoundary, { label: t('settings.failed'), className: "min-h-0 grow", children: _jsxs(
|
|
95
|
+
}, children: _jsx(ErrorBoundary, { label: t('settings.failed'), className: "min-h-0 grow", children: _jsxs(LazyBoundary, { children: [Hero && (_jsx("div", { className: "-mx-8 shrink-0 max-[960px]:-mx-4", children: _jsx(Hero, {}) })), _jsx(Pane, {})] }) }) }, entry.id));
|
|
95
96
|
}) })] })] }), _jsx(DialogDescription, { className: "sr-only", children: meta.description })] }) }));
|
|
96
97
|
}
|
package/dist/theme.css
CHANGED
|
@@ -501,12 +501,13 @@
|
|
|
501
501
|
/* A strip that scrolls sideways fades out at the edge that still hides something, and only there,
|
|
502
502
|
so a strip that fits keeps its first and last item sharp. The edges are set by whoever scrolls. */
|
|
503
503
|
.scroll-fade-x {
|
|
504
|
+
--fade-size: 24px;
|
|
504
505
|
--fade-start: 0px;
|
|
505
506
|
--fade-end: 0px;
|
|
506
507
|
mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
|
|
507
508
|
}
|
|
508
|
-
.scroll-fade-x[data-fade-start] { --fade-start:
|
|
509
|
-
.scroll-fade-x[data-fade-end] { --fade-end:
|
|
509
|
+
.scroll-fade-x[data-fade-start] { --fade-start: var(--fade-size); }
|
|
510
|
+
.scroll-fade-x[data-fade-end] { --fade-end: var(--fade-size); }
|
|
510
511
|
/* The same for a column under a fixed header: its top fades once something has scrolled under it. */
|
|
511
512
|
.scroll-fade-top {
|
|
512
513
|
--fade-start: 0px;
|
|
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
import { Icon } from '../Icon.js';
|
|
8
8
|
import { IconButton } from '../IconButton.js';
|
|
9
9
|
import { Tooltip } from '../Tooltip.js';
|
|
10
|
-
import { tabGapAt } from './geometry.js';
|
|
10
|
+
import { tabGapAt, tabRevealScrollLeft } from './geometry.js';
|
|
11
11
|
/* Where an arrow, Home or End moves the selection from `index`, wrapping at either end; null for any other key. */
|
|
12
12
|
function tabIndexForKey(key, index, count) {
|
|
13
13
|
switch (key) {
|
|
@@ -62,20 +62,44 @@ export function TabStrip({ items, value, onValueChange, renderTab, focused = tru
|
|
|
62
62
|
setInsertion(after?.offsetLeft ?? (last ? last.offsetLeft + last.offsetWidth : 0));
|
|
63
63
|
}
|
|
64
64
|
}, [insertAt]);
|
|
65
|
-
|
|
65
|
+
// Stays set after the active tab changes, as its width can settle frames later; a person's own scroll ends it.
|
|
66
|
+
const revealing = useRef(false);
|
|
67
|
+
const reveal = () => {
|
|
68
|
+
const element = strip.current;
|
|
69
|
+
const selected = element?.querySelector('[aria-selected="true"]');
|
|
70
|
+
if (!revealing.current || !element || !selected) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const container = selected.closest('[data-document-tab]');
|
|
74
|
+
const tab = container && element.contains(container) ? container : selected;
|
|
75
|
+
const fade = parseFloat(getComputedStyle(element).getPropertyValue('--fade-size')) || 0;
|
|
76
|
+
const bounds = element.getBoundingClientRect();
|
|
77
|
+
const rect = tab.getBoundingClientRect();
|
|
78
|
+
const left = tabRevealScrollLeft(element, rect.left - bounds.left - element.clientLeft + element.scrollLeft, rect.width, fade);
|
|
79
|
+
if (left !== element.scrollLeft) {
|
|
80
|
+
element.scrollLeft = left;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
useLayoutEffect(() => {
|
|
84
|
+
revealing.current = true;
|
|
85
|
+
}, [value]);
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
reveal();
|
|
88
|
+
measure();
|
|
89
|
+
});
|
|
66
90
|
useEffect(() => {
|
|
67
91
|
const element = strip.current;
|
|
68
92
|
if (!element || typeof ResizeObserver === 'undefined') {
|
|
69
93
|
return;
|
|
70
94
|
}
|
|
71
|
-
const observer = new ResizeObserver(
|
|
95
|
+
const observer = new ResizeObserver(() => {
|
|
96
|
+
reveal();
|
|
97
|
+
measure();
|
|
98
|
+
});
|
|
72
99
|
observer.observe(element);
|
|
73
100
|
element.querySelectorAll('[data-document-tab]').forEach((tab) => observer.observe(tab));
|
|
74
101
|
return () => observer.disconnect();
|
|
75
102
|
});
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
78
|
-
}, [value]);
|
|
79
103
|
const gap = (event) => {
|
|
80
104
|
const rects = Array.from(event.currentTarget.querySelectorAll('[data-document-tab]'), (tab) => tab.getBoundingClientRect());
|
|
81
105
|
return tabGapAt(rects, event.clientX);
|
|
@@ -110,10 +134,21 @@ export function TabStrip({ items, value, onValueChange, renderTab, focused = tru
|
|
|
110
134
|
},
|
|
111
135
|
onWheel: (event) => {
|
|
112
136
|
props.onWheel?.(event);
|
|
113
|
-
if (
|
|
137
|
+
if (event.defaultPrevented) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
revealing.current = false;
|
|
141
|
+
if (!event.deltaX && event.deltaY) {
|
|
114
142
|
event.currentTarget.scrollLeft += event.deltaY;
|
|
115
143
|
}
|
|
116
144
|
},
|
|
145
|
+
onPointerDown: (event) => {
|
|
146
|
+
props.onPointerDown?.(event);
|
|
147
|
+
// A press on the strip itself grabs its scrollbar, and a touch may pan it.
|
|
148
|
+
if (event.target === event.currentTarget || event.pointerType === 'touch') {
|
|
149
|
+
revealing.current = false;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
117
152
|
onDragOver: (event) => {
|
|
118
153
|
props.onDragOver?.(event);
|
|
119
154
|
if (!event.defaultPrevented) {
|
|
@@ -37,3 +37,14 @@ export declare function tabGapAt(rects: readonly {
|
|
|
37
37
|
left: number;
|
|
38
38
|
right: number;
|
|
39
39
|
}[], x: number): number;
|
|
40
|
+
export interface TabScrollView {
|
|
41
|
+
scrollLeft: number;
|
|
42
|
+
clientWidth: number;
|
|
43
|
+
scrollWidth: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The scrollLeft that brings a tab fully into view and clear of the edge fades, or the current one
|
|
47
|
+
* when it already is. The strip only fades an edge it can still scroll toward, so a fade is counted
|
|
48
|
+
* only where the scroll position leaves one. A tab wider than the room between the fades shows its start.
|
|
49
|
+
*/
|
|
50
|
+
export declare function tabRevealScrollLeft(view: TabScrollView, start: number, width: number, fade: number): number;
|
|
@@ -151,3 +151,22 @@ export function tabGapAt(rects, x) {
|
|
|
151
151
|
const index = rects.findIndex((rect) => x < (rect.left + rect.right) / 2);
|
|
152
152
|
return index < 0 ? rects.length : index;
|
|
153
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* The scrollLeft that brings a tab fully into view and clear of the edge fades, or the current one
|
|
156
|
+
* when it already is. The strip only fades an edge it can still scroll toward, so a fade is counted
|
|
157
|
+
* only where the scroll position leaves one. A tab wider than the room between the fades shows its start.
|
|
158
|
+
*/
|
|
159
|
+
export function tabRevealScrollLeft(view, start, width, fade) {
|
|
160
|
+
const max = Math.max(0, view.scrollWidth - view.clientWidth);
|
|
161
|
+
const end = start + width;
|
|
162
|
+
const visibleStart = (left) => left + (left > 0 ? fade : 0);
|
|
163
|
+
const visibleEnd = (left) => left + view.clientWidth - (left < max - 1 ? fade : 0);
|
|
164
|
+
const clamp = (left) => Math.round(Math.min(max, Math.max(0, left)));
|
|
165
|
+
if (start >= visibleStart(view.scrollLeft) && end <= visibleEnd(view.scrollLeft)) {
|
|
166
|
+
return view.scrollLeft;
|
|
167
|
+
}
|
|
168
|
+
if (start < visibleStart(view.scrollLeft) || width > view.clientWidth - 2 * fade) {
|
|
169
|
+
return clamp(start - fade);
|
|
170
|
+
}
|
|
171
|
+
return clamp(end + fade - view.clientWidth);
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Suspense, useSyncExternalStore, type ReactNode } from 'react';
|
|
2
|
+
import { readLandings, subscribeLandings } from './lazy-landings.ts';
|
|
3
|
+
|
|
4
|
+
export interface LazyBoundaryProps {
|
|
5
|
+
readonly fallback?: ReactNode;
|
|
6
|
+
readonly children?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `Suspense` for what `lazyNamed` loads. React holds content that comes in as a retry back until
|
|
11
|
+
* 300 ms after its fallback showed; this boundary draws again as an ordinary update once a module
|
|
12
|
+
* lands, so a chunk that takes 20 ms shows after 20 ms.
|
|
13
|
+
*/
|
|
14
|
+
export function LazyBoundary({ fallback = null, children }: LazyBoundaryProps) {
|
|
15
|
+
useSyncExternalStore(subscribeLandings, readLandings, readLandings);
|
|
16
|
+
return <Suspense fallback={fallback}>{children}</Suspense>;
|
|
17
|
+
}
|
package/src/file-tree/helpers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FileTree, FileTreeDirectoryHandle, FileTreeVisibleRow } from '@pierre/trees';
|
|
2
|
+
import { nameOrder } from '../format/locale.ts';
|
|
2
3
|
|
|
3
4
|
// Expanded folders can reveal more flattened rows; cap custom-key cycles.
|
|
4
5
|
const EXPANSION_PASSES = 32;
|
|
@@ -152,8 +153,7 @@ export function compareRows(left: SortRow, right: SortRow): number {
|
|
|
152
153
|
if (leftIsDirectory !== rightIsDirectory) {
|
|
153
154
|
return leftIsDirectory ? -1 : 1;
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
-
return leftSegment.localeCompare(rightSegment, undefined, { numeric: true, sensitivity: 'base' }) || leftSegment.localeCompare(rightSegment);
|
|
156
|
+
return nameOrder(leftSegment, rightSegment);
|
|
157
157
|
}
|
|
158
158
|
// One path is the head of the other, so the shorter one is the directory the longer one sits in.
|
|
159
159
|
return left.segments.length - right.segments.length;
|
package/src/format/locale.ts
CHANGED
|
@@ -151,3 +151,14 @@ export const labelCollator = (): Intl.Collator => {
|
|
|
151
151
|
collators.set(locale, collator);
|
|
152
152
|
return collator;
|
|
153
153
|
};
|
|
154
|
+
|
|
155
|
+
let nameCollators: { loose: Intl.Collator; exact: Intl.Collator } | null = null;
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* The order of names that are no words, such as files: the runtime's own locale, numbers by value and
|
|
159
|
+
* case only to break a tie. It is what `localeCompare` did, which built a collator on every call.
|
|
160
|
+
*/
|
|
161
|
+
export const nameOrder = (left: string, right: string): number => {
|
|
162
|
+
nameCollators ??= { loose: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), exact: new Intl.Collator() };
|
|
163
|
+
return nameCollators.loose.compare(left, right) || nameCollators.exact.compare(left, right);
|
|
164
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -83,6 +83,9 @@ export type { ResetKeys } from './error-boundary.ts';
|
|
|
83
83
|
export { FILE_TREE_ICONS } from './file-icon.ts';
|
|
84
84
|
export { cameThroughPortal, isInFloatingLayer } from './floating.ts';
|
|
85
85
|
export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.tsx';
|
|
86
|
+
export type { LazyComponent } from './lazy.tsx';
|
|
87
|
+
export { LazyBoundary } from './LazyBoundary.tsx';
|
|
88
|
+
export type { LazyBoundaryProps } from './LazyBoundary.tsx';
|
|
86
89
|
export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.ts';
|
|
87
90
|
export { startInputModality } from './modality.ts';
|
|
88
91
|
export { isApplePlatform } from './platform.ts';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
let landings = 0;
|
|
2
|
+
const listeners = new Set<() => void>();
|
|
3
|
+
|
|
4
|
+
/* Called once per module, when a `LoadedComponent` first has it. */
|
|
5
|
+
export function noteLanding(): void {
|
|
6
|
+
landings += 1;
|
|
7
|
+
for (const listener of listeners) {
|
|
8
|
+
listener();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function subscribeLandings(listener: () => void): () => void {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readLandings(): number {
|
|
20
|
+
return landings;
|
|
21
|
+
}
|
package/src/lazy.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Suspense, lazy, useState, useSyncExternalStore, type ComponentProps, type ComponentType } from 'react';
|
|
2
|
+
import { noteLanding } from './lazy-landings.ts';
|
|
2
3
|
import { prefetcher } from './prefetch.ts';
|
|
3
4
|
|
|
4
5
|
/*
|
|
@@ -22,6 +23,7 @@ export class LoadedComponent<Component> {
|
|
|
22
23
|
for (const listener of this.listeners) {
|
|
23
24
|
listener();
|
|
24
25
|
}
|
|
26
|
+
noteLanding();
|
|
25
27
|
}
|
|
26
28
|
return component;
|
|
27
29
|
}
|
|
@@ -60,6 +62,9 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/* A lazy component, with `preload` to have its module before the first render, so that render never suspends. */
|
|
66
|
+
export type LazyComponent<Props> = ComponentType<Props> & { readonly preload: () => Promise<void> };
|
|
67
|
+
|
|
63
68
|
/*
|
|
64
69
|
* `React.lazy` for a module that exports its component by name, `default` included. Every module
|
|
65
70
|
* loaded through here, or through `lazyDialog`, registers with `prefetcher`, which loads it ahead of
|
|
@@ -68,7 +73,7 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
|
|
|
68
73
|
export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(
|
|
69
74
|
load: () => Promise<Module>,
|
|
70
75
|
name: Name
|
|
71
|
-
):
|
|
76
|
+
): LazyComponent<ComponentProps<Module[Name]>> {
|
|
72
77
|
const loaded = new LoadedComponent(async () => (await load())[name]);
|
|
73
78
|
prefetcher.register(() => loaded.load());
|
|
74
79
|
const Lazy = lazy(() => open(loaded));
|
|
@@ -77,7 +82,11 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
|
|
|
77
82
|
const Component: ComponentType<any> = loaded.current ?? Lazy;
|
|
78
83
|
return <Component {...props} />;
|
|
79
84
|
};
|
|
80
|
-
return LazyNamed
|
|
85
|
+
return Object.assign(LazyNamed, {
|
|
86
|
+
preload: async () => {
|
|
87
|
+
await loaded.load();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/* Stays true after the first opening, so every close after it still has something mounted to animate. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { Tabs } from '@base-ui/react/tabs';
|
|
@@ -8,6 +8,7 @@ import { DialogDescription, DialogPopup, DialogRoot, DialogTitle } from '../dial
|
|
|
8
8
|
import { ErrorBoundary } from '../ErrorBoundary.tsx';
|
|
9
9
|
import { Icon } from '../Icon.tsx';
|
|
10
10
|
import { IconButton } from '../IconButton.tsx';
|
|
11
|
+
import { LazyBoundary } from '../LazyBoundary.tsx';
|
|
11
12
|
import { Input } from '../Input.tsx';
|
|
12
13
|
import { Select } from '../Select.tsx';
|
|
13
14
|
import { SettingsTargetContext } from './target.ts';
|
|
@@ -18,7 +19,7 @@ export interface SettingsSectionEntry {
|
|
|
18
19
|
label: string;
|
|
19
20
|
/* One line under the pane title that says what the pane is about. */
|
|
20
21
|
description: string;
|
|
21
|
-
/* Rendered in a
|
|
22
|
+
/* Rendered in a `LazyBoundary`, so a lazy component loads when its section is opened. */
|
|
22
23
|
pane: ComponentType;
|
|
23
24
|
/* A list beside a detail that scrolls on its own (`MasterDetail`), instead of one padded column. */
|
|
24
25
|
split?: boolean;
|
|
@@ -320,14 +321,14 @@ export function SettingsDialog({
|
|
|
320
321
|
}}
|
|
321
322
|
>
|
|
322
323
|
<ErrorBoundary label={t('settings.failed')} className="min-h-0 grow">
|
|
323
|
-
<
|
|
324
|
+
<LazyBoundary>
|
|
324
325
|
{Hero && (
|
|
325
326
|
<div className="-mx-8 shrink-0 max-[960px]:-mx-4">
|
|
326
327
|
<Hero />
|
|
327
328
|
</div>
|
|
328
329
|
)}
|
|
329
330
|
<Pane />
|
|
330
|
-
</
|
|
331
|
+
</LazyBoundary>
|
|
331
332
|
</ErrorBoundary>
|
|
332
333
|
</Tabs.Panel>
|
|
333
334
|
);
|
package/src/theme.css
CHANGED
|
@@ -501,12 +501,13 @@
|
|
|
501
501
|
/* A strip that scrolls sideways fades out at the edge that still hides something, and only there,
|
|
502
502
|
so a strip that fits keeps its first and last item sharp. The edges are set by whoever scrolls. */
|
|
503
503
|
.scroll-fade-x {
|
|
504
|
+
--fade-size: 24px;
|
|
504
505
|
--fade-start: 0px;
|
|
505
506
|
--fade-end: 0px;
|
|
506
507
|
mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
|
|
507
508
|
}
|
|
508
|
-
.scroll-fade-x[data-fade-start] { --fade-start:
|
|
509
|
-
.scroll-fade-x[data-fade-end] { --fade-end:
|
|
509
|
+
.scroll-fade-x[data-fade-start] { --fade-start: var(--fade-size); }
|
|
510
|
+
.scroll-fade-x[data-fade-end] { --fade-end: var(--fade-size); }
|
|
510
511
|
/* The same for a column under a fixed header: its top fades once something has scrolled under it. */
|
|
511
512
|
.scroll-fade-top {
|
|
512
513
|
--fade-start: 0px;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
type DragEvent,
|
|
10
10
|
type MouseEvent,
|
|
11
|
+
type PointerEvent,
|
|
11
12
|
type ReactNode,
|
|
12
13
|
type UIEvent,
|
|
13
14
|
type WheelEvent
|
|
@@ -19,7 +20,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
19
20
|
import { Icon } from '../Icon.tsx';
|
|
20
21
|
import { IconButton, type IconButtonProps } from '../IconButton.tsx';
|
|
21
22
|
import { Tooltip } from '../Tooltip.tsx';
|
|
22
|
-
import { tabGapAt } from './geometry.ts';
|
|
23
|
+
import { tabGapAt, tabRevealScrollLeft } from './geometry.ts';
|
|
23
24
|
|
|
24
25
|
/* Where an arrow, Home or End moves the selection from `index`, wrapping at either end; null for any other key. */
|
|
25
26
|
function tabIndexForKey(key: string, index: number, count: number): number | null {
|
|
@@ -123,20 +124,44 @@ export function TabStrip({
|
|
|
123
124
|
setInsertion(after?.offsetLeft ?? (last ? last.offsetLeft + last.offsetWidth : 0));
|
|
124
125
|
}
|
|
125
126
|
}, [insertAt]);
|
|
126
|
-
|
|
127
|
+
// Stays set after the active tab changes, as its width can settle frames later; a person's own scroll ends it.
|
|
128
|
+
const revealing = useRef(false);
|
|
129
|
+
const reveal = (): void => {
|
|
130
|
+
const element = strip.current;
|
|
131
|
+
const selected = element?.querySelector<HTMLElement>('[aria-selected="true"]');
|
|
132
|
+
if (!revealing.current || !element || !selected) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const container = selected.closest<HTMLElement>('[data-document-tab]');
|
|
136
|
+
const tab = container && element.contains(container) ? container : selected;
|
|
137
|
+
const fade = parseFloat(getComputedStyle(element).getPropertyValue('--fade-size')) || 0;
|
|
138
|
+
const bounds = element.getBoundingClientRect();
|
|
139
|
+
const rect = tab.getBoundingClientRect();
|
|
140
|
+
const left = tabRevealScrollLeft(element, rect.left - bounds.left - element.clientLeft + element.scrollLeft, rect.width, fade);
|
|
141
|
+
if (left !== element.scrollLeft) {
|
|
142
|
+
element.scrollLeft = left;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
useLayoutEffect(() => {
|
|
146
|
+
revealing.current = true;
|
|
147
|
+
}, [value]);
|
|
148
|
+
useLayoutEffect(() => {
|
|
149
|
+
reveal();
|
|
150
|
+
measure();
|
|
151
|
+
});
|
|
127
152
|
useEffect(() => {
|
|
128
153
|
const element = strip.current;
|
|
129
154
|
if (!element || typeof ResizeObserver === 'undefined') {
|
|
130
155
|
return;
|
|
131
156
|
}
|
|
132
|
-
const observer = new ResizeObserver(
|
|
157
|
+
const observer = new ResizeObserver(() => {
|
|
158
|
+
reveal();
|
|
159
|
+
measure();
|
|
160
|
+
});
|
|
133
161
|
observer.observe(element);
|
|
134
162
|
element.querySelectorAll('[data-document-tab]').forEach((tab) => observer.observe(tab));
|
|
135
163
|
return () => observer.disconnect();
|
|
136
164
|
});
|
|
137
|
-
useEffect(() => {
|
|
138
|
-
strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
139
|
-
}, [value]);
|
|
140
165
|
const gap = (event: DragEvent<HTMLDivElement>): number => {
|
|
141
166
|
const rects = Array.from(event.currentTarget.querySelectorAll('[data-document-tab]'), (tab) => tab.getBoundingClientRect());
|
|
142
167
|
return tabGapAt(rects, event.clientX);
|
|
@@ -171,10 +196,21 @@ export function TabStrip({
|
|
|
171
196
|
},
|
|
172
197
|
onWheel: (event: WheelEvent<HTMLDivElement>) => {
|
|
173
198
|
props.onWheel?.(event);
|
|
174
|
-
if (
|
|
199
|
+
if (event.defaultPrevented) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
revealing.current = false;
|
|
203
|
+
if (!event.deltaX && event.deltaY) {
|
|
175
204
|
event.currentTarget.scrollLeft += event.deltaY;
|
|
176
205
|
}
|
|
177
206
|
},
|
|
207
|
+
onPointerDown: (event: PointerEvent<HTMLDivElement>) => {
|
|
208
|
+
props.onPointerDown?.(event);
|
|
209
|
+
// A press on the strip itself grabs its scrollbar, and a touch may pan it.
|
|
210
|
+
if (event.target === event.currentTarget || event.pointerType === 'touch') {
|
|
211
|
+
revealing.current = false;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
178
214
|
onDragOver: (event: DragEvent<HTMLDivElement>) => {
|
|
179
215
|
props.onDragOver?.(event);
|
|
180
216
|
if (!event.defaultPrevented) {
|
|
@@ -188,3 +188,29 @@ export function tabGapAt(rects: readonly { left: number; right: number }[], x: n
|
|
|
188
188
|
const index = rects.findIndex((rect) => x < (rect.left + rect.right) / 2);
|
|
189
189
|
return index < 0 ? rects.length : index;
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
export interface TabScrollView {
|
|
193
|
+
scrollLeft: number;
|
|
194
|
+
clientWidth: number;
|
|
195
|
+
scrollWidth: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The scrollLeft that brings a tab fully into view and clear of the edge fades, or the current one
|
|
200
|
+
* when it already is. The strip only fades an edge it can still scroll toward, so a fade is counted
|
|
201
|
+
* only where the scroll position leaves one. A tab wider than the room between the fades shows its start.
|
|
202
|
+
*/
|
|
203
|
+
export function tabRevealScrollLeft(view: TabScrollView, start: number, width: number, fade: number): number {
|
|
204
|
+
const max = Math.max(0, view.scrollWidth - view.clientWidth);
|
|
205
|
+
const end = start + width;
|
|
206
|
+
const visibleStart = (left: number): number => left + (left > 0 ? fade : 0);
|
|
207
|
+
const visibleEnd = (left: number): number => left + view.clientWidth - (left < max - 1 ? fade : 0);
|
|
208
|
+
const clamp = (left: number): number => Math.round(Math.min(max, Math.max(0, left)));
|
|
209
|
+
if (start >= visibleStart(view.scrollLeft) && end <= visibleEnd(view.scrollLeft)) {
|
|
210
|
+
return view.scrollLeft;
|
|
211
|
+
}
|
|
212
|
+
if (start < visibleStart(view.scrollLeft) || width > view.clientWidth - 2 * fade) {
|
|
213
|
+
return clamp(start - fade);
|
|
214
|
+
}
|
|
215
|
+
return clamp(end + fade - view.clientWidth);
|
|
216
|
+
}
|