@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/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
- id={id ?? field?.id}
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('inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-2xs', SHAPES[shape], PADDING[shape][size], TONES[tone], mono && 'font-mono', className);
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}>
@@ -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 className={fill} style={{ backgroundColor: part.color }} aria-current={part.current ? true : undefined}>
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. It carries no margin of its own.
11
- The row's gap puts 8px on either side of it, which is what makes the line read as a divider and
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
  }
@@ -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. Until then the contents stay mounted, so a close plays out. */
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. */
@@ -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
- <span className="grid h-4 w-4 place-items-center">
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
- <span className="grid h-4 w-4 place-items-center">
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
  )}
@@ -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 the sets the tree
7
- ships with. The tree only colors its own sets, so every symbol carries its hue in its id and the
8
- sprite brings the rules that color it. A tree takes all of it as configuration where everything
9
- drawn beside it calls `fileIconFor`, so a tab, a picker row and a tree row never disagree about
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. The path may be absolute or relative. Only the last segment decides, first by exact
31
- * name (`package.json`, `.gitignore`), then by a part of the name (`Dockerfile.dev`), then by the
32
- * longest extension that matches (`spec.ts` before `ts`). Anything the set does not know falls back
33
- * to its generic file icon, and so does a directory. The tree marks a folder with the chevron that
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
 
@@ -26,8 +26,8 @@ export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
26
26
  treeProps?: Omit<PierreProps, 'model'>;
27
27
  };
28
28
 
29
- function inSlot(event: { nativeEvent: Event }): boolean {
30
- return event.nativeEvent.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute('data-tree-slot'));
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({ nativeEvent: event }) || extendsSelection(event)) {
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({ nativeEvent: event })) {
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 onContext = (event: MouseEvent): void => {
153
- if (inSlot({ nativeEvent: event })) {
154
- return;
155
- }
156
- const path = rowPathOf({ nativeEvent: event });
157
- if (path !== null) {
158
- onRowContextMenu?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
159
- }
160
- };
161
- const onDrag = (event: DragEvent): void => {
162
- if (inSlot({ nativeEvent: event })) {
163
- return;
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 './shift.ts';
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 - (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0);
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.control.remove();
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.control.remove();
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]);
@@ -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 = typeof at === 'number' ? new Date(at) : at;
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 english = wordFormatter(options).formatToParts(date);
20
- return parts.map((part) => (WORD_PARTS.has(part.type) ? (english.find((word) => word.type === part.type)?.value ?? part.value) : part.value)).join('');
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 = typeof a === 'number' ? new Date(a) : a;
59
- const right = typeof b === 'number' ? new Date(b) : b;
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
 
@@ -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) {
@@ -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
- const fixedSpecs = new Map<number, Intl.NumberFormatOptions>();
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
- /* Always `decimals` places, so values that follow each other keep one width: `1,0` and `1,5`, never `1` and `1,5`. */
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 fixedSpec = (decimals: number): Intl.NumberFormatOptions => {
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
- const roundedSpecs = new Map<number, Intl.NumberFormatOptions>();
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;
@@ -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
- side={side ?? defaults.side}
102
- align={align ?? defaults.align}
103
- sideOffset={sideOffset ?? defaults.sideOffset}
104
- alignOffset={alignOffset ?? defaults.alignOffset}
105
- collisionPadding={collisionPadding}
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)} {...props} />
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, on the root element as `data-modality`. A ring that
15
- only belongs to keyboard navigation cannot ask `:focus-visible` alone. Focus that a script moves
16
- (Base UI does that for the hovered menu item) keeps matching it while the browser's own keyboard
17
- flag is up, which a single keystroke anywhere, a terminal included, leaves on. Once per page;
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;
@@ -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
+ }