@adecore/ui 0.21.0 → 0.21.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/README.md +4 -4
- package/dist/DisabledReason.js +4 -0
- package/dist/Field.js +3 -2
- package/dist/Input.js +3 -8
- package/dist/SegmentBar.js +2 -1
- package/dist/Separator.js +2 -3
- package/dist/SlidingColumn.js +1 -1
- package/dist/ZoomControls.js +4 -1
- package/dist/field-context.d.ts +5 -0
- package/dist/field-context.js +6 -0
- package/dist/file-icon.js +8 -10
- package/dist/file-tree/parts.js +8 -15
- package/dist/file-tree/use-file-tree-shift.js +4 -5
- package/dist/file-tree/use-slots.js +31 -31
- package/dist/format/datetime.js +6 -5
- package/dist/format/locale.js +6 -1
- package/dist/format/number.js +15 -18
- package/dist/menu/parts.d.ts +3 -2
- package/dist/menu/parts.js +4 -2
- package/dist/modality.js +4 -5
- package/dist/placement.d.ts +4 -0
- package/dist/placement.js +23 -0
- package/dist/popover/parts.d.ts +4 -3
- package/dist/popover/parts.js +7 -4
- package/dist/shortcut.js +9 -4
- package/dist/tabs/parts.js +3 -2
- package/dist/tree/parts.js +10 -20
- package/dist/tree/use-tree-shift.js +1 -1
- package/dist/workspace/SplitView.js +20 -14
- package/dist/workspace/TabStrip.js +19 -11
- package/dist/workspace/Workspace.js +4 -3
- package/dist/workspace/model.js +123 -122
- package/package.json +1 -1
- package/src/DisabledReason.tsx +4 -5
- package/src/Field.tsx +4 -2
- package/src/IconButton.tsx +0 -1
- package/src/Input.tsx +3 -10
- package/src/Pill.tsx +8 -1
- package/src/SegmentBar.tsx +3 -10
- package/src/Separator.tsx +2 -3
- package/src/SlidingColumn.tsx +1 -1
- package/src/ZoomControls.tsx +11 -7
- package/src/field-context.ts +7 -0
- package/src/file-icon.ts +8 -10
- package/src/file-tree/parts.tsx +17 -22
- package/src/file-tree/use-file-tree-shift.tsx +4 -7
- package/src/file-tree/use-slots.ts +31 -31
- package/src/format/datetime.ts +7 -5
- package/src/format/locale.ts +7 -7
- package/src/format/number.ts +17 -20
- package/src/menu/parts.tsx +10 -29
- package/src/modality.ts +4 -5
- package/src/placement.ts +26 -0
- package/src/popover/parts.tsx +10 -63
- package/src/settings/SettingsDialog.tsx +1 -1
- package/src/shortcut.ts +9 -4
- package/src/tabs/parts.tsx +8 -8
- package/src/tree/parts.tsx +14 -21
- package/src/tree/use-tree-shift.tsx +1 -1
- package/src/workspace/SplitView.tsx +23 -19
- package/src/workspace/TabStrip.tsx +20 -12
- package/src/workspace/Workspace.tsx +5 -3
- package/src/workspace/model.ts +141 -138
- /package/dist/{file-tree → tree}/shift.d.ts +0 -0
- /package/dist/{file-tree → tree}/shift.js +0 -0
- /package/src/{file-tree → tree}/shift.ts +0 -0
package/src/Input.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ComponentProps } from 'react';
|
|
2
2
|
import type { LucideIcon } from 'lucide-react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
|
-
import { useFieldControl } from './field-context.ts';
|
|
4
|
+
import { fieldControlProps, useFieldControl } from './field-context.ts';
|
|
5
5
|
import { Icon } from './Icon.tsx';
|
|
6
6
|
|
|
7
7
|
export interface InputProps extends Omit<ComponentProps<'input'>, 'size'> {
|
|
@@ -17,12 +17,7 @@ export interface InputProps extends Omit<ComponentProps<'input'>, 'size'> {
|
|
|
17
17
|
export function Input({ size = 'md', mono = false, icon, className, id, ...props }: InputProps) {
|
|
18
18
|
const field = useFieldControl();
|
|
19
19
|
const small = size === 'sm';
|
|
20
|
-
const control = {
|
|
21
|
-
id: id ?? field?.id,
|
|
22
|
-
'aria-describedby': field?.describedBy,
|
|
23
|
-
'aria-invalid': field?.invalid || undefined,
|
|
24
|
-
...props
|
|
25
|
-
};
|
|
20
|
+
const control = { ...fieldControlProps(field, id), ...props };
|
|
26
21
|
|
|
27
22
|
if (icon === undefined) {
|
|
28
23
|
return <input {...control} className={clsx('field', small && 'field-sm', mono && 'font-mono text-code', className)} />;
|
|
@@ -62,9 +57,7 @@ export function TextArea({ size = 'md', resize = 'none', mono = false, className
|
|
|
62
57
|
const field = useFieldControl();
|
|
63
58
|
return (
|
|
64
59
|
<textarea
|
|
65
|
-
|
|
66
|
-
aria-describedby={field?.describedBy}
|
|
67
|
-
aria-invalid={field?.invalid || undefined}
|
|
60
|
+
{...fieldControlProps(field, id)}
|
|
68
61
|
{...props}
|
|
69
62
|
className={clsx(
|
|
70
63
|
'field h-auto min-h-16 py-1.5',
|
package/src/Pill.tsx
CHANGED
|
@@ -48,7 +48,14 @@ export interface PillProps {
|
|
|
48
48
|
/* The small rounded label in a header, a sidebar row or a settings line: a count, a branch, a status. */
|
|
49
49
|
export function Pill({ icon, children, tone = 'muted', shape = 'pill', size = 'md', mono = false, onClick, pressed, disabled, className, ref }: PillProps) {
|
|
50
50
|
// Never broken inside itself: in a row that wraps, a pill that does not fit moves to the next line whole.
|
|
51
|
-
const shared = clsx(
|
|
51
|
+
const shared = clsx(
|
|
52
|
+
'inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-2xs',
|
|
53
|
+
SHAPES[shape],
|
|
54
|
+
PADDING[shape][size],
|
|
55
|
+
TONES[tone],
|
|
56
|
+
mono && 'font-mono',
|
|
57
|
+
className
|
|
58
|
+
);
|
|
52
59
|
if (!onClick) {
|
|
53
60
|
return (
|
|
54
61
|
<span ref={ref} className={shared}>
|
package/src/SegmentBar.tsx
CHANGED
|
@@ -91,19 +91,12 @@ export function SegmentBar({ parts, range, size = 'md', onSelect, label, classNa
|
|
|
91
91
|
{part.label}
|
|
92
92
|
</span>
|
|
93
93
|
);
|
|
94
|
+
const look = { className: fill, style: { backgroundColor: part.color }, 'aria-current': part.current ? true : undefined };
|
|
94
95
|
const body =
|
|
95
96
|
onSelect === undefined ? (
|
|
96
|
-
<span
|
|
97
|
-
{text}
|
|
98
|
-
</span>
|
|
97
|
+
<span {...look}>{text}</span>
|
|
99
98
|
) : (
|
|
100
|
-
<button
|
|
101
|
-
type="button"
|
|
102
|
-
className={fill}
|
|
103
|
-
style={{ backgroundColor: part.color }}
|
|
104
|
-
aria-current={part.current ? true : undefined}
|
|
105
|
-
onClick={() => onSelect(index)}
|
|
106
|
-
>
|
|
99
|
+
<button type="button" {...look} onClick={() => onSelect(index)}>
|
|
107
100
|
{text}
|
|
108
101
|
</button>
|
|
109
102
|
);
|
package/src/Separator.tsx
CHANGED
|
@@ -7,9 +7,8 @@ export interface SeparatorProps {
|
|
|
7
7
|
ref?: Ref<HTMLSpanElement>;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/* A hairline between two groups of controls in a toolbar-like row.
|
|
11
|
-
|
|
12
|
-
not as a group of its own. Between two groups of menu rows it is `Menu.Separator`. */
|
|
10
|
+
/* A hairline between two groups of controls in a toolbar-like row. No margin of its own: the row's gap
|
|
11
|
+
puts 8px on either side, which makes it read as a divider. Between groups of menu rows it is `Menu.Separator`. */
|
|
13
12
|
export function Separator({ orientation = 'vertical', className, ref }: SeparatorProps) {
|
|
14
13
|
return <span ref={ref} aria-hidden className={clsx(orientation === 'vertical' ? 'h-4 w-px' : 'h-px w-full', 'shrink-0 bg-border', className)} />;
|
|
15
14
|
}
|
package/src/SlidingColumn.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export interface SlidingColumnProps {
|
|
|
41
41
|
* drag handle.
|
|
42
42
|
*/
|
|
43
43
|
export function SlidingColumn({ open, width, gap = 0, bounds, onWidthChange, instant = false, body, className, ref, children }: SlidingColumnProps) {
|
|
44
|
-
/* Closed and done animating.
|
|
44
|
+
/* Closed and done animating. */
|
|
45
45
|
const [settled, setSettled] = useState(!open);
|
|
46
46
|
/* A width that lands without a transition fires no `transitionend`, so the motion it would have
|
|
47
47
|
ended is over in the same commit that starts it. */
|
package/src/ZoomControls.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Ref } from 'react';
|
|
2
|
-
import { Maximize, Minus, Plus, Scan } from 'lucide-react';
|
|
2
|
+
import { Maximize, Minus, Plus, Scan, type LucideIcon } from 'lucide-react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { ButtonGroup } from './ButtonGroup.tsx';
|
|
5
5
|
import { Icon } from './Icon.tsx';
|
|
@@ -42,6 +42,14 @@ export interface ZoomControlsProps {
|
|
|
42
42
|
ref?: Ref<HTMLDivElement>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function MenuIcon({ icon }: { icon: LucideIcon }) {
|
|
46
|
+
return (
|
|
47
|
+
<span className="grid h-4 w-4 place-items-center">
|
|
48
|
+
<Icon icon={icon} size={14} />
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/* The zoom group of a dock: out, the readout with its presets, in, and fit everything. */
|
|
46
54
|
export function ZoomControls({
|
|
47
55
|
zoom,
|
|
@@ -77,16 +85,12 @@ export function ZoomControls({
|
|
|
77
85
|
</MenuRadioGroup>
|
|
78
86
|
<MenuSeparator />
|
|
79
87
|
<MenuItem onClick={onFitAll}>
|
|
80
|
-
<
|
|
81
|
-
<Icon icon={Maximize} size={14} />
|
|
82
|
-
</span>
|
|
88
|
+
<MenuIcon icon={Maximize} />
|
|
83
89
|
{labels.fit ?? t('zoom.fit')} {shortcuts.fitAll && <Kbd shortcut={shortcuts.fitAll} />}
|
|
84
90
|
</MenuItem>
|
|
85
91
|
{selection && (
|
|
86
92
|
<MenuItem disabled={!selection.enabled} onClick={selection.onZoom}>
|
|
87
|
-
<
|
|
88
|
-
<Icon icon={Scan} size={14} />
|
|
89
|
-
</span>
|
|
93
|
+
<MenuIcon icon={Scan} />
|
|
90
94
|
{selection.label} {selection.shortcut && <Kbd shortcut={selection.shortcut} />}
|
|
91
95
|
</MenuItem>
|
|
92
96
|
)}
|
package/src/field-context.ts
CHANGED
|
@@ -10,3 +10,10 @@ export const FieldContext = createContext<FieldControl | null>(null);
|
|
|
10
10
|
|
|
11
11
|
/* What an `Input` or a `TextArea` inside a `Field` takes over: the id its label points at, the hint and the error it is described by. */
|
|
12
12
|
export const useFieldControl = (): FieldControl | null => useContext(FieldContext);
|
|
13
|
+
|
|
14
|
+
/* The attributes that connect a control to its field; an `id` the caller sets wins over the field's. */
|
|
15
|
+
export const fieldControlProps = (field: FieldControl | null, id: string | undefined) => ({
|
|
16
|
+
id: id ?? field?.id,
|
|
17
|
+
'aria-describedby': field?.describedBy,
|
|
18
|
+
'aria-invalid': field?.invalid || undefined
|
|
19
|
+
});
|
package/src/file-icon.ts
CHANGED
|
@@ -3,11 +3,10 @@ import { SETI_BY_FILE_EXTENSION, SETI_BY_FILE_NAME, SETI_BY_FILE_NAME_CONTAINS,
|
|
|
3
3
|
|
|
4
4
|
const SPRITE_ELEMENT_ID = 'adecore-ui-file-icon-sprite';
|
|
5
5
|
|
|
6
|
-
/* The glyphs are Seti's (`seti-icons.ts`), which knows far more file types than
|
|
7
|
-
|
|
8
|
-
sprite brings the rules that color it.
|
|
9
|
-
|
|
10
|
-
what a file is. Hand this to the tree. */
|
|
6
|
+
/* Hand this to the tree. The glyphs are Seti's (`seti-icons.ts`), which knows far more file types than
|
|
7
|
+
the tree's own sets. The tree colors only those, so every symbol carries its hue in its id and the
|
|
8
|
+
sprite brings the rules that color it. Everything drawn beside a tree calls `fileIconFor`, so a tab,
|
|
9
|
+
a picker row and a tree row never disagree about what a file is. */
|
|
11
10
|
export const FILE_TREE_ICONS: FileTreeIconConfig = {
|
|
12
11
|
set: 'none',
|
|
13
12
|
spriteSheet: SETI_SPRITE_SHEET,
|
|
@@ -27,11 +26,10 @@ const resolveIcon: ReturnType<typeof createFileTreeIconResolver>['resolveIcon']
|
|
|
27
26
|
|
|
28
27
|
/*
|
|
29
28
|
* The id of the `<symbol>` the set gives a file, in the sprite `mountFileIconSprite` puts in the
|
|
30
|
-
* document.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* turns as it opens, not with a glyph of its own.
|
|
29
|
+
* document. Only the last segment of the path decides, first by exact name (`package.json`), then by
|
|
30
|
+
* a part of the name (`Dockerfile.dev`), then by the longest extension that matches (`spec.ts` before
|
|
31
|
+
* `ts`). Anything else falls back to the generic file icon, a directory included: the tree marks a
|
|
32
|
+
* folder with its chevron, not a glyph.
|
|
35
33
|
*/
|
|
36
34
|
export const fileIconFor = (path: string): string => resolveIcon('file-tree-icon-file', path).name;
|
|
37
35
|
|
package/src/file-tree/parts.tsx
CHANGED
|
@@ -26,8 +26,8 @@ export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
|
|
|
26
26
|
treeProps?: Omit<PierreProps, 'model'>;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function inSlot(event:
|
|
30
|
-
return event.
|
|
29
|
+
function inSlot(event: Event): boolean {
|
|
30
|
+
return event.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute('data-tree-slot'));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function FileTreeRoot({
|
|
@@ -118,7 +118,7 @@ export function FileTreeRoot({
|
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
120
|
const onClickRow = (event: MouseEvent): void => {
|
|
121
|
-
if (event.defaultPrevented || inSlot(
|
|
121
|
+
if (event.defaultPrevented || inSlot(event) || extendsSelection(event)) {
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
const path = rowPathOf({ nativeEvent: event });
|
|
@@ -127,7 +127,7 @@ export function FileTreeRoot({
|
|
|
127
127
|
}
|
|
128
128
|
};
|
|
129
129
|
const onKey = (event: KeyboardEvent): void => {
|
|
130
|
-
if (event.defaultPrevented || inSlot(
|
|
130
|
+
if (event.defaultPrevented || inSlot(event)) {
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
133
133
|
if (movesFocus(event) && selectionFollowsFocus) {
|
|
@@ -149,24 +149,19 @@ export function FileTreeRoot({
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
};
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const path = rowPathOf({ nativeEvent: event });
|
|
166
|
-
if (path !== null) {
|
|
167
|
-
onRowDragStart?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
|
|
168
|
-
}
|
|
169
|
-
};
|
|
152
|
+
const withTargets =
|
|
153
|
+
<RowEvent extends Event>(handle?: (path: string, targets: readonly string[], event: RowEvent) => void) =>
|
|
154
|
+
(event: RowEvent): void => {
|
|
155
|
+
if (inSlot(event)) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const path = rowPathOf({ nativeEvent: event });
|
|
159
|
+
if (path !== null) {
|
|
160
|
+
handle?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const onContext = withTargets(onRowContextMenu);
|
|
164
|
+
const onDrag = withTargets(onRowDragStart);
|
|
170
165
|
frame.addEventListener('click', onClickRow);
|
|
171
166
|
frame.addEventListener('keydown', onKey, true);
|
|
172
167
|
frame.addEventListener('contextmenu', onContext);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo, type ReactElement } from 'react';
|
|
2
2
|
import type { FileTree } from '@pierre/trees';
|
|
3
3
|
import { useTreeShift, type TreeShiftSource } from '../tree/use-tree-shift.tsx';
|
|
4
|
-
import { shiftNeed } from '
|
|
4
|
+
import { shiftNeed } from '../tree/shift.ts';
|
|
5
5
|
|
|
6
6
|
const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
|
|
7
7
|
|
|
@@ -11,6 +11,7 @@ function boxesOf(element: Element): Element[] {
|
|
|
11
11
|
|
|
12
12
|
function limitOf(row: HTMLElement, content: Element, gap: number): number {
|
|
13
13
|
const start = content.getBoundingClientRect().left;
|
|
14
|
+
const contentMargin = Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0;
|
|
14
15
|
let limit = Number.POSITIVE_INFINITY;
|
|
15
16
|
for (const part of [...row.children].filter((child) => child !== content).flatMap((child) => boxesOf(child))) {
|
|
16
17
|
const rect = part.getBoundingClientRect();
|
|
@@ -23,13 +24,9 @@ function limitOf(row: HTMLElement, content: Element, gap: number): number {
|
|
|
23
24
|
limit = Math.min(limit, rect.left - gap);
|
|
24
25
|
}
|
|
25
26
|
if (limit !== Number.POSITIVE_INFINITY) {
|
|
26
|
-
return limit -
|
|
27
|
+
return limit - contentMargin;
|
|
27
28
|
}
|
|
28
|
-
return (
|
|
29
|
-
row.getBoundingClientRect().right -
|
|
30
|
-
Number.parseFloat(getComputedStyle(row).paddingInlineEnd) -
|
|
31
|
-
(Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0)
|
|
32
|
-
);
|
|
29
|
+
return row.getBoundingClientRect().right - Number.parseFloat(getComputedStyle(row).paddingInlineEnd) - contentMargin;
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
function needOf(row: HTMLElement, shift: number, gap: number, range: Range): number | null {
|
|
@@ -54,6 +54,33 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
const resize = new ResizeObserver(position);
|
|
57
|
+
const dropPort = (port: Port): void => {
|
|
58
|
+
port.control.remove();
|
|
59
|
+
port.decoration.remove();
|
|
60
|
+
resize.unobserve(port.lightControl);
|
|
61
|
+
resize.unobserve(port.lightDecoration);
|
|
62
|
+
port.lightControl.remove();
|
|
63
|
+
port.lightDecoration.remove();
|
|
64
|
+
};
|
|
65
|
+
const makePort = (element: HTMLElement, host: HTMLElement, kind: 'control' | 'decoration'): { container: HTMLElement; target: HTMLElement } => {
|
|
66
|
+
const container = document.createElement('span');
|
|
67
|
+
container.dataset.treePort = kind;
|
|
68
|
+
const slot = document.createElement('slot');
|
|
69
|
+
slot.name = `${prefix}-${kind}-${serial}`;
|
|
70
|
+
container.append(slot);
|
|
71
|
+
element.parentElement?.append(container);
|
|
72
|
+
const target = document.createElement('span');
|
|
73
|
+
target.slot = slot.name;
|
|
74
|
+
target.dataset.treeSlot = kind;
|
|
75
|
+
target.className = kind === 'control' ? 'adecore-tree-control' : 'adecore-tree-decoration';
|
|
76
|
+
// React delegates to this portal target before the event reaches the engine's shadow tree.
|
|
77
|
+
for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'pointerdown', 'dragstart']) {
|
|
78
|
+
target.addEventListener(type, (event) => event.stopPropagation());
|
|
79
|
+
}
|
|
80
|
+
host.append(target);
|
|
81
|
+
resize.observe(target);
|
|
82
|
+
return { container, target };
|
|
83
|
+
};
|
|
57
84
|
const scan = (): void => {
|
|
58
85
|
pending = false;
|
|
59
86
|
if (disposed) {
|
|
@@ -87,12 +114,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
87
114
|
const live = new Set(elements);
|
|
88
115
|
for (const [element, port] of ports) {
|
|
89
116
|
if (!live.has(element)) {
|
|
90
|
-
port
|
|
91
|
-
port.decoration.remove();
|
|
92
|
-
resize.unobserve(port.lightControl);
|
|
93
|
-
resize.unobserve(port.lightDecoration);
|
|
94
|
-
port.lightControl.remove();
|
|
95
|
-
port.lightDecoration.remove();
|
|
117
|
+
dropPort(port);
|
|
96
118
|
ports.delete(element);
|
|
97
119
|
}
|
|
98
120
|
}
|
|
@@ -108,28 +130,9 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
108
130
|
element.dataset.joinedEnd = String(row.isSelected && rows[row.index + 1]?.isSelected === true);
|
|
109
131
|
let port = ports.get(element);
|
|
110
132
|
if (port === undefined) {
|
|
111
|
-
const makePort = (kind: string): { container: HTMLElement; target: HTMLElement } => {
|
|
112
|
-
const container = document.createElement('span');
|
|
113
|
-
container.dataset.treePort = kind;
|
|
114
|
-
const slot = document.createElement('slot');
|
|
115
|
-
slot.name = `${prefix}-${kind}-${serial}`;
|
|
116
|
-
container.append(slot);
|
|
117
|
-
element.parentElement?.append(container);
|
|
118
|
-
const target = document.createElement('span');
|
|
119
|
-
target.slot = slot.name;
|
|
120
|
-
target.dataset.treeSlot = kind;
|
|
121
|
-
target.className = kind === 'control' ? 'adecore-tree-control' : 'adecore-tree-decoration';
|
|
122
|
-
// React delegates to this portal target before the event reaches the engine's shadow tree.
|
|
123
|
-
for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'pointerdown', 'dragstart']) {
|
|
124
|
-
target.addEventListener(type, (event) => event.stopPropagation());
|
|
125
|
-
}
|
|
126
|
-
current.append(target);
|
|
127
|
-
resize.observe(target);
|
|
128
|
-
return { container, target };
|
|
129
|
-
};
|
|
130
133
|
serial++;
|
|
131
|
-
const control = makePort('control');
|
|
132
|
-
const decoration = makePort('decoration');
|
|
134
|
+
const control = makePort(element, current, 'control');
|
|
135
|
+
const decoration = makePort(element, current, 'decoration');
|
|
133
136
|
port = {
|
|
134
137
|
rowElement: element,
|
|
135
138
|
control: control.container,
|
|
@@ -181,10 +184,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
181
184
|
for (const port of ports.values()) {
|
|
182
185
|
port.rowElement.style.removeProperty('--adecore-tree-control-space');
|
|
183
186
|
port.rowElement.style.removeProperty('--adecore-tree-decoration-space');
|
|
184
|
-
port
|
|
185
|
-
port.decoration.remove();
|
|
186
|
-
port.lightControl.remove();
|
|
187
|
-
port.lightDecoration.remove();
|
|
187
|
+
dropPort(port);
|
|
188
188
|
}
|
|
189
189
|
};
|
|
190
190
|
}, [frame, model, label, prefix]);
|
package/src/format/datetime.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { dateFormatter, wordFormatter } from './locale.ts';
|
|
|
3
3
|
/* The parts that are a word rather than a number, and so come from the language and not the region. */
|
|
4
4
|
const WORD_PARTS = new Set<Intl.DateTimeFormatPartTypes>(['month', 'weekday', 'dayPeriod', 'era']);
|
|
5
5
|
|
|
6
|
+
const toDate = (at: Date | number): Date => (typeof at === 'number' ? new Date(at) : at);
|
|
7
|
+
|
|
6
8
|
/*
|
|
7
9
|
* A date written the way the region writes one, with its words in the language the interface is in.
|
|
8
10
|
* Both formatters get the same options and the same instant, so every part the region asks for has
|
|
@@ -11,13 +13,13 @@ const WORD_PARTS = new Set<Intl.DateTimeFormatPartTypes>(['month', 'weekday', 'd
|
|
|
11
13
|
* region does not say AM.
|
|
12
14
|
*/
|
|
13
15
|
export const formatDateTime = (at: Date | number, options: Intl.DateTimeFormatOptions): string => {
|
|
14
|
-
const date =
|
|
16
|
+
const date = toDate(at);
|
|
15
17
|
const parts = dateFormatter(options).formatToParts(date);
|
|
16
18
|
if (!parts.some((part) => WORD_PARTS.has(part.type))) {
|
|
17
19
|
return parts.map((part) => part.value).join('');
|
|
18
20
|
}
|
|
19
|
-
const
|
|
20
|
-
return parts.map((part) => (WORD_PARTS.has(part.type) ? (
|
|
21
|
+
const words = wordFormatter(options).formatToParts(date);
|
|
22
|
+
return parts.map((part) => (WORD_PARTS.has(part.type) ? (words.find((word) => word.type === part.type)?.value ?? part.value) : part.value)).join('');
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
const CLOCK: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
|
@@ -55,8 +57,8 @@ export const formatNumericDate = (at: Date | number): string => formatDateTime(a
|
|
|
55
57
|
|
|
56
58
|
/* Whether two instants fall on the same day of the same year, in the zone of whoever is reading. */
|
|
57
59
|
export const isSameDay = (a: Date | number, b: Date | number): boolean => {
|
|
58
|
-
const left =
|
|
59
|
-
const right =
|
|
60
|
+
const left = toDate(a);
|
|
61
|
+
const right = toDate(b);
|
|
60
62
|
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
|
|
61
63
|
};
|
|
62
64
|
|
package/src/format/locale.ts
CHANGED
|
@@ -88,22 +88,22 @@ const resolve = (region: string): string => {
|
|
|
88
88
|
plain function over the source rather than a hook. */
|
|
89
89
|
export const formatLocale = (): string => resolve(source.region());
|
|
90
90
|
|
|
91
|
-
/* What a component calls to draw again once the region changes. The value is the locale, which a
|
|
92
|
-
caller may use or ignore; subscribing is the point. */
|
|
93
91
|
const subscribe = (onChange: () => void): (() => void) => source.subscribe(onChange);
|
|
94
92
|
|
|
93
|
+
/* What a component calls to draw again once the region changes. The value is the locale, which a
|
|
94
|
+
caller may use or ignore; subscribing is the point. */
|
|
95
95
|
export const useFormatLocale = (): string => useSyncExternalStore(subscribe, formatLocale, formatLocale);
|
|
96
96
|
|
|
97
|
-
/*
|
|
98
|
-
* A formatter per options object, keyed on the object itself, so a module-level spec builds its
|
|
99
|
-
* formatter once and a list of a thousand rows constructs nothing. A region change throws the entry
|
|
100
|
-
* away, since the locale is part of what was cached.
|
|
101
|
-
*/
|
|
102
97
|
interface Cached<T> {
|
|
103
98
|
locale: string;
|
|
104
99
|
formatter: T;
|
|
105
100
|
}
|
|
106
101
|
|
|
102
|
+
/*
|
|
103
|
+
* A formatter per options object, keyed on the object itself, so a module-level spec builds its
|
|
104
|
+
* formatter once and a list of a thousand rows constructs nothing. A region change throws the entry
|
|
105
|
+
* away, since the locale is part of what was cached.
|
|
106
|
+
*/
|
|
107
107
|
const cacheFor = <O extends object, T>(store: WeakMap<O, Cached<T>>, options: O, locale: string, build: (locale: string, options: O) => T): T => {
|
|
108
108
|
const hit = store.get(options);
|
|
109
109
|
if (hit !== undefined && hit.locale === locale) {
|
package/src/format/number.ts
CHANGED
|
@@ -9,31 +9,28 @@ export const formatNumber = (value: number): string => numberFormatter(WHOLE).fo
|
|
|
9
9
|
/* The same, to one decimal, where the fraction is the part that means something. */
|
|
10
10
|
export const formatDecimal = (value: number): string => numberFormatter(ONE_DECIMAL).format(value);
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/* One options object per key, so the formatter cached on it is built once (`numberFormatter`). */
|
|
13
|
+
const specCache = <Key>(build: (key: Key) => Intl.NumberFormatOptions): ((key: Key) => Intl.NumberFormatOptions) => {
|
|
14
|
+
const specs = new Map<Key, Intl.NumberFormatOptions>();
|
|
15
|
+
return (key) => {
|
|
16
|
+
let spec = specs.get(key);
|
|
17
|
+
if (spec === undefined) {
|
|
18
|
+
spec = build(key);
|
|
19
|
+
specs.set(key, spec);
|
|
20
|
+
}
|
|
21
|
+
return spec;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
13
24
|
|
|
14
|
-
|
|
15
|
-
export const formatFixed = (value: number, decimals: number): string => numberFormatter(fixedSpec(decimals)).format(value);
|
|
25
|
+
const fixedSpec = specCache((decimals: number) => ({ minimumFractionDigits: decimals, maximumFractionDigits: decimals }));
|
|
16
26
|
|
|
17
|
-
const
|
|
18
|
-
let spec = fixedSpecs.get(decimals);
|
|
19
|
-
if (spec === undefined) {
|
|
20
|
-
spec = { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
|
|
21
|
-
fixedSpecs.set(decimals, spec);
|
|
22
|
-
}
|
|
23
|
-
return spec;
|
|
24
|
-
};
|
|
27
|
+
const roundedSpec = specCache((decimals: number) => ({ maximumFractionDigits: decimals }));
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
/* Always `decimals` places, so values that follow each other keep one width: `1,0` and `1,5`, never `1` and `1,5`. */
|
|
30
|
+
export const formatFixed = (value: number, decimals: number): string => numberFormatter(fixedSpec(decimals)).format(value);
|
|
27
31
|
|
|
28
32
|
/* At most `decimals` places, trailing zeros dropped, for a figure whose fraction may or may not be there: `39`, `1,95`. */
|
|
29
|
-
export const formatRounded = (value: number, decimals: number): string =>
|
|
30
|
-
let spec = roundedSpecs.get(decimals);
|
|
31
|
-
if (spec === undefined) {
|
|
32
|
-
spec = { maximumFractionDigits: decimals };
|
|
33
|
-
roundedSpecs.set(decimals, spec);
|
|
34
|
-
}
|
|
35
|
-
return numberFormatter(spec).format(value);
|
|
36
|
-
};
|
|
33
|
+
export const formatRounded = (value: number, decimals: number): string => numberFormatter(roundedSpec(decimals)).format(value);
|
|
37
34
|
|
|
38
35
|
/* Intl takes at most this many fraction digits. */
|
|
39
36
|
const MAX_FRACTION_DIGITS = 100;
|
package/src/menu/parts.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { useRender } from '@base-ui-components/react/use-render';
|
|
|
8
8
|
import { withClass } from '../class-name.ts';
|
|
9
9
|
import { Icon } from '../Icon.tsx';
|
|
10
10
|
import { Tooltip } from '../Tooltip.tsx';
|
|
11
|
+
import { splitPlacement, type PlacementKey } from '../placement.ts';
|
|
11
12
|
import { renderPositioner } from '../popup-layer.ts';
|
|
12
13
|
|
|
13
14
|
/* Where a popup opens from decides where it goes when a call site says nothing. */
|
|
@@ -17,10 +18,7 @@ const MenuKindContext = createContext<MenuKind>('menu');
|
|
|
17
18
|
|
|
18
19
|
type PositionerProps = ComponentProps<typeof BaseMenu.Positioner>;
|
|
19
20
|
|
|
20
|
-
type Placement = Pick<
|
|
21
|
-
PositionerProps,
|
|
22
|
-
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding' | 'collisionBoundary' | 'collisionAvoidance' | 'anchor' | 'sticky' | 'positionMethod'
|
|
23
|
-
>;
|
|
21
|
+
type Placement = Pick<PositionerProps, PlacementKey>;
|
|
24
22
|
|
|
25
23
|
/*
|
|
26
24
|
* A menu hangs under its trigger, lined up with its start, unless there is no room. A context menu
|
|
@@ -78,38 +76,21 @@ export type MenuPopupProps = ComponentProps<typeof BaseMenu.Popup> & Placement;
|
|
|
78
76
|
* decided once. The popup scrolls inside the room the window leaves it; `className` adds a minimum
|
|
79
77
|
* width or a layout of its own, such as a grid of swatches.
|
|
80
78
|
*/
|
|
81
|
-
export function MenuPopup({
|
|
82
|
-
side,
|
|
83
|
-
align,
|
|
84
|
-
sideOffset,
|
|
85
|
-
alignOffset,
|
|
86
|
-
collisionPadding,
|
|
87
|
-
collisionBoundary,
|
|
88
|
-
collisionAvoidance,
|
|
89
|
-
anchor,
|
|
90
|
-
sticky,
|
|
91
|
-
positionMethod,
|
|
92
|
-
className,
|
|
93
|
-
...props
|
|
94
|
-
}: MenuPopupProps) {
|
|
79
|
+
export function MenuPopup({ className, ...props }: MenuPopupProps) {
|
|
95
80
|
const defaults = PLACEMENT[useContext(MenuKindContext)];
|
|
81
|
+
const [placement, popupProps] = splitPlacement(props);
|
|
96
82
|
return (
|
|
97
83
|
<BaseMenu.Portal>
|
|
98
84
|
<BaseMenu.Positioner
|
|
99
85
|
className="popup-positioner"
|
|
100
86
|
render={renderPositioner}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
collisionBoundary={collisionBoundary}
|
|
107
|
-
collisionAvoidance={collisionAvoidance}
|
|
108
|
-
anchor={anchor}
|
|
109
|
-
sticky={sticky}
|
|
110
|
-
positionMethod={positionMethod}
|
|
87
|
+
{...placement}
|
|
88
|
+
side={placement.side ?? defaults.side}
|
|
89
|
+
align={placement.align ?? defaults.align}
|
|
90
|
+
sideOffset={placement.sideOffset ?? defaults.sideOffset}
|
|
91
|
+
alignOffset={placement.alignOffset ?? defaults.alignOffset}
|
|
111
92
|
>
|
|
112
|
-
<BaseMenu.Popup className={withClass('menu-popup', className)} {...
|
|
93
|
+
<BaseMenu.Popup className={withClass('menu-popup', className)} {...popupProps} />
|
|
113
94
|
</BaseMenu.Positioner>
|
|
114
95
|
</BaseMenu.Portal>
|
|
115
96
|
);
|
package/src/modality.ts
CHANGED
|
@@ -11,11 +11,10 @@ const set = (modality: Modality): void => {
|
|
|
11
11
|
document.documentElement.dataset.modality = modality;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
/* Which device the app was last driven with,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`UIProvider` starts it, and a second call does nothing. */
|
|
14
|
+
/* Which device the app was last driven with, as `data-modality` on the root. `:focus-visible` alone
|
|
15
|
+
cannot tell: focus a script moves (Base UI's hovered menu item) keeps matching it once a single
|
|
16
|
+
keystroke anywhere, a terminal included, raised the browser's keyboard flag. `UIProvider` starts
|
|
17
|
+
it; a second call does nothing. */
|
|
19
18
|
export function startInputModality(): void {
|
|
20
19
|
if (started) {
|
|
21
20
|
return;
|
package/src/placement.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* The positioner props a popup part takes beside its own, so a caller places a popup on the one element it renders. */
|
|
2
|
+
const PLACEMENT_KEYS = [
|
|
3
|
+
'side',
|
|
4
|
+
'align',
|
|
5
|
+
'sideOffset',
|
|
6
|
+
'alignOffset',
|
|
7
|
+
'collisionPadding',
|
|
8
|
+
'collisionBoundary',
|
|
9
|
+
'collisionAvoidance',
|
|
10
|
+
'anchor',
|
|
11
|
+
'sticky',
|
|
12
|
+
'positionMethod'
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type PlacementKey = (typeof PLACEMENT_KEYS)[number];
|
|
16
|
+
|
|
17
|
+
/* Splits a popup part's props into what goes to the positioner and what goes to the popup. */
|
|
18
|
+
export function splitPlacement<Props extends Partial<Record<PlacementKey, unknown>>>(props: Props): [Pick<Props, PlacementKey>, Omit<Props, PlacementKey>] {
|
|
19
|
+
const placement: Record<string, unknown> = {};
|
|
20
|
+
const rest: Record<string, unknown> = { ...props };
|
|
21
|
+
for (const key of PLACEMENT_KEYS) {
|
|
22
|
+
placement[key] = props[key];
|
|
23
|
+
delete rest[key];
|
|
24
|
+
}
|
|
25
|
+
return [placement as Pick<Props, PlacementKey>, rest as Omit<Props, PlacementKey>];
|
|
26
|
+
}
|