@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.
Files changed (66) hide show
  1. package/README.md +4 -4
  2. package/dist/DisabledReason.js +4 -0
  3. package/dist/Field.js +3 -2
  4. package/dist/Input.js +3 -8
  5. package/dist/SegmentBar.js +2 -1
  6. package/dist/Separator.js +2 -3
  7. package/dist/SlidingColumn.js +1 -1
  8. package/dist/ZoomControls.js +4 -1
  9. package/dist/field-context.d.ts +5 -0
  10. package/dist/field-context.js +6 -0
  11. package/dist/file-icon.js +8 -10
  12. package/dist/file-tree/parts.js +8 -15
  13. package/dist/file-tree/use-file-tree-shift.js +4 -5
  14. package/dist/file-tree/use-slots.js +31 -31
  15. package/dist/format/datetime.js +6 -5
  16. package/dist/format/locale.js +6 -1
  17. package/dist/format/number.js +15 -18
  18. package/dist/menu/parts.d.ts +3 -2
  19. package/dist/menu/parts.js +4 -2
  20. package/dist/modality.js +4 -5
  21. package/dist/placement.d.ts +4 -0
  22. package/dist/placement.js +23 -0
  23. package/dist/popover/parts.d.ts +4 -3
  24. package/dist/popover/parts.js +7 -4
  25. package/dist/shortcut.js +9 -4
  26. package/dist/tabs/parts.js +3 -2
  27. package/dist/tree/parts.js +10 -20
  28. package/dist/tree/use-tree-shift.js +1 -1
  29. package/dist/workspace/SplitView.js +20 -14
  30. package/dist/workspace/TabStrip.js +19 -11
  31. package/dist/workspace/Workspace.js +4 -3
  32. package/dist/workspace/model.js +123 -122
  33. package/package.json +1 -1
  34. package/src/DisabledReason.tsx +4 -5
  35. package/src/Field.tsx +4 -2
  36. package/src/IconButton.tsx +0 -1
  37. package/src/Input.tsx +3 -10
  38. package/src/Pill.tsx +8 -1
  39. package/src/SegmentBar.tsx +3 -10
  40. package/src/Separator.tsx +2 -3
  41. package/src/SlidingColumn.tsx +1 -1
  42. package/src/ZoomControls.tsx +11 -7
  43. package/src/field-context.ts +7 -0
  44. package/src/file-icon.ts +8 -10
  45. package/src/file-tree/parts.tsx +17 -22
  46. package/src/file-tree/use-file-tree-shift.tsx +4 -7
  47. package/src/file-tree/use-slots.ts +31 -31
  48. package/src/format/datetime.ts +7 -5
  49. package/src/format/locale.ts +7 -7
  50. package/src/format/number.ts +17 -20
  51. package/src/menu/parts.tsx +10 -29
  52. package/src/modality.ts +4 -5
  53. package/src/placement.ts +26 -0
  54. package/src/popover/parts.tsx +10 -63
  55. package/src/settings/SettingsDialog.tsx +1 -1
  56. package/src/shortcut.ts +9 -4
  57. package/src/tabs/parts.tsx +8 -8
  58. package/src/tree/parts.tsx +14 -21
  59. package/src/tree/use-tree-shift.tsx +1 -1
  60. package/src/workspace/SplitView.tsx +23 -19
  61. package/src/workspace/TabStrip.tsx +20 -12
  62. package/src/workspace/Workspace.tsx +5 -3
  63. package/src/workspace/model.ts +141 -138
  64. /package/dist/{file-tree → tree}/shift.d.ts +0 -0
  65. /package/dist/{file-tree → tree}/shift.js +0 -0
  66. /package/src/{file-tree → tree}/shift.ts +0 -0
package/README.md CHANGED
@@ -54,6 +54,10 @@ From there, every component is ready to use:
54
54
 
55
55
  [Getting started](https://adecore.dev/ui/guide/getting-started) covers the rest: the format source, a third language, the optional pieces and how to work on a local checkout.
56
56
 
57
+ ## Workspaces
58
+
59
+ `Workspace` lays out a toolbar, side regions and content in a standard or a roomy layout. `SplitView` nests resizable panels with document tabs, and `TabStrip` draws the tabs. A view stays mounted when it moves to another panel. The app keeps the documents' state, decides whether a tab may close and saves the layout. The [workspace guide](https://adecore.dev/ui/layout/workspace) covers the controlled layout model, with demos.
60
+
57
61
  ## Entry points
58
62
 
59
63
  | Import | What it holds |
@@ -81,7 +85,3 @@ Nothing else is public. No module does work on import, so a bundler keeps only w
81
85
  ## License
82
86
 
83
87
  FSL-1.1-MIT, see [LICENSE](./LICENSE).
84
-
85
- ## Workspaces
86
-
87
- `Workspace`, `SplitView` and `TabStrip` provide standard or roomy layouts, nested resizable panels and open document tabs. Views stay mounted when they move between panels. Applications own document state, close approval and persistence. See the [workspace guide](https://adecore.dev/ui/layout/workspace) for the controlled layout model and demos.
@@ -1,5 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Tooltip } from './Tooltip.js';
3
+ /*
4
+ * Why a row cannot be picked, in a tooltip beside it rather than in the row itself: a sentence in a menu
5
+ * row sets the width of the whole menu. A row with nothing in its way is handed through untouched.
6
+ */
3
7
  export function DisabledReason({ reason, children }) {
4
8
  if (reason === null) {
5
9
  return children;
package/dist/Field.js CHANGED
@@ -8,6 +8,7 @@ const HINT = 'text-xs text-text-muted';
8
8
  const ERROR = 'text-xs text-status-error';
9
9
  // Beside the control the label reads at the size of what is typed, on the middle of the control's first line however tall it grows.
10
10
  const LABEL_BESIDE = 'flex h-8 items-center self-start text-sm text-text-muted';
11
+ const isShown = (line) => line !== undefined && line !== null && line !== '';
11
12
  /* The line under a field that says what goes in it. */
12
13
  export function FieldHint({ render, className, ref, ...props }) {
13
14
  return useRender({
@@ -41,8 +42,8 @@ export function Field({ label, hint, error, group = false, orientation = 'vertic
41
42
  const horizontal = orientation === 'horizontal';
42
43
  const id = useId();
43
44
  const hasLabel = label !== undefined;
44
- const hasHint = hint !== undefined && hint !== null && hint !== '';
45
- const hasError = error !== undefined && error !== null && error !== '';
45
+ const hasHint = isShown(hint);
46
+ const hasError = isShown(error);
46
47
  const describedBy = [hasHint ? `${id}-hint` : null, hasError ? `${id}-error` : null].filter((part) => part !== null).join(' ') || undefined;
47
48
  return (_jsxs("div", { ref: ref, role: group ? 'group' : undefined, "aria-labelledby": group && hasLabel ? `${id}-label` : undefined, "aria-describedby": group ? describedBy : undefined, className: clsx(horizontal ? 'grid grid-cols-[112px_minmax(0,1fr)] gap-x-3 gap-y-1.5' : 'flex flex-col gap-1.5', className), children: [hasLabel && (_jsx(FieldLabel, { id: id, group: group, horizontal: horizontal, children: label })), _jsx(FieldContext, { value: group ? null : { id: `${id}-control`, describedBy, invalid: hasError }, children: horizontal ? _jsx("div", { className: "col-start-2 min-w-0", children: children }) : children }), hasHint && (_jsx("p", { id: `${id}-hint`, className: clsx(HINT, horizontal && 'col-start-2'), children: hint })), hasError && (_jsx("p", { id: `${id}-error`, role: "alert", className: clsx(ERROR, 'break-words', horizontal && 'col-start-2'), children: error }))] }));
48
49
  }
package/dist/Input.js CHANGED
@@ -1,17 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
- import { useFieldControl } from './field-context.js';
3
+ import { fieldControlProps, useFieldControl } from './field-context.js';
4
4
  import { Icon } from './Icon.js';
5
5
  /* The one text input: one border and one focus ring at the one height or the compact one. */
6
6
  export function Input({ size = 'md', mono = false, icon, className, id, ...props }) {
7
7
  const field = useFieldControl();
8
8
  const small = size === 'sm';
9
- const control = {
10
- id: id ?? field?.id,
11
- 'aria-describedby': field?.describedBy,
12
- 'aria-invalid': field?.invalid || undefined,
13
- ...props
14
- };
9
+ const control = { ...fieldControlProps(field, id), ...props };
15
10
  if (icon === undefined) {
16
11
  return _jsx("input", { ...control, className: clsx('field', small && 'field-sm', mono && 'font-mono text-code', className) });
17
12
  }
@@ -21,5 +16,5 @@ export function Input({ size = 'md', mono = false, icon, className, id, ...props
21
16
  /* A field of a few lines: a commit message, the body of a pull request, a note. At least 64px tall; `rows` makes it taller. */
22
17
  export function TextArea({ size = 'md', resize = 'none', mono = false, className, id, ...props }) {
23
18
  const field = useFieldControl();
24
- return (_jsx("textarea", { id: id ?? field?.id, "aria-describedby": field?.describedBy, "aria-invalid": field?.invalid || undefined, ...props, className: clsx('field h-auto min-h-16 py-1.5', size === 'sm' && 'field-sm', resize === 'vertical' ? 'resize-y' : 'resize-none', mono && 'font-mono text-code', className) }));
19
+ return (_jsx("textarea", { ...fieldControlProps(field, id), ...props, className: clsx('field h-auto min-h-16 py-1.5', size === 'sm' && 'field-sm', resize === 'vertical' ? 'resize-y' : 'resize-none', mono && 'font-mono text-code', className) }));
25
20
  }
@@ -38,7 +38,8 @@ export function SegmentBar({ parts, range, size = 'md', onSelect, label, classNa
38
38
  const text = (_jsx("span", { ref: (element) => {
39
39
  labels.current[i] = element;
40
40
  }, className: size === 'sm' ? 'sr-only' : 'min-w-0 truncate', children: part.label }));
41
- const body = onSelect === undefined ? (_jsx("span", { className: fill, style: { backgroundColor: part.color }, "aria-current": part.current ? true : undefined, children: text })) : (_jsx("button", { type: "button", className: fill, style: { backgroundColor: part.color }, "aria-current": part.current ? true : undefined, onClick: () => onSelect(index), children: text }));
41
+ const look = { className: fill, style: { backgroundColor: part.color }, 'aria-current': part.current ? true : undefined };
42
+ const body = onSelect === undefined ? (_jsx("span", { ...look, children: text })) : (_jsx("button", { type: "button", ...look, onClick: () => onSelect(index), children: text }));
42
43
  return (_jsx("li", { className: clsx('absolute inset-y-0 focus-within:z-10', touches && 'pr-0.5'), style: { left: `${left * 100}%`, width: `${(right - left) * 100}%` }, children: part.label === undefined ? (body) : (_jsx(Tooltip, { label: part.label, disabled: size === 'md' && !cut[i], children: body })) }, index));
43
44
  }) }));
44
45
  }
package/dist/Separator.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
- /* A hairline between two groups of controls in a toolbar-like row. It carries no margin of its own.
4
- The row's gap puts 8px on either side of it, which is what makes the line read as a divider and
5
- not as a group of its own. Between two groups of menu rows it is `Menu.Separator`. */
3
+ /* A hairline between two groups of controls in a toolbar-like row. No margin of its own: the row's gap
4
+ puts 8px on either side, which makes it read as a divider. Between groups of menu rows it is `Menu.Separator`. */
6
5
  export function Separator({ orientation = 'vertical', className, ref }) {
7
6
  return _jsx("span", { ref: ref, "aria-hidden": true, className: clsx(orientation === 'vertical' ? 'h-4 w-px' : 'h-px w-full', 'shrink-0 bg-border', className) });
8
7
  }
@@ -13,7 +13,7 @@ const TRANSITION_MS = 200;
13
13
  * drag handle.
14
14
  */
15
15
  export function SlidingColumn({ open, width, gap = 0, bounds, onWidthChange, instant = false, body, className, ref, children }) {
16
- /* Closed and done animating. Until then the contents stay mounted, so a close plays out. */
16
+ /* Closed and done animating. */
17
17
  const [settled, setSettled] = useState(!open);
18
18
  /* A width that lands without a transition fires no `transitionend`, so the motion it would have
19
19
  ended is over in the same commit that starts it. */
@@ -8,9 +8,12 @@ import { Kbd } from './Kbd.js';
8
8
  import { MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuRoot, MenuSeparator, MenuTrigger } from './menu/parts.js';
9
9
  import { Tooltip } from './Tooltip.js';
10
10
  import { activeZoomPreset, steppedZoom, ZOOM_PRESETS } from './zoom.js';
11
+ function MenuIcon({ icon }) {
12
+ return (_jsx("span", { className: "grid h-4 w-4 place-items-center", children: _jsx(Icon, { icon: icon, size: 14 }) }));
13
+ }
11
14
  /* The zoom group of a dock: out, the readout with its presets, in, and fit everything. */
12
15
  export function ZoomControls({ zoom, onZoomChange, onFitAll, presets = ZOOM_PRESETS, labels = {}, shortcuts = {}, selection, className, ref }) {
13
16
  const { t } = useTranslation('ui');
14
17
  const preset = activeZoomPreset(zoom, presets);
15
- return (_jsxs(ButtonGroup, { ref: ref, className: className, children: [_jsx(IconButton, { icon: Minus, label: labels.out ?? t('zoom.out'), onClick: () => onZoomChange(steppedZoom(zoom, -10)) }), _jsxs(MenuRoot, { children: [_jsx(Tooltip, { label: labels.presets ?? t('zoom.presets'), children: _jsxs(MenuTrigger, { className: "h-8 min-w-14 rounded-lg px-1 text-xs text-text-muted tabular-nums hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text", children: [Math.round(zoom * 100), "%"] }) }), _jsxs(MenuPopup, { side: "top", sideOffset: 10, align: "center", className: "min-w-44", children: [_jsx(MenuRadioGroup, { value: preset, onValueChange: (value) => onZoomChange(value / 100), children: presets.map((percent) => (_jsxs(MenuRadioItem, { value: percent, children: [_jsxs("span", { className: "tabular-nums", children: [percent, "%"] }), percent === 100 && shortcuts.zoomReset && _jsx(Kbd, { shortcut: shortcuts.zoomReset })] }, percent))) }), _jsx(MenuSeparator, {}), _jsxs(MenuItem, { onClick: onFitAll, children: [_jsx("span", { className: "grid h-4 w-4 place-items-center", children: _jsx(Icon, { icon: Maximize, size: 14 }) }), labels.fit ?? t('zoom.fit'), " ", shortcuts.fitAll && _jsx(Kbd, { shortcut: shortcuts.fitAll })] }), selection && (_jsxs(MenuItem, { disabled: !selection.enabled, onClick: selection.onZoom, children: [_jsx("span", { className: "grid h-4 w-4 place-items-center", children: _jsx(Icon, { icon: Scan, size: 14 }) }), selection.label, " ", selection.shortcut && _jsx(Kbd, { shortcut: selection.shortcut })] }))] })] }), _jsx(IconButton, { icon: Plus, label: labels.in ?? t('zoom.in'), onClick: () => onZoomChange(steppedZoom(zoom, 10)) }), _jsx(IconButton, { icon: Maximize, label: labels.fitEverything ?? t('zoom.fitEverything'), kbd: shortcuts.fitAll, onClick: onFitAll })] }));
18
+ return (_jsxs(ButtonGroup, { ref: ref, className: className, children: [_jsx(IconButton, { icon: Minus, label: labels.out ?? t('zoom.out'), onClick: () => onZoomChange(steppedZoom(zoom, -10)) }), _jsxs(MenuRoot, { children: [_jsx(Tooltip, { label: labels.presets ?? t('zoom.presets'), children: _jsxs(MenuTrigger, { className: "h-8 min-w-14 rounded-lg px-1 text-xs text-text-muted tabular-nums hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text", children: [Math.round(zoom * 100), "%"] }) }), _jsxs(MenuPopup, { side: "top", sideOffset: 10, align: "center", className: "min-w-44", children: [_jsx(MenuRadioGroup, { value: preset, onValueChange: (value) => onZoomChange(value / 100), children: presets.map((percent) => (_jsxs(MenuRadioItem, { value: percent, children: [_jsxs("span", { className: "tabular-nums", children: [percent, "%"] }), percent === 100 && shortcuts.zoomReset && _jsx(Kbd, { shortcut: shortcuts.zoomReset })] }, percent))) }), _jsx(MenuSeparator, {}), _jsxs(MenuItem, { onClick: onFitAll, children: [_jsx(MenuIcon, { icon: Maximize }), labels.fit ?? t('zoom.fit'), " ", shortcuts.fitAll && _jsx(Kbd, { shortcut: shortcuts.fitAll })] }), selection && (_jsxs(MenuItem, { disabled: !selection.enabled, onClick: selection.onZoom, children: [_jsx(MenuIcon, { icon: Scan }), selection.label, " ", selection.shortcut && _jsx(Kbd, { shortcut: selection.shortcut })] }))] })] }), _jsx(IconButton, { icon: Plus, label: labels.in ?? t('zoom.in'), onClick: () => onZoomChange(steppedZoom(zoom, 10)) }), _jsx(IconButton, { icon: Maximize, label: labels.fitEverything ?? t('zoom.fitEverything'), kbd: shortcuts.fitAll, onClick: onFitAll })] }));
16
19
  }
@@ -5,3 +5,8 @@ export interface FieldControl {
5
5
  }
6
6
  export declare const FieldContext: import("react").Context<FieldControl | null>;
7
7
  export declare const useFieldControl: () => FieldControl | null;
8
+ export declare const fieldControlProps: (field: FieldControl | null, id: string | undefined) => {
9
+ id: string | undefined;
10
+ 'aria-describedby': string | undefined;
11
+ 'aria-invalid': true | undefined;
12
+ };
@@ -2,3 +2,9 @@ import { createContext, useContext } from 'react';
2
2
  export const FieldContext = createContext(null);
3
3
  /* 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. */
4
4
  export const useFieldControl = () => useContext(FieldContext);
5
+ /* The attributes that connect a control to its field; an `id` the caller sets wins over the field's. */
6
+ export const fieldControlProps = (field, id) => ({
7
+ id: id ?? field?.id,
8
+ 'aria-describedby': field?.describedBy,
9
+ 'aria-invalid': field?.invalid || undefined
10
+ });
package/dist/file-icon.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import { createFileTreeIconResolver } from '@pierre/trees';
2
2
  import { SETI_BY_FILE_EXTENSION, SETI_BY_FILE_NAME, SETI_BY_FILE_NAME_CONTAINS, SETI_DEFAULT_SYMBOL, SETI_SPRITE_SHEET } from './seti-icons.js';
3
3
  const SPRITE_ELEMENT_ID = 'adecore-ui-file-icon-sprite';
4
- /* The glyphs are Seti's (`seti-icons.ts`), which knows far more file types than the sets the tree
5
- ships with. The tree only colors its own sets, so every symbol carries its hue in its id and the
6
- sprite brings the rules that color it. A tree takes all of it as configuration where everything
7
- drawn beside it calls `fileIconFor`, so a tab, a picker row and a tree row never disagree about
8
- what a file is. Hand this to the tree. */
4
+ /* Hand this to the tree. The glyphs are Seti's (`seti-icons.ts`), which knows far more file types than
5
+ the tree's own sets. The tree colors only those, so every symbol carries its hue in its id and the
6
+ sprite brings the rules that color it. Everything drawn beside a tree calls `fileIconFor`, so a tab,
7
+ a picker row and a tree row never disagree about what a file is. */
9
8
  export const FILE_TREE_ICONS = {
10
9
  set: 'none',
11
10
  spriteSheet: SETI_SPRITE_SHEET,
@@ -22,11 +21,10 @@ const resolveIcon = (...args) => {
22
21
  };
23
22
  /*
24
23
  * The id of the `<symbol>` the set gives a file, in the sprite `mountFileIconSprite` puts in the
25
- * document. The path may be absolute or relative. Only the last segment decides, first by exact
26
- * name (`package.json`, `.gitignore`), then by a part of the name (`Dockerfile.dev`), then by the
27
- * longest extension that matches (`spec.ts` before `ts`). Anything the set does not know falls back
28
- * to its generic file icon, and so does a directory. The tree marks a folder with the chevron that
29
- * turns as it opens, not with a glyph of its own.
24
+ * document. Only the last segment of the path decides, first by exact name (`package.json`), then by
25
+ * a part of the name (`Dockerfile.dev`), then by the longest extension that matches (`spec.ts` before
26
+ * `ts`). Anything else falls back to the generic file icon, a directory included: the tree marks a
27
+ * folder with its chevron, not a glyph.
30
28
  */
31
29
  export const fileIconFor = (path) => resolveIcon('file-tree-icon-file', path).name;
32
30
  /* The tree carries the sprite into its own shadow root, out of reach of anything that draws an
@@ -10,7 +10,7 @@ import { directoryHandle, extendsSelection, followFocus, movesFocus, menuTargets
10
10
  import { useFileTreeShift } from './use-file-tree-shift.js';
11
11
  import { useFileTreeSlots } from './use-slots.js';
12
12
  function inSlot(event) {
13
- return event.nativeEvent.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute('data-tree-slot'));
13
+ return event.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute('data-tree-slot'));
14
14
  }
15
15
  export function FileTreeRoot({ model, label, resetKey, selectionFollowsFocus = true, onActivate, onFocusMove, onLoadChildren, onExpandedPathsChange, onRowContextMenu, onRowDragStart, renderControl, renderDecoration, treeProps, render, className, ref, children, onClick, onKeyDownCapture, ...props }) {
16
16
  const { t } = useTranslation('ui');
@@ -73,7 +73,7 @@ export function FileTreeRoot({ model, label, resetKey, selectionFollowsFocus = t
73
73
  return;
74
74
  }
75
75
  const onClickRow = (event) => {
76
- if (event.defaultPrevented || inSlot({ nativeEvent: event }) || extendsSelection(event)) {
76
+ if (event.defaultPrevented || inSlot(event) || extendsSelection(event)) {
77
77
  return;
78
78
  }
79
79
  const path = rowPathOf({ nativeEvent: event });
@@ -82,7 +82,7 @@ export function FileTreeRoot({ model, label, resetKey, selectionFollowsFocus = t
82
82
  }
83
83
  };
84
84
  const onKey = (event) => {
85
- if (event.defaultPrevented || inSlot({ nativeEvent: event })) {
85
+ if (event.defaultPrevented || inSlot(event)) {
86
86
  return;
87
87
  }
88
88
  if (movesFocus(event) && selectionFollowsFocus) {
@@ -106,24 +106,17 @@ export function FileTreeRoot({ model, label, resetKey, selectionFollowsFocus = t
106
106
  }
107
107
  }
108
108
  };
109
- const onContext = (event) => {
110
- if (inSlot({ nativeEvent: event })) {
109
+ const withTargets = (handle) => (event) => {
110
+ if (inSlot(event)) {
111
111
  return;
112
112
  }
113
113
  const path = rowPathOf({ nativeEvent: event });
114
114
  if (path !== null) {
115
- onRowContextMenu?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
116
- }
117
- };
118
- const onDrag = (event) => {
119
- if (inSlot({ nativeEvent: event })) {
120
- return;
121
- }
122
- const path = rowPathOf({ nativeEvent: event });
123
- if (path !== null) {
124
- onRowDragStart?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
115
+ handle?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
125
116
  }
126
117
  };
118
+ const onContext = withTargets(onRowContextMenu);
119
+ const onDrag = withTargets(onRowDragStart);
127
120
  frame.addEventListener('click', onClickRow);
128
121
  frame.addEventListener('keydown', onKey, true);
129
122
  frame.addEventListener('contextmenu', onContext);
@@ -1,12 +1,13 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useTreeShift } from '../tree/use-tree-shift.js';
3
- import { shiftNeed } from './shift.js';
3
+ import { shiftNeed } from '../tree/shift.js';
4
4
  const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
5
5
  function boxesOf(element) {
6
6
  return element.getClientRects().length > 0 ? [element] : [...element.children].flatMap((child) => boxesOf(child));
7
7
  }
8
8
  function limitOf(row, content, gap) {
9
9
  const start = content.getBoundingClientRect().left;
10
+ const contentMargin = Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0;
10
11
  let limit = Number.POSITIVE_INFINITY;
11
12
  for (const part of [...row.children].filter((child) => child !== content).flatMap((child) => boxesOf(child))) {
12
13
  const rect = part.getBoundingClientRect();
@@ -19,11 +20,9 @@ function limitOf(row, content, gap) {
19
20
  limit = Math.min(limit, rect.left - gap);
20
21
  }
21
22
  if (limit !== Number.POSITIVE_INFINITY) {
22
- return limit - (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0);
23
+ return limit - contentMargin;
23
24
  }
24
- return (row.getBoundingClientRect().right -
25
- Number.parseFloat(getComputedStyle(row).paddingInlineEnd) -
26
- (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0));
25
+ return row.getBoundingClientRect().right - Number.parseFloat(getComputedStyle(row).paddingInlineEnd) - contentMargin;
27
26
  }
28
27
  function needOf(row, shift, gap, range) {
29
28
  const content = row.querySelector(':scope > [data-item-section="content"]');
@@ -35,6 +35,33 @@ export function useFileTreeSlots(model, frame, label) {
35
35
  }
36
36
  };
37
37
  const resize = new ResizeObserver(position);
38
+ const dropPort = (port) => {
39
+ port.control.remove();
40
+ port.decoration.remove();
41
+ resize.unobserve(port.lightControl);
42
+ resize.unobserve(port.lightDecoration);
43
+ port.lightControl.remove();
44
+ port.lightDecoration.remove();
45
+ };
46
+ const makePort = (element, host, kind) => {
47
+ const container = document.createElement('span');
48
+ container.dataset.treePort = kind;
49
+ const slot = document.createElement('slot');
50
+ slot.name = `${prefix}-${kind}-${serial}`;
51
+ container.append(slot);
52
+ element.parentElement?.append(container);
53
+ const target = document.createElement('span');
54
+ target.slot = slot.name;
55
+ target.dataset.treeSlot = kind;
56
+ target.className = kind === 'control' ? 'adecore-tree-control' : 'adecore-tree-decoration';
57
+ // React delegates to this portal target before the event reaches the engine's shadow tree.
58
+ for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'pointerdown', 'dragstart']) {
59
+ target.addEventListener(type, (event) => event.stopPropagation());
60
+ }
61
+ host.append(target);
62
+ resize.observe(target);
63
+ return { container, target };
64
+ };
38
65
  const scan = () => {
39
66
  pending = false;
40
67
  if (disposed) {
@@ -68,12 +95,7 @@ export function useFileTreeSlots(model, frame, label) {
68
95
  const live = new Set(elements);
69
96
  for (const [element, port] of ports) {
70
97
  if (!live.has(element)) {
71
- port.control.remove();
72
- port.decoration.remove();
73
- resize.unobserve(port.lightControl);
74
- resize.unobserve(port.lightDecoration);
75
- port.lightControl.remove();
76
- port.lightDecoration.remove();
98
+ dropPort(port);
77
99
  ports.delete(element);
78
100
  }
79
101
  }
@@ -89,28 +111,9 @@ export function useFileTreeSlots(model, frame, label) {
89
111
  element.dataset.joinedEnd = String(row.isSelected && rows[row.index + 1]?.isSelected === true);
90
112
  let port = ports.get(element);
91
113
  if (port === undefined) {
92
- const makePort = (kind) => {
93
- const container = document.createElement('span');
94
- container.dataset.treePort = kind;
95
- const slot = document.createElement('slot');
96
- slot.name = `${prefix}-${kind}-${serial}`;
97
- container.append(slot);
98
- element.parentElement?.append(container);
99
- const target = document.createElement('span');
100
- target.slot = slot.name;
101
- target.dataset.treeSlot = kind;
102
- target.className = kind === 'control' ? 'adecore-tree-control' : 'adecore-tree-decoration';
103
- // React delegates to this portal target before the event reaches the engine's shadow tree.
104
- for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'pointerdown', 'dragstart']) {
105
- target.addEventListener(type, (event) => event.stopPropagation());
106
- }
107
- current.append(target);
108
- resize.observe(target);
109
- return { container, target };
110
- };
111
114
  serial++;
112
- const control = makePort('control');
113
- const decoration = makePort('decoration');
115
+ const control = makePort(element, current, 'control');
116
+ const decoration = makePort(element, current, 'decoration');
114
117
  port = {
115
118
  rowElement: element,
116
119
  control: control.container,
@@ -162,10 +165,7 @@ export function useFileTreeSlots(model, frame, label) {
162
165
  for (const port of ports.values()) {
163
166
  port.rowElement.style.removeProperty('--adecore-tree-control-space');
164
167
  port.rowElement.style.removeProperty('--adecore-tree-decoration-space');
165
- port.control.remove();
166
- port.decoration.remove();
167
- port.lightControl.remove();
168
- port.lightDecoration.remove();
168
+ dropPort(port);
169
169
  }
170
170
  };
171
171
  }, [frame, model, label, prefix]);
@@ -1,6 +1,7 @@
1
1
  import { dateFormatter, wordFormatter } from './locale.js';
2
2
  /* The parts that are a word rather than a number, and so come from the language and not the region. */
3
3
  const WORD_PARTS = new Set(['month', 'weekday', 'dayPeriod', 'era']);
4
+ const toDate = (at) => (typeof at === 'number' ? new Date(at) : at);
4
5
  /*
5
6
  * A date written the way the region writes one, with its words in the language the interface is in.
6
7
  * Both formatters get the same options and the same instant, so every part the region asks for has
@@ -9,13 +10,13 @@ const WORD_PARTS = new Set(['month', 'weekday', 'dayPeriod', 'era']);
9
10
  * region does not say AM.
10
11
  */
11
12
  export const formatDateTime = (at, options) => {
12
- const date = typeof at === 'number' ? new Date(at) : at;
13
+ const date = toDate(at);
13
14
  const parts = dateFormatter(options).formatToParts(date);
14
15
  if (!parts.some((part) => WORD_PARTS.has(part.type))) {
15
16
  return parts.map((part) => part.value).join('');
16
17
  }
17
- const english = wordFormatter(options).formatToParts(date);
18
- return parts.map((part) => (WORD_PARTS.has(part.type) ? (english.find((word) => word.type === part.type)?.value ?? part.value) : part.value)).join('');
18
+ const words = wordFormatter(options).formatToParts(date);
19
+ return parts.map((part) => (WORD_PARTS.has(part.type) ? (words.find((word) => word.type === part.type)?.value ?? part.value) : part.value)).join('');
19
20
  };
20
21
  const CLOCK = { hour: '2-digit', minute: '2-digit' };
21
22
  const WEEKDAY_CLOCK = { weekday: 'short', hour: '2-digit', minute: '2-digit' };
@@ -43,8 +44,8 @@ export const formatDayClock = (at) => formatDateTime(at, DAY_CLOCK);
43
44
  export const formatNumericDate = (at) => formatDateTime(at, NUMERIC_DATE);
44
45
  /* Whether two instants fall on the same day of the same year, in the zone of whoever is reading. */
45
46
  export const isSameDay = (a, b) => {
46
- const left = typeof a === 'number' ? new Date(a) : a;
47
- const right = typeof b === 'number' ? new Date(b) : b;
47
+ const left = toDate(a);
48
+ const right = toDate(b);
48
49
  return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
49
50
  };
50
51
  export const formatMoment = (at, now = Date.now()) => (isSameDay(at, now) ? formatClock(at) : formatDayClock(at));
@@ -63,10 +63,15 @@ const resolve = (region) => {
63
63
  /* The locale every formatter in this folder is built on. Read outside React as well, so it is a
64
64
  plain function over the source rather than a hook. */
65
65
  export const formatLocale = () => resolve(source.region());
66
+ const subscribe = (onChange) => source.subscribe(onChange);
66
67
  /* What a component calls to draw again once the region changes. The value is the locale, which a
67
68
  caller may use or ignore; subscribing is the point. */
68
- const subscribe = (onChange) => source.subscribe(onChange);
69
69
  export const useFormatLocale = () => useSyncExternalStore(subscribe, formatLocale, formatLocale);
70
+ /*
71
+ * A formatter per options object, keyed on the object itself, so a module-level spec builds its
72
+ * formatter once and a list of a thousand rows constructs nothing. A region change throws the entry
73
+ * away, since the locale is part of what was cached.
74
+ */
70
75
  const cacheFor = (store, options, locale, build) => {
71
76
  const hit = store.get(options);
72
77
  if (hit !== undefined && hit.locale === locale) {
@@ -5,27 +5,24 @@ const ONE_DECIMAL = { maximumFractionDigits: 1 };
5
5
  export const formatNumber = (value) => numberFormatter(WHOLE).format(Math.round(value));
6
6
  /* The same, to one decimal, where the fraction is the part that means something. */
7
7
  export const formatDecimal = (value) => numberFormatter(ONE_DECIMAL).format(value);
8
- const fixedSpecs = new Map();
8
+ /* One options object per key, so the formatter cached on it is built once (`numberFormatter`). */
9
+ const specCache = (build) => {
10
+ const specs = new Map();
11
+ return (key) => {
12
+ let spec = specs.get(key);
13
+ if (spec === undefined) {
14
+ spec = build(key);
15
+ specs.set(key, spec);
16
+ }
17
+ return spec;
18
+ };
19
+ };
20
+ const fixedSpec = specCache((decimals) => ({ minimumFractionDigits: decimals, maximumFractionDigits: decimals }));
21
+ const roundedSpec = specCache((decimals) => ({ maximumFractionDigits: decimals }));
9
22
  /* Always `decimals` places, so values that follow each other keep one width: `1,0` and `1,5`, never `1` and `1,5`. */
10
23
  export const formatFixed = (value, decimals) => numberFormatter(fixedSpec(decimals)).format(value);
11
- const fixedSpec = (decimals) => {
12
- let spec = fixedSpecs.get(decimals);
13
- if (spec === undefined) {
14
- spec = { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
15
- fixedSpecs.set(decimals, spec);
16
- }
17
- return spec;
18
- };
19
- const roundedSpecs = new Map();
20
24
  /* At most `decimals` places, trailing zeros dropped, for a figure whose fraction may or may not be there: `39`, `1,95`. */
21
- export const formatRounded = (value, decimals) => {
22
- let spec = roundedSpecs.get(decimals);
23
- if (spec === undefined) {
24
- spec = { maximumFractionDigits: decimals };
25
- roundedSpecs.set(decimals, spec);
26
- }
27
- return numberFormatter(spec).format(value);
28
- };
25
+ export const formatRounded = (value, decimals) => numberFormatter(roundedSpec(decimals)).format(value);
29
26
  /* Intl takes at most this many fraction digits. */
30
27
  const MAX_FRACTION_DIGITS = 100;
31
28
  const NUMERAL = /^[+-]?\d+(?:\.(\d+))?$/;
@@ -4,8 +4,9 @@ import { ContextMenu as BaseContextMenu } from '@base-ui-components/react/contex
4
4
  import { Menu as BaseMenu } from '@base-ui-components/react/menu';
5
5
  import { Separator as BaseSeparator } from '@base-ui-components/react/separator';
6
6
  import { useRender } from '@base-ui-components/react/use-render';
7
+ import { type PlacementKey } from '../placement.ts';
7
8
  type PositionerProps = ComponentProps<typeof BaseMenu.Positioner>;
8
- type Placement = Pick<PositionerProps, 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding' | 'collisionBoundary' | 'collisionAvoidance' | 'anchor' | 'sticky' | 'positionMethod'>;
9
+ type Placement = Pick<PositionerProps, PlacementKey>;
9
10
  export declare const MENU_LABEL = "px-2.5 pt-1.5 pb-0.5 text-xs/[inherit] text-text-faint";
10
11
  export declare function MenuRoot(props: ComponentProps<typeof BaseMenu.Root>): import("react").JSX.Element;
11
12
  export declare function ContextMenuRoot(props: ComponentProps<typeof BaseContextMenu.Root>): import("react").JSX.Element;
@@ -13,7 +14,7 @@ export declare function MenuSubmenuRoot(props: ComponentProps<typeof BaseMenu.Su
13
14
  export declare const MenuTrigger: BaseMenu.Trigger;
14
15
  export declare const ContextMenuTrigger: import("react").ForwardRefExoticComponent<import("@base-ui-components/react/context-menu").ContextMenuTriggerProps & import("react").RefAttributes<HTMLDivElement>>;
15
16
  export type MenuPopupProps = ComponentProps<typeof BaseMenu.Popup> & Placement;
16
- export declare function MenuPopup({ side, align, sideOffset, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod, className, ...props }: MenuPopupProps): import("react").JSX.Element;
17
+ export declare function MenuPopup({ className, ...props }: MenuPopupProps): import("react").JSX.Element;
17
18
  type ItemLook = {
18
19
  unstyled?: boolean;
19
20
  };
@@ -9,6 +9,7 @@ import { useRender } from '@base-ui-components/react/use-render';
9
9
  import { withClass } from '../class-name.js';
10
10
  import { Icon } from '../Icon.js';
11
11
  import { Tooltip } from '../Tooltip.js';
12
+ import { splitPlacement } from '../placement.js';
12
13
  import { renderPositioner } from '../popup-layer.js';
13
14
  const MenuKindContext = createContext('menu');
14
15
  /*
@@ -44,9 +45,10 @@ export const ContextMenuTrigger = BaseContextMenu.Trigger;
44
45
  * decided once. The popup scrolls inside the room the window leaves it; `className` adds a minimum
45
46
  * width or a layout of its own, such as a grid of swatches.
46
47
  */
47
- export function MenuPopup({ side, align, sideOffset, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod, className, ...props }) {
48
+ export function MenuPopup({ className, ...props }) {
48
49
  const defaults = PLACEMENT[useContext(MenuKindContext)];
49
- return (_jsx(BaseMenu.Portal, { children: _jsx(BaseMenu.Positioner, { className: "popup-positioner", render: renderPositioner, side: side ?? defaults.side, align: align ?? defaults.align, sideOffset: sideOffset ?? defaults.sideOffset, alignOffset: alignOffset ?? defaults.alignOffset, collisionPadding: collisionPadding, collisionBoundary: collisionBoundary, collisionAvoidance: collisionAvoidance, anchor: anchor, sticky: sticky, positionMethod: positionMethod, children: _jsx(BaseMenu.Popup, { className: withClass('menu-popup', className), ...props }) }) }));
50
+ const [placement, popupProps] = splitPlacement(props);
51
+ return (_jsx(BaseMenu.Portal, { children: _jsx(BaseMenu.Positioner, { className: "popup-positioner", render: renderPositioner, ...placement, side: placement.side ?? defaults.side, align: placement.align ?? defaults.align, sideOffset: placement.sideOffset ?? defaults.sideOffset, alignOffset: placement.alignOffset ?? defaults.alignOffset, children: _jsx(BaseMenu.Popup, { className: withClass('menu-popup', className), ...popupProps }) }) }));
50
52
  }
51
53
  export function MenuItem({ unstyled = false, className, ...props }) {
52
54
  return _jsx(BaseMenu.Item, { className: unstyled ? className : withClass('menu-item', className), ...props });
package/dist/modality.js CHANGED
@@ -7,11 +7,10 @@ const set = (modality) => {
7
7
  current = modality;
8
8
  document.documentElement.dataset.modality = modality;
9
9
  };
10
- /* Which device the app was last driven with, on the root element as `data-modality`. A ring that
11
- only belongs to keyboard navigation cannot ask `:focus-visible` alone. Focus that a script moves
12
- (Base UI does that for the hovered menu item) keeps matching it while the browser's own keyboard
13
- flag is up, which a single keystroke anywhere, a terminal included, leaves on. Once per page;
14
- `UIProvider` starts it, and a second call does nothing. */
10
+ /* Which device the app was last driven with, as `data-modality` on the root. `:focus-visible` alone
11
+ cannot tell: focus a script moves (Base UI's hovered menu item) keeps matching it once a single
12
+ keystroke anywhere, a terminal included, raised the browser's keyboard flag. `UIProvider` starts
13
+ it; a second call does nothing. */
15
14
  export function startInputModality() {
16
15
  if (started) {
17
16
  return;
@@ -0,0 +1,4 @@
1
+ declare const PLACEMENT_KEYS: readonly ['side', 'align', 'sideOffset', 'alignOffset', 'collisionPadding', 'collisionBoundary', 'collisionAvoidance', 'anchor', 'sticky', 'positionMethod'];
2
+ export type PlacementKey = (typeof PLACEMENT_KEYS)[number];
3
+ export declare function splitPlacement<Props extends Partial<Record<PlacementKey, unknown>>>(props: Props): [Pick<Props, PlacementKey>, Omit<Props, PlacementKey>];
4
+ export {};
@@ -0,0 +1,23 @@
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
+ ];
14
+ /* Splits a popup part's props into what goes to the positioner and what goes to the popup. */
15
+ export function splitPlacement(props) {
16
+ const placement = {};
17
+ const rest = { ...props };
18
+ for (const key of PLACEMENT_KEYS) {
19
+ placement[key] = props[key];
20
+ delete rest[key];
21
+ }
22
+ return [placement, rest];
23
+ }
@@ -1,13 +1,14 @@
1
1
  import type { ComponentProps } from 'react';
2
2
  import { Popover as BasePopover } from '@base-ui-components/react/popover';
3
3
  import { PreviewCard as BasePreviewCard } from '@base-ui-components/react/preview-card';
4
+ import { type PlacementKey } from '../placement.ts';
4
5
  declare const POPUP_VARIANT: {
5
6
  readonly menu: 'menu-popup';
6
7
  readonly picker: 'picker-popup';
7
8
  readonly plain: '';
8
9
  };
9
10
  export type PopupVariant = keyof typeof POPUP_VARIANT;
10
- type Placement = Pick<ComponentProps<typeof BasePopover.Positioner>, 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding' | 'collisionBoundary' | 'collisionAvoidance' | 'anchor' | 'sticky' | 'positionMethod'>;
11
+ type Placement = Pick<ComponentProps<typeof BasePopover.Positioner>, PlacementKey>;
11
12
  export declare const PopoverRoot: typeof BasePopover.Root;
12
13
  export declare const PopoverTrigger: BasePopover.Trigger;
13
14
  export declare const PopoverClose: import("react").ForwardRefExoticComponent<import("@base-ui-components/react/popover").PopoverCloseProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -16,11 +17,11 @@ export declare const PopoverDescription: import("react").ForwardRefExoticCompone
16
17
  export type PopoverPopupProps = ComponentProps<typeof BasePopover.Popup> & Placement & {
17
18
  variant?: PopupVariant;
18
19
  };
19
- export declare function PopoverPopup({ variant, side, align, sideOffset, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod, className, ...props }: PopoverPopupProps): import("react").JSX.Element;
20
+ export declare function PopoverPopup({ variant, side, align, sideOffset, className, ...props }: PopoverPopupProps): import("react").JSX.Element;
20
21
  export declare const PreviewCardRoot: typeof BasePreviewCard.Root;
21
22
  export declare const PreviewCardTrigger: import("react").ForwardRefExoticComponent<import("@base-ui-components/react/preview-card").PreviewCardTriggerProps & import("react").RefAttributes<HTMLAnchorElement>>;
22
23
  export type PreviewCardPopupProps = ComponentProps<typeof BasePreviewCard.Popup> & Placement & {
23
24
  variant?: PopupVariant;
24
25
  };
25
- export declare function PreviewCardPopup({ variant, side, align, sideOffset, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod, className, ...props }: PreviewCardPopupProps): import("react").JSX.Element;
26
+ export declare function PreviewCardPopup({ variant, side, align, sideOffset, className, ...props }: PreviewCardPopupProps): import("react").JSX.Element;
26
27
  export {};