@adecore/ui 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Button.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { ComponentProps } from 'react';
2
2
  export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-outline' | 'positive' | 'inverse';
3
3
  export type ButtonSize = 'xs' | 'sm' | 'md';
4
+ export type ButtonShape = 'default' | 'pill';
4
5
  export interface ButtonProps extends ComponentProps<'button'> {
5
6
  variant?: ButtonVariant;
6
7
  size?: ButtonSize;
8
+ shape?: ButtonShape;
7
9
  href?: string;
8
10
  }
9
- export declare function Button({ variant, size, className, href, type, ref, ...rest }: ButtonProps): import("react").JSX.Element;
11
+ export declare function Button({ variant, size, shape, className, href, type, ref, ...rest }: ButtonProps): import("react").JSX.Element;
package/dist/Button.js CHANGED
@@ -18,9 +18,14 @@ const SIZE = {
18
18
  sm: 'h-7 px-2.5',
19
19
  md: 'h-8 px-3'
20
20
  };
21
+ /* A pill is one size of its own, for a control that sits in a round row, so it takes the place of `size`. */
22
+ const SHAPE = {
23
+ default: 'rounded-md text-xs',
24
+ pill: 'h-9 rounded-full px-3.5 text-sm'
25
+ };
21
26
  /* Every button with a word in it. A button that is only an icon is an `IconButton`, which is a square, not a label. */
22
- export function Button({ variant = 'ghost', size = 'md', className, href, type = 'button', ref, ...rest }) {
23
- const classes = clsx('inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-xs font-medium disabled:opacity-50', VARIANT[variant], SIZE[size], className);
27
+ export function Button({ variant = 'ghost', size = 'md', shape = 'default', className, href, type = 'button', ref, ...rest }) {
28
+ const classes = clsx('inline-flex shrink-0 items-center justify-center gap-1.5 font-medium disabled:opacity-50', SHAPE[shape], VARIANT[variant], shape === 'default' && SIZE[size], className);
24
29
  if (href) {
25
30
  return (_jsx("a", { ref: ref, className: classes, href: href, target: "_blank", rel: "noreferrer", ...rest }));
26
31
  }
@@ -5,12 +5,10 @@ export interface EmptyStateProps {
5
5
  size?: number;
6
6
  }>;
7
7
  busy?: boolean;
8
- /** @deprecated Turns a Lucide icon; use `busy`, which draws a `Spinner` in its place. */
9
- spin?: boolean;
10
8
  title?: ReactNode;
11
9
  children: ReactNode;
12
10
  action?: ReactNode;
13
11
  className?: string;
14
12
  ref?: Ref<HTMLDivElement>;
15
13
  }
16
- export declare function EmptyState({ icon, busy, spin, title, children, action, className, ref }: EmptyStateProps): import("react").JSX.Element;
14
+ export declare function EmptyState({ icon, busy, title, children, action, className, ref }: EmptyStateProps): import("react").JSX.Element;
@@ -6,6 +6,6 @@ import { Spinner } from './Spinner.js';
6
6
  // One size in a panel, a card and a dialog alike, so no caller picks its own.
7
7
  const ICON_SIZE = 20;
8
8
  /* What a list, a canvas or a thread shows before it holds anything. */
9
- export function EmptyState({ icon, busy = false, spin = false, title, children, action, className, ref }) {
10
- return (_jsxs("div", { ref: ref, className: clsx('flex flex-col items-center justify-center gap-2 px-6 py-8 text-center', className), children: [(busy || icon) && (_jsx("span", { className: "text-text-faint", children: busy ? (_jsx(Spinner, { size: ICON_SIZE })) : isValidElement(icon) ? (cloneElement(icon, { size: ICON_SIZE })) : (icon && _jsx(Icon, { icon: icon, size: ICON_SIZE, className: spin ? 'animate-spin' : undefined })) })), title && _jsx("p", { className: "text-sm font-medium text-text", children: title }), _jsx("p", { className: "max-w-[280px] text-xs leading-snug text-text-muted", children: children }), action && _jsx("div", { className: "mt-2", children: action })] }));
9
+ export function EmptyState({ icon, busy = false, title, children, action, className, ref }) {
10
+ return (_jsxs("div", { ref: ref, className: clsx('flex flex-col items-center justify-center gap-2 px-6 py-8 text-center', className), children: [(busy || icon) && (_jsx("span", { className: "text-text-faint", children: busy ? (_jsx(Spinner, { size: ICON_SIZE })) : isValidElement(icon) ? (cloneElement(icon, { size: ICON_SIZE })) : (icon && _jsx(Icon, { icon: icon, size: ICON_SIZE })) })), title && _jsx("p", { className: "text-sm font-medium text-text", children: title }), _jsx("p", { className: "max-w-[280px] text-xs leading-snug text-text-muted", children: children }), action && _jsx("div", { className: "mt-2", children: action })] }));
11
11
  }
@@ -14,10 +14,8 @@ export interface IconButtonProps extends Omit<ComponentProps<'button'>, 'childre
14
14
  size?: IconButtonSize;
15
15
  active?: boolean;
16
16
  busy?: boolean;
17
- /** @deprecated Turns the icon; use `busy`, which draws a `Spinner` in its place. */
18
- spin?: boolean;
19
17
  iconClassName?: string;
20
18
  children?: ReactNode;
21
19
  render?: useRender.RenderProp;
22
20
  }
23
- export declare function IconButton({ icon, label, tooltip, kbd, tooltipSide, size, active, busy, spin, iconClassName, render, className, ref, children, ...props }: IconButtonProps): import("react").JSX.Element;
21
+ export declare function IconButton({ icon, label, tooltip, kbd, tooltipSide, size, active, busy, iconClassName, render, className, ref, children, ...props }: IconButtonProps): import("react").JSX.Element;
@@ -6,7 +6,7 @@ import { Spinner } from './Spinner.js';
6
6
  import { Tooltip } from './Tooltip.js';
7
7
  import { ICON_BUTTON_CLASS, ICON_BUTTON_ICON_SIZE } from './icon-button-size.js';
8
8
  /* Every button that is an icon and no word. A square, and the one place the icon inside it is sized. */
9
- export function IconButton({ icon, label, tooltip, kbd, tooltipSide, size = 'md', active = false, busy = false, spin = false, iconClassName, render, className, ref, children, ...props }) {
9
+ export function IconButton({ icon, label, tooltip, kbd, tooltipSide, size = 'md', active = false, busy = false, iconClassName, render, className, ref, children, ...props }) {
10
10
  const button = useRender({
11
11
  render: render ?? _jsx("button", { type: "button" }),
12
12
  ref,
@@ -15,7 +15,7 @@ export function IconButton({ icon, label, tooltip, kbd, tooltipSide, size = 'md'
15
15
  'data-active': active ? 'true' : undefined,
16
16
  ...props,
17
17
  className: clsx('icon-btn', ICON_BUTTON_CLASS[size], className),
18
- children: (_jsxs(_Fragment, { children: [busy ? (_jsx(Spinner, { size: ICON_BUTTON_ICON_SIZE[size], className: iconClassName })) : (icon !== undefined && _jsx(Icon, { icon: icon, size: ICON_BUTTON_ICON_SIZE[size], className: clsx(spin && 'animate-spin', iconClassName) })), children] }))
18
+ children: (_jsxs(_Fragment, { children: [busy ? (_jsx(Spinner, { size: ICON_BUTTON_ICON_SIZE[size], className: iconClassName })) : (icon !== undefined && _jsx(Icon, { icon: icon, size: ICON_BUTTON_ICON_SIZE[size], className: iconClassName })), children] }))
19
19
  }
20
20
  });
21
21
  if (tooltip === false) {
@@ -0,0 +1,6 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface LazyBoundaryProps {
3
+ readonly fallback?: ReactNode;
4
+ readonly children?: ReactNode;
5
+ }
6
+ export declare function LazyBoundary({ fallback, children }: LazyBoundaryProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Suspense, useSyncExternalStore } from 'react';
3
+ import { readLandings, subscribeLandings } from './lazy-landings.js';
4
+ /*
5
+ * `Suspense` for what `lazyNamed` loads. React holds content that comes in as a retry back until
6
+ * 300 ms after its fallback showed; this boundary draws again as an ordinary update once a module
7
+ * lands, so a chunk that takes 20 ms shows after 20 ms.
8
+ */
9
+ export function LazyBoundary({ fallback = null, children }) {
10
+ useSyncExternalStore(subscribeLandings, readLandings, readLandings);
11
+ return _jsx(Suspense, { fallback: fallback, children: children });
12
+ }
@@ -4,8 +4,6 @@ export interface PanelEmptyProps {
4
4
  header?: ReactNode;
5
5
  icon?: LucideIcon;
6
6
  busy?: boolean;
7
- /** @deprecated Turns the icon; use `busy`, which draws a `Spinner` in its place. */
8
- spin?: boolean;
9
7
  action?: ReactNode;
10
8
  sunken?: boolean;
11
9
  fill?: 'grow' | 'full';
@@ -13,4 +11,4 @@ export interface PanelEmptyProps {
13
11
  className?: string;
14
12
  ref?: Ref<HTMLDivElement>;
15
13
  }
16
- export declare function PanelEmpty({ header, icon, busy, spin, action, sunken, fill, children, className, ref }: PanelEmptyProps): import("react").JSX.Element;
14
+ export declare function PanelEmpty({ header, icon, busy, action, sunken, fill, children, className, ref }: PanelEmptyProps): import("react").JSX.Element;
@@ -2,6 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
3
  import { EmptyState } from './EmptyState.js';
4
4
  /* What a panel or a card shows while it holds nothing: a sentence, one icon, at most one button. */
5
- export function PanelEmpty({ header, icon, busy = false, spin = false, action, sunken = false, fill = 'grow', children, className, ref }) {
6
- return (_jsxs("div", { ref: ref, className: clsx('grid min-h-0 place-items-center', fill === 'full' ? 'h-full' : 'grow', sunken && 'bg-surface-sunken', className), children: [header, _jsx(EmptyState, { icon: icon, busy: busy, spin: spin, action: action, children: children })] }));
5
+ export function PanelEmpty({ header, icon, busy = false, action, sunken = false, fill = 'grow', children, className, ref }) {
6
+ return (_jsxs("div", { ref: ref, className: clsx('grid min-h-0 place-items-center', fill === 'full' ? 'h-full' : 'grow', sunken && 'bg-surface-sunken', className), children: [header, _jsx(EmptyState, { icon: icon, busy: busy, action: action, children: children })] }));
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { nameOrder } from '../format/locale.js';
1
2
  // Expanded folders can reveal more flattened rows; cap custom-key cycles.
2
3
  const EXPANSION_PASSES = 32;
3
4
  export function directoryHandle(model, path) {
@@ -126,8 +127,7 @@ export function compareRows(left, right) {
126
127
  if (leftIsDirectory !== rightIsDirectory) {
127
128
  return leftIsDirectory ? -1 : 1;
128
129
  }
129
- // Case is not a reason to split two names apart; the raw compare only breaks a tie.
130
- return leftSegment.localeCompare(rightSegment, undefined, { numeric: true, sensitivity: 'base' }) || leftSegment.localeCompare(rightSegment);
130
+ return nameOrder(leftSegment, rightSegment);
131
131
  }
132
132
  // One path is the head of the other, so the shorter one is the directory the longer one sits in.
133
133
  return left.segments.length - right.segments.length;
@@ -1,11 +1,12 @@
1
1
  export declare const formatDateTime: (at: Date | number, options: Intl.DateTimeFormatOptions) => string;
2
- export declare const formatClock: (at: Date | number) => string;
2
+ export declare const formatClock: (at: Date | number, timeZone?: string) => string;
3
3
  export declare const formatWeekdayClock: (at: Date | number) => string;
4
4
  export declare const formatHour: (at: Date | number) => string;
5
- export declare const formatDay: (at: Date | number) => string;
6
- export declare const formatDayWithYear: (at: Date | number) => string;
5
+ export declare const formatDay: (at: Date | number, timeZone?: string) => string;
6
+ export declare const formatDayWithYear: (at: Date | number, timeZone?: string) => string;
7
7
  export declare const formatWeekdayDay: (at: Date | number) => string;
8
- export declare const formatDayClock: (at: Date | number) => string;
8
+ export declare const formatDayClock: (at: Date | number, timeZone?: string) => string;
9
+ export declare const formatDayClockWithYear: (at: Date | number, timeZone?: string) => string;
9
10
  export declare const formatNumericDate: (at: Date | number) => string;
10
11
  export declare const isSameDay: (a: Date | number, b: Date | number) => boolean;
11
12
  export declare const formatMoment: (at: Date | number, now?: Date | number) => string;
@@ -26,21 +26,27 @@ const DAY = { month: 'short', day: 'numeric' };
26
26
  const DAY_WITH_YEAR = { month: 'short', day: 'numeric', year: 'numeric' };
27
27
  const WEEKDAY_DAY = { weekday: 'short', month: 'short', day: 'numeric' };
28
28
  const DAY_CLOCK = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
29
+ const DAY_CLOCK_WITH_YEAR = { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' };
29
30
  const NUMERIC_DATE = { year: 'numeric', month: 'numeric', day: 'numeric' };
30
- /* `08:05`, or `08:05 AM` in a region that counts to twelve. */
31
- export const formatClock = (at) => formatDateTime(at, CLOCK);
31
+ /*
32
+ * `08:05`, or `08:05 AM` in a region that counts to twelve. The clock functions below take the `timeZone`
33
+ * to write in, such as `UTC` for a moment whose parts are given and mean the same wall clock anywhere.
34
+ */
35
+ export const formatClock = (at, timeZone) => formatDateTime(at, timeZone === undefined ? CLOCK : { ...CLOCK, timeZone });
32
36
  /* A time far enough off that its day is part of the answer: `Sat 08:05`. */
33
37
  export const formatWeekdayClock = (at) => formatDateTime(at, WEEKDAY_CLOCK);
34
38
  /* An hour of the day on its own, for the foot of a chart: `08`, or `8 AM` where that is the clock. */
35
39
  export const formatHour = (at) => formatDateTime(at, HOUR);
36
40
  /* `19 Sep`, the short date a row falls back to once "days ago" stops meaning anything. */
37
- export const formatDay = (at) => formatDateTime(at, DAY);
41
+ export const formatDay = (at, timeZone) => formatDateTime(at, timeZone === undefined ? DAY : { ...DAY, timeZone });
38
42
  /* The same date with the year, for anything older than the one we are in. */
39
- export const formatDayWithYear = (at) => formatDateTime(at, DAY_WITH_YEAR);
43
+ export const formatDayWithYear = (at, timeZone) => formatDateTime(at, timeZone === undefined ? DAY_WITH_YEAR : { ...DAY_WITH_YEAR, timeZone });
40
44
  /* `Sat 19 Sep`. */
41
45
  export const formatWeekdayDay = (at) => formatDateTime(at, WEEKDAY_DAY);
42
46
  /* `19 Sep, 08:05`: a moment that is not today, to the minute. */
43
- export const formatDayClock = (at) => formatDateTime(at, DAY_CLOCK);
47
+ export const formatDayClock = (at, timeZone) => formatDateTime(at, timeZone === undefined ? DAY_CLOCK : { ...DAY_CLOCK, timeZone });
48
+ /* `19 Sep 2026, 08:05`: the same moment with the year, for one that is not in the year we are in. */
49
+ export const formatDayClockWithYear = (at, timeZone) => formatDateTime(at, timeZone === undefined ? DAY_CLOCK_WITH_YEAR : { ...DAY_CLOCK_WITH_YEAR, timeZone });
44
50
  /* All numbers, as a form would print it: `19-9-2026` here, `9/19/2026` in the United States. */
45
51
  export const formatNumericDate = (at) => formatDateTime(at, NUMERIC_DATE);
46
52
  /* Whether two instants fall on the same day of the same year, in the zone of whoever is reading. */
@@ -1,6 +1,6 @@
1
- export { formatAge, formatClock, formatDateTime, formatDay, formatDayClock, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.ts';
1
+ export { formatAge, formatClock, formatDateTime, formatDay, formatDayClock, formatDayClockWithYear, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.ts';
2
2
  export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.ts';
3
- export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
4
- export { formatBytes, formatDecimal, formatFixed, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.ts';
3
+ export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
4
+ export { formatBytes, formatDecimal, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.ts';
5
5
  export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.ts';
6
6
  export { localTimeZone } from './time-zone.ts';
@@ -1,7 +1,7 @@
1
1
  /* Every number, date, time and duration a person reads, written the way the language and the region they set ask for. */
2
- export { formatAge, formatClock, formatDateTime, formatDay, formatDayClock, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.js';
2
+ export { formatAge, formatClock, formatDateTime, formatDay, formatDayClock, formatDayClockWithYear, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.js';
3
3
  export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.js';
4
- export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale } from './locale.js';
5
- export { formatBytes, formatDecimal, formatFixed, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.js';
4
+ export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale } from './locale.js';
5
+ export { formatBytes, formatDecimal, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.js';
6
6
  export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.js';
7
7
  export { localTimeZone } from './time-zone.js';
@@ -15,3 +15,4 @@ export declare const dateFormatter: (options: Intl.DateTimeFormatOptions) => Int
15
15
  export declare const wordFormatter: (options: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat;
16
16
  export declare const relativeFormatter: (options: Intl.RelativeTimeFormatOptions) => Intl.RelativeTimeFormat;
17
17
  export declare const labelCollator: () => Intl.Collator;
18
+ export declare const nameOrder: (left: string, right: string) => number;
@@ -108,3 +108,12 @@ export const labelCollator = () => {
108
108
  collators.set(locale, collator);
109
109
  return collator;
110
110
  };
111
+ let nameCollators = null;
112
+ /*
113
+ * The order of names that are no words, such as files: the runtime's own locale, numbers by value and
114
+ * case only to break a tie. It is what `localeCompare` did, which built a collator on every call.
115
+ */
116
+ export const nameOrder = (left, right) => {
117
+ nameCollators ??= { loose: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), exact: new Intl.Collator() };
118
+ return nameCollators.loose.compare(left, right) || nameCollators.exact.compare(left, right);
119
+ };
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export type { PopoverPopupProps, PopupVariant, PreviewCardPopupProps } from './p
14
14
  export type { TabsCountProps, TabsListProps, TabsRootProps, TabsTabProps, TabsValue, TabsVariant } from './tabs/parts.tsx';
15
15
  export { AccentSwatches, type AccentSwatchesProps } from './AccentSwatches.tsx';
16
16
  export { Banner, type BannerProps, type BannerTone } from './Banner.tsx';
17
- export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button.tsx';
17
+ export { Button, type ButtonProps, type ButtonShape, type ButtonSize, type ButtonVariant } from './Button.tsx';
18
18
  export { ButtonGroup, type ButtonGroupProps } from './ButtonGroup.tsx';
19
19
  export { Checkbox, type CheckboxProps } from './Checkbox.tsx';
20
20
  export { ChoiceCards, type Choice, type ChoiceCardsProps } from './ChoiceCards.tsx';
@@ -56,7 +56,7 @@ export { Switch, type SwitchProps } from './Switch.tsx';
56
56
  export { TextMenu, type TextMenuProps } from './TextMenu.tsx';
57
57
  export { Tile, type TileProps } from './Tile.tsx';
58
58
  export { Toasts, type ToastsProps } from './Toasts.tsx';
59
- export { Tooltip, TooltipProvider, type TooltipProps, type TooltipSide } from './Tooltip.tsx';
59
+ export { Tooltip, type TooltipProps, type TooltipSide } from './Tooltip.tsx';
60
60
  export { UIProvider, type UIProviderProps } from './UIProvider.tsx';
61
61
  export { Waveform, type WaveformProps } from './Waveform.tsx';
62
62
  export { Wipe, type WipeProps } from './Wipe.tsx';
@@ -67,19 +67,20 @@ export type { ResetKeys } from './error-boundary.ts';
67
67
  export { FILE_TREE_ICONS } from './file-icon.ts';
68
68
  export { cameThroughPortal, isInFloatingLayer } from './floating.ts';
69
69
  export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.tsx';
70
- export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.ts';
71
- export { startInputModality } from './modality.ts';
70
+ export type { LazyComponent } from './lazy.tsx';
71
+ export { LazyBoundary } from './LazyBoundary.tsx';
72
+ export type { LazyBoundaryProps } from './LazyBoundary.tsx';
73
+ export { UI_NAMESPACE, UI_RESOURCES } from './locales.ts';
72
74
  export { isApplePlatform } from './platform.ts';
73
- export { Prefetcher, prefetcher, type Loader, type WhenIdle } from './prefetch.ts';
75
+ export { prefetcher, type Loader, type WhenIdle } from './prefetch.ts';
74
76
  export { selectAllWithin, selectionWithin } from './selection.ts';
75
77
  export { EDIT_SHORTCUTS, KEY_SHORTCUTS, formatShortcut, isModHeld, matchesShortcut, shortcut, shortcutParts, type KeyLike, type Shortcut } from './shortcut.ts';
76
- export { SUCCESS_MS, UNDO_MS, createToastStore, elapsedOf, type Toast, type ToastAction, type ToastDeadline, type ToastInput, type ToastKind, type ToastPatch, type ToastStore, type ToastStoreHook } from './toast-store.ts';
78
+ export { UNDO_MS, createToastStore, type Toast, type ToastAction, type ToastDeadline, type ToastInput, type ToastKind, type ToastPatch, type ToastStore, type ToastStoreHook } from './toast-store.ts';
77
79
  export { useAsyncAction, type AsyncAction } from './useAsyncAction.ts';
78
80
  export { clampColumnSize, useColumnResize, type ColumnEdge, type ColumnResize, type ColumnResizeOptions } from './useColumnResize.ts';
79
81
  export { useContentSize, type ContentSize } from './useContentSize.ts';
80
82
  export { useMeasuredWidth } from './useMeasuredWidth.ts';
81
83
  export { useNow, useTickingText } from './useNow.ts';
82
- export { ZOOM_PRESETS } from './zoom.ts';
83
84
  export { useFileTree, type FileTreeOptions } from './file-tree/use-file-tree.ts';
84
85
  export type { FileTreeRootProps } from './file-tree/parts.tsx';
85
86
  export type { FileTree as FileTreeModel, FileTreeVisibleRow, FileTreeDragAndDropConfig, FileTreeDropContext, FileTreeDropResult } from '@pierre/trees';
@@ -90,6 +91,6 @@ export type { WorkspaceLayout } from './workspace/context.ts';
90
91
  export { DocumentTab, TabStrip, type DocumentTabProps, type TabStripProps, type TabStripItem } from './workspace/TabStrip.tsx';
91
92
  export { SplitView, type SplitViewProps, type SplitViewInfo, type SplitViewBounds, type SplitDropTarget, type SplitDrag } from './workspace/SplitView.tsx';
92
93
  export { DropPreview, type DropPreviewProps } from './workspace/DropPreview.tsx';
93
- export { dropPreviewPath, rectDropPreview, sameDropPreview, tabDropPreview, type DropPreviewShape, type TabDrop } from './workspace/preview.ts';
94
- export { createSplitLayout, normalizeSplitLayout, updateSplitLayout, splitPanes, type SplitAxis, type SplitSide, type SplitPane, type SplitBranch, type SplitNode, type SplitLayout, type SplitCommand } from './workspace/model.ts';
94
+ export { rectDropPreview, sameDropPreview, tabDropPreview, type DropPreviewShape, type TabDrop } from './workspace/preview.ts';
95
+ export { createSplitLayout, updateSplitLayout, splitPanes, type SplitAxis, type SplitSide, type SplitPane, type SplitBranch, type SplitNode, type SplitLayout, type SplitCommand } from './workspace/model.ts';
95
96
  export { tabGapAt, type SplitRect, type SplitEdges, type SplitMinimum } from './workspace/geometry.ts';
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ export { Switch } from './Switch.js';
55
55
  export { TextMenu } from './TextMenu.js';
56
56
  export { Tile } from './Tile.js';
57
57
  export { Toasts } from './Toasts.js';
58
- export { Tooltip, TooltipProvider } from './Tooltip.js';
58
+ export { Tooltip } from './Tooltip.js';
59
59
  export { UIProvider } from './UIProvider.js';
60
60
  export { Waveform } from './Waveform.js';
61
61
  export { Wipe } from './Wipe.js';
@@ -65,24 +65,23 @@ export { messageOf } from './error-message.js';
65
65
  export { FILE_TREE_ICONS } from './file-icon.js';
66
66
  export { cameThroughPortal, isInFloatingLayer } from './floating.js';
67
67
  export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.js';
68
- export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.js';
69
- export { startInputModality } from './modality.js';
68
+ export { LazyBoundary } from './LazyBoundary.js';
69
+ export { UI_NAMESPACE, UI_RESOURCES } from './locales.js';
70
70
  export { isApplePlatform } from './platform.js';
71
- export { Prefetcher, prefetcher } from './prefetch.js';
71
+ export { prefetcher } from './prefetch.js';
72
72
  export { selectAllWithin, selectionWithin } from './selection.js';
73
73
  export { EDIT_SHORTCUTS, KEY_SHORTCUTS, formatShortcut, isModHeld, matchesShortcut, shortcut, shortcutParts } from './shortcut.js';
74
- export { SUCCESS_MS, UNDO_MS, createToastStore, elapsedOf } from './toast-store.js';
74
+ export { UNDO_MS, createToastStore } from './toast-store.js';
75
75
  export { useAsyncAction } from './useAsyncAction.js';
76
76
  export { clampColumnSize, useColumnResize } from './useColumnResize.js';
77
77
  export { useContentSize } from './useContentSize.js';
78
78
  export { useMeasuredWidth } from './useMeasuredWidth.js';
79
79
  export { useNow, useTickingText } from './useNow.js';
80
- export { ZOOM_PRESETS } from './zoom.js';
81
80
  export { useFileTree } from './file-tree/use-file-tree.js';
82
81
  export { Workspace } from './workspace/Workspace.js';
83
82
  export { DocumentTab, TabStrip } from './workspace/TabStrip.js';
84
83
  export { SplitView } from './workspace/SplitView.js';
85
84
  export { DropPreview } from './workspace/DropPreview.js';
86
- export { dropPreviewPath, rectDropPreview, sameDropPreview, tabDropPreview } from './workspace/preview.js';
87
- export { createSplitLayout, normalizeSplitLayout, updateSplitLayout, splitPanes } from './workspace/model.js';
85
+ export { rectDropPreview, sameDropPreview, tabDropPreview } from './workspace/preview.js';
86
+ export { createSplitLayout, updateSplitLayout, splitPanes } from './workspace/model.js';
88
87
  export { tabGapAt } from './workspace/geometry.js';
@@ -0,0 +1,3 @@
1
+ export declare function noteLanding(): void;
2
+ export declare function subscribeLandings(listener: () => void): () => void;
3
+ export declare function readLandings(): number;
@@ -0,0 +1,18 @@
1
+ let landings = 0;
2
+ const listeners = new Set();
3
+ /* Called once per module, when a `LoadedComponent` first has it. */
4
+ export function noteLanding() {
5
+ landings += 1;
6
+ for (const listener of listeners) {
7
+ listener();
8
+ }
9
+ }
10
+ export function subscribeLandings(listener) {
11
+ listeners.add(listener);
12
+ return () => {
13
+ listeners.delete(listener);
14
+ };
15
+ }
16
+ export function readLandings() {
17
+ return landings;
18
+ }
package/dist/lazy.d.ts CHANGED
@@ -8,5 +8,8 @@ export declare class LoadedComponent<Component> {
8
8
  subscribe(listener: () => void): () => void;
9
9
  }
10
10
  export declare function onLazyOpenError(listener: (error: unknown) => void): () => void;
11
- export declare function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(load: () => Promise<Module>, name: Name): ComponentType<ComponentProps<Module[Name]>>;
11
+ export type LazyComponent<Props> = ComponentType<Props> & {
12
+ readonly preload: () => Promise<void>;
13
+ };
14
+ export declare function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(load: () => Promise<Module>, name: Name): LazyComponent<ComponentProps<Module[Name]>>;
12
15
  export declare function lazyDialog<Module extends Record<Name, ComponentType>, Name extends keyof Module, State>(load: () => Promise<Module>, name: Name, useStore: (select: (state: State) => boolean) => boolean, isOpen: (state: State) => boolean): ComponentType;
package/dist/lazy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Suspense, lazy, useState, useSyncExternalStore } from 'react';
3
+ import { noteLanding } from './lazy-landings.js';
3
4
  import { prefetcher } from './prefetch.js';
4
5
  /*
5
6
  * A module's component, loaded once and kept. `React.lazy` suspends for a tick even on a module the
@@ -20,6 +21,7 @@ export class LoadedComponent {
20
21
  for (const listener of this.listeners) {
21
22
  listener();
22
23
  }
24
+ noteLanding();
23
25
  }
24
26
  return component;
25
27
  }
@@ -68,7 +70,11 @@ export function lazyNamed(load, name) {
68
70
  const Component = loaded.current ?? Lazy;
69
71
  return _jsx(Component, { ...props });
70
72
  };
71
- return LazyNamed;
73
+ return Object.assign(LazyNamed, {
74
+ preload: async () => {
75
+ await loaded.load();
76
+ }
77
+ });
72
78
  }
73
79
  /* Stays true after the first opening, so every close after it still has something mounted to animate. */
74
80
  function useOpenedOnce(open) {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { Tabs } from '@base-ui/react/tabs';
@@ -9,6 +9,7 @@ import { DialogDescription, DialogPopup, DialogRoot, DialogTitle } from '../dial
9
9
  import { ErrorBoundary } from '../ErrorBoundary.js';
10
10
  import { Icon } from '../Icon.js';
11
11
  import { IconButton } from '../IconButton.js';
12
+ import { LazyBoundary } from '../LazyBoundary.js';
12
13
  import { Input } from '../Input.js';
13
14
  import { Select } from '../Select.js';
14
15
  import { SettingsTargetContext } from './target.js';
@@ -91,6 +92,6 @@ export function SettingsDialog({ open, onOpenChange, section, onNavigate, groups
91
92
  const top = event.currentTarget.scrollTop;
92
93
  event.currentTarget.toggleAttribute('data-fade-start', top > 0);
93
94
  barFill.current?.style.setProperty('opacity', String(Math.min(top / BAR_FILL_DISTANCE, 1)));
94
- }, children: _jsx(ErrorBoundary, { label: t('settings.failed'), className: "min-h-0 grow", children: _jsxs(Suspense, { fallback: null, children: [Hero && (_jsx("div", { className: "-mx-8 shrink-0 max-[960px]:-mx-4", children: _jsx(Hero, {}) })), _jsx(Pane, {})] }) }) }, entry.id));
95
+ }, children: _jsx(ErrorBoundary, { label: t('settings.failed'), className: "min-h-0 grow", children: _jsxs(LazyBoundary, { children: [Hero && (_jsx("div", { className: "-mx-8 shrink-0 max-[960px]:-mx-4", children: _jsx(Hero, {}) })), _jsx(Pane, {})] }) }) }, entry.id));
95
96
  }) })] })] }), _jsx(DialogDescription, { className: "sr-only", children: meta.description })] }) }));
96
97
  }
@@ -77,7 +77,5 @@ export type SplitCommand = {
77
77
  export declare function createSplitLayout(views?: string[], paneId?: string): SplitLayout;
78
78
  export declare function splitPanes(node: SplitNode): SplitPane[];
79
79
  export declare function findSplitNode(node: SplitNode, id: string): SplitNode | undefined;
80
- /** Repairs persisted data at the application boundary; view payloads never enter the layout. */
81
- export declare function normalizeSplitLayout(value: unknown): SplitLayout;
82
80
  /** Invalid or stale commands are no-ops. IDs for new nodes come from the caller. */
83
81
  export declare function updateSplitLayout(layout: SplitLayout, command: SplitCommand): SplitLayout;
@@ -40,75 +40,6 @@ function flattenSplits(node) {
40
40
  }
41
41
  return { ...node, children, sizes: shares(sizes, children.length) };
42
42
  }
43
- /** Repairs persisted data at the application boundary; view payloads never enter the layout. */
44
- export function normalizeSplitLayout(value) {
45
- const ids = new Set();
46
- const views = new Set();
47
- const seen = new Set();
48
- let serial = 0;
49
- const nextId = (candidate) => {
50
- let id = typeof candidate === 'string' && candidate.length > 0 ? candidate : `pane-${serial++}`;
51
- while (ids.has(id)) {
52
- id = `pane-${serial++}`;
53
- }
54
- ids.add(id);
55
- return id;
56
- };
57
- const read = (input, depth) => {
58
- if (input === null || typeof input !== 'object' || seen.has(input) || depth > 64) {
59
- return null;
60
- }
61
- seen.add(input);
62
- const row = input;
63
- if (row.type === 'pane') {
64
- const items = Array.isArray(row.views)
65
- ? row.views.filter((view) => {
66
- if (typeof view !== 'string' || view.length === 0 || views.has(view)) {
67
- return false;
68
- }
69
- views.add(view);
70
- return true;
71
- })
72
- : [];
73
- return {
74
- type: 'pane',
75
- id: nextId(row.id),
76
- views: items,
77
- active: typeof row.active === 'string' && items.includes(row.active) ? row.active : (items[0] ?? null)
78
- };
79
- }
80
- if (row.type !== 'split' || !Array.isArray(row.children)) {
81
- return null;
82
- }
83
- const children = [];
84
- const sizes = [];
85
- row.children.forEach((child, i) => {
86
- const result = read(child, depth + 1);
87
- if (result) {
88
- children.push(result);
89
- sizes.push(Array.isArray(row.sizes) ? Number(row.sizes[i]) : 1);
90
- }
91
- });
92
- if (children.length < 2) {
93
- return children[0] ?? null;
94
- }
95
- return {
96
- type: 'split',
97
- id: nextId(row.id),
98
- axis: row.axis === 'vertical' ? 'vertical' : 'horizontal',
99
- children,
100
- sizes: shares(sizes, children.length)
101
- };
102
- };
103
- const input = value !== null && typeof value === 'object' ? value : {};
104
- const root = flattenSplits(read(input.root, 0) ?? createSplitLayout().root);
105
- const panes = splitPanes(root);
106
- return {
107
- root,
108
- focused: panes.find((pane) => pane.id === input.focused)?.id ?? panes[0].id,
109
- maximized: panes.find((pane) => pane.id === input.maximized)?.id ?? null
110
- };
111
- }
112
43
  function replace(node, id, transform) {
113
44
  if (node.id === id) {
114
45
  return transform(node);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
package/src/Button.tsx CHANGED
@@ -3,6 +3,7 @@ import clsx from 'clsx';
3
3
 
4
4
  export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-outline' | 'positive' | 'inverse';
5
5
  export type ButtonSize = 'xs' | 'sm' | 'md';
6
+ export type ButtonShape = 'default' | 'pill';
6
7
 
7
8
  const VARIANT: Record<ButtonVariant, string> = {
8
9
  // A filled button darkens under the pointer. Its color is the meaning, so no token of its own for hover.
@@ -24,19 +25,28 @@ const SIZE: Record<ButtonSize, string> = {
24
25
  md: 'h-8 px-3'
25
26
  };
26
27
 
28
+ /* A pill is one size of its own, for a control that sits in a round row, so it takes the place of `size`. */
29
+ const SHAPE: Record<ButtonShape, string> = {
30
+ default: 'rounded-md text-xs',
31
+ pill: 'h-9 rounded-full px-3.5 text-sm'
32
+ };
33
+
27
34
  export interface ButtonProps extends ComponentProps<'button'> {
28
35
  variant?: ButtonVariant;
29
36
  size?: ButtonSize;
37
+ /* `pill` is 36 pixels tall and fully rounded, and ignores `size`. */
38
+ shape?: ButtonShape;
30
39
  /* Makes it a link that looks like a button, a real anchor, so it opens the way links open. */
31
40
  href?: string;
32
41
  }
33
42
 
34
43
  /* Every button with a word in it. A button that is only an icon is an `IconButton`, which is a square, not a label. */
35
- export function Button({ variant = 'ghost', size = 'md', className, href, type = 'button', ref, ...rest }: ButtonProps) {
44
+ export function Button({ variant = 'ghost', size = 'md', shape = 'default', className, href, type = 'button', ref, ...rest }: ButtonProps) {
36
45
  const classes = clsx(
37
- 'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-xs font-medium disabled:opacity-50',
46
+ 'inline-flex shrink-0 items-center justify-center gap-1.5 font-medium disabled:opacity-50',
47
+ SHAPE[shape],
38
48
  VARIANT[variant],
39
- SIZE[size],
49
+ shape === 'default' && SIZE[size],
40
50
  className
41
51
  );
42
52
  if (href) {
@@ -12,8 +12,6 @@ export interface EmptyStateProps {
12
12
  icon?: LucideIcon | ReactElement<{ size?: number }>;
13
13
  /* A `Spinner` in place of the icon, for a state that is still loading. */
14
14
  busy?: boolean;
15
- /** @deprecated Turns a Lucide icon; use `busy`, which draws a `Spinner` in its place. */
16
- spin?: boolean;
17
15
  /* A heading above the sentence, for a state that is an outcome rather than a list with nothing in it. */
18
16
  title?: ReactNode;
19
17
  /* One sentence. What is missing, and what puts something there. */
@@ -25,7 +23,7 @@ export interface EmptyStateProps {
25
23
  }
26
24
 
27
25
  /* What a list, a canvas or a thread shows before it holds anything. */
28
- export function EmptyState({ icon, busy = false, spin = false, title, children, action, className, ref }: EmptyStateProps) {
26
+ export function EmptyState({ icon, busy = false, title, children, action, className, ref }: EmptyStateProps) {
29
27
  return (
30
28
  <div ref={ref} className={clsx('flex flex-col items-center justify-center gap-2 px-6 py-8 text-center', className)}>
31
29
  {(busy || icon) && (
@@ -35,7 +33,7 @@ export function EmptyState({ icon, busy = false, spin = false, title, children,
35
33
  ) : isValidElement(icon) ? (
36
34
  cloneElement(icon, { size: ICON_SIZE })
37
35
  ) : (
38
- icon && <Icon icon={icon} size={ICON_SIZE} className={spin ? 'animate-spin' : undefined} />
36
+ icon && <Icon icon={icon} size={ICON_SIZE} />
39
37
  )}
40
38
  </span>
41
39
  )}
@@ -24,8 +24,6 @@ export interface IconButtonProps extends Omit<ComponentProps<'button'>, 'childre
24
24
  active?: boolean;
25
25
  /* A `Spinner` in place of the icon, for a step that is running. */
26
26
  busy?: boolean;
27
- /** @deprecated Turns the icon; use `busy`, which draws a `Spinner` in its place. */
28
- spin?: boolean;
29
27
  /* Classes for the icon rather than the button: a tone of its own, a turn, a fill. The size stays the button's. */
30
28
  iconClassName?: string;
31
29
  /* Drawn after the icon: a count, or a word beside it together with `w-auto`. */
@@ -44,7 +42,6 @@ export function IconButton({
44
42
  size = 'md',
45
43
  active = false,
46
44
  busy = false,
47
- spin = false,
48
45
  iconClassName,
49
46
  render,
50
47
  className,
@@ -65,7 +62,7 @@ export function IconButton({
65
62
  {busy ? (
66
63
  <Spinner size={ICON_BUTTON_ICON_SIZE[size]} className={iconClassName} />
67
64
  ) : (
68
- icon !== undefined && <Icon icon={icon} size={ICON_BUTTON_ICON_SIZE[size]} className={clsx(spin && 'animate-spin', iconClassName)} />
65
+ icon !== undefined && <Icon icon={icon} size={ICON_BUTTON_ICON_SIZE[size]} className={iconClassName} />
69
66
  )}
70
67
  {children}
71
68
  </>
@@ -0,0 +1,17 @@
1
+ import { Suspense, useSyncExternalStore, type ReactNode } from 'react';
2
+ import { readLandings, subscribeLandings } from './lazy-landings.ts';
3
+
4
+ export interface LazyBoundaryProps {
5
+ readonly fallback?: ReactNode;
6
+ readonly children?: ReactNode;
7
+ }
8
+
9
+ /*
10
+ * `Suspense` for what `lazyNamed` loads. React holds content that comes in as a retry back until
11
+ * 300 ms after its fallback showed; this boundary draws again as an ordinary update once a module
12
+ * lands, so a chunk that takes 20 ms shows after 20 ms.
13
+ */
14
+ export function LazyBoundary({ fallback = null, children }: LazyBoundaryProps) {
15
+ useSyncExternalStore(subscribeLandings, readLandings, readLandings);
16
+ return <Suspense fallback={fallback}>{children}</Suspense>;
17
+ }
@@ -9,8 +9,6 @@ export interface PanelEmptyProps {
9
9
  icon?: LucideIcon;
10
10
  /* A `Spinner` in place of the icon, for a panel that is still loading. */
11
11
  busy?: boolean;
12
- /** @deprecated Turns the icon; use `busy`, which draws a `Spinner` in its place. */
13
- spin?: boolean;
14
12
  action?: ReactNode;
15
13
  /* The sunken ground, for a box that has none behind it yet (a card on a canvas) rather than a panel that does. */
16
14
  sunken?: boolean;
@@ -25,11 +23,11 @@ export interface PanelEmptyProps {
25
23
  }
26
24
 
27
25
  /* What a panel or a card shows while it holds nothing: a sentence, one icon, at most one button. */
28
- export function PanelEmpty({ header, icon, busy = false, spin = false, action, sunken = false, fill = 'grow', children, className, ref }: PanelEmptyProps) {
26
+ export function PanelEmpty({ header, icon, busy = false, action, sunken = false, fill = 'grow', children, className, ref }: PanelEmptyProps) {
29
27
  return (
30
28
  <div ref={ref} className={clsx('grid min-h-0 place-items-center', fill === 'full' ? 'h-full' : 'grow', sunken && 'bg-surface-sunken', className)}>
31
29
  {header}
32
- <EmptyState icon={icon} busy={busy} spin={spin} action={action}>
30
+ <EmptyState icon={icon} busy={busy} action={action}>
33
31
  {children}
34
32
  </EmptyState>
35
33
  </div>
@@ -1,4 +1,5 @@
1
1
  import type { FileTree, FileTreeDirectoryHandle, FileTreeVisibleRow } from '@pierre/trees';
2
+ import { nameOrder } from '../format/locale.ts';
2
3
 
3
4
  // Expanded folders can reveal more flattened rows; cap custom-key cycles.
4
5
  const EXPANSION_PASSES = 32;
@@ -152,8 +153,7 @@ export function compareRows(left: SortRow, right: SortRow): number {
152
153
  if (leftIsDirectory !== rightIsDirectory) {
153
154
  return leftIsDirectory ? -1 : 1;
154
155
  }
155
- // Case is not a reason to split two names apart; the raw compare only breaks a tie.
156
- return leftSegment.localeCompare(rightSegment, undefined, { numeric: true, sensitivity: 'base' }) || leftSegment.localeCompare(rightSegment);
156
+ return nameOrder(leftSegment, rightSegment);
157
157
  }
158
158
  // One path is the head of the other, so the shorter one is the directory the longer one sits in.
159
159
  return left.segments.length - right.segments.length;
@@ -30,10 +30,14 @@ const DAY: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
30
30
  const DAY_WITH_YEAR: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
31
31
  const WEEKDAY_DAY: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' };
32
32
  const DAY_CLOCK: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
33
+ const DAY_CLOCK_WITH_YEAR: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' };
33
34
  const NUMERIC_DATE: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'numeric', day: 'numeric' };
34
35
 
35
- /* `08:05`, or `08:05 AM` in a region that counts to twelve. */
36
- export const formatClock = (at: Date | number): string => formatDateTime(at, CLOCK);
36
+ /*
37
+ * `08:05`, or `08:05 AM` in a region that counts to twelve. The clock functions below take the `timeZone`
38
+ * to write in, such as `UTC` for a moment whose parts are given and mean the same wall clock anywhere.
39
+ */
40
+ export const formatClock = (at: Date | number, timeZone?: string): string => formatDateTime(at, timeZone === undefined ? CLOCK : { ...CLOCK, timeZone });
37
41
 
38
42
  /* A time far enough off that its day is part of the answer: `Sat 08:05`. */
39
43
  export const formatWeekdayClock = (at: Date | number): string => formatDateTime(at, WEEKDAY_CLOCK);
@@ -42,16 +46,22 @@ export const formatWeekdayClock = (at: Date | number): string => formatDateTime(
42
46
  export const formatHour = (at: Date | number): string => formatDateTime(at, HOUR);
43
47
 
44
48
  /* `19 Sep`, the short date a row falls back to once "days ago" stops meaning anything. */
45
- export const formatDay = (at: Date | number): string => formatDateTime(at, DAY);
49
+ export const formatDay = (at: Date | number, timeZone?: string): string => formatDateTime(at, timeZone === undefined ? DAY : { ...DAY, timeZone });
46
50
 
47
51
  /* The same date with the year, for anything older than the one we are in. */
48
- export const formatDayWithYear = (at: Date | number): string => formatDateTime(at, DAY_WITH_YEAR);
52
+ export const formatDayWithYear = (at: Date | number, timeZone?: string): string =>
53
+ formatDateTime(at, timeZone === undefined ? DAY_WITH_YEAR : { ...DAY_WITH_YEAR, timeZone });
49
54
 
50
55
  /* `Sat 19 Sep`. */
51
56
  export const formatWeekdayDay = (at: Date | number): string => formatDateTime(at, WEEKDAY_DAY);
52
57
 
53
58
  /* `19 Sep, 08:05`: a moment that is not today, to the minute. */
54
- export const formatDayClock = (at: Date | number): string => formatDateTime(at, DAY_CLOCK);
59
+ export const formatDayClock = (at: Date | number, timeZone?: string): string =>
60
+ formatDateTime(at, timeZone === undefined ? DAY_CLOCK : { ...DAY_CLOCK, timeZone });
61
+
62
+ /* `19 Sep 2026, 08:05`: the same moment with the year, for one that is not in the year we are in. */
63
+ export const formatDayClockWithYear = (at: Date | number, timeZone?: string): string =>
64
+ formatDateTime(at, timeZone === undefined ? DAY_CLOCK_WITH_YEAR : { ...DAY_CLOCK_WITH_YEAR, timeZone });
55
65
 
56
66
  /* All numbers, as a form would print it: `19-9-2026` here, `9/19/2026` in the United States. */
57
67
  export const formatNumericDate = (at: Date | number): string => formatDateTime(at, NUMERIC_DATE);
@@ -6,6 +6,7 @@ export {
6
6
  formatDateTime,
7
7
  formatDay,
8
8
  formatDayClock,
9
+ formatDayClockWithYear,
9
10
  formatDayWithYear,
10
11
  formatHour,
11
12
  formatMoment,
@@ -15,11 +16,10 @@ export {
15
16
  isSameDay
16
17
  } from './datetime.ts';
17
18
  export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.ts';
18
- export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
19
+ export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
19
20
  export {
20
21
  formatBytes,
21
22
  formatDecimal,
22
- formatFixed,
23
23
  formatMoney,
24
24
  formatNumber,
25
25
  formatNumeral,
@@ -151,3 +151,14 @@ export const labelCollator = (): Intl.Collator => {
151
151
  collators.set(locale, collator);
152
152
  return collator;
153
153
  };
154
+
155
+ let nameCollators: { loose: Intl.Collator; exact: Intl.Collator } | null = null;
156
+
157
+ /*
158
+ * The order of names that are no words, such as files: the runtime's own locale, numbers by value and
159
+ * case only to break a tie. It is what `localeCompare` did, which built a collator on every call.
160
+ */
161
+ export const nameOrder = (left: string, right: string): number => {
162
+ nameCollators ??= { loose: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), exact: new Intl.Collator() };
163
+ return nameCollators.loose.compare(left, right) || nameCollators.exact.compare(left, right);
164
+ };
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@ export type { TabsCountProps, TabsListProps, TabsRootProps, TabsTabProps, TabsVa
29
29
 
30
30
  export { AccentSwatches, type AccentSwatchesProps } from './AccentSwatches.tsx';
31
31
  export { Banner, type BannerProps, type BannerTone } from './Banner.tsx';
32
- export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button.tsx';
32
+ export { Button, type ButtonProps, type ButtonShape, type ButtonSize, type ButtonVariant } from './Button.tsx';
33
33
  export { ButtonGroup, type ButtonGroupProps } from './ButtonGroup.tsx';
34
34
  export { Checkbox, type CheckboxProps } from './Checkbox.tsx';
35
35
  export { ChoiceCards, type Choice, type ChoiceCardsProps } from './ChoiceCards.tsx';
@@ -71,7 +71,7 @@ export { Switch, type SwitchProps } from './Switch.tsx';
71
71
  export { TextMenu, type TextMenuProps } from './TextMenu.tsx';
72
72
  export { Tile, type TileProps } from './Tile.tsx';
73
73
  export { Toasts, type ToastsProps } from './Toasts.tsx';
74
- export { Tooltip, TooltipProvider, type TooltipProps, type TooltipSide } from './Tooltip.tsx';
74
+ export { Tooltip, type TooltipProps, type TooltipSide } from './Tooltip.tsx';
75
75
  export { UIProvider, type UIProviderProps } from './UIProvider.tsx';
76
76
  export { Waveform, type WaveformProps } from './Waveform.tsx';
77
77
  export { Wipe, type WipeProps } from './Wipe.tsx';
@@ -83,17 +83,17 @@ export type { ResetKeys } from './error-boundary.ts';
83
83
  export { FILE_TREE_ICONS } from './file-icon.ts';
84
84
  export { cameThroughPortal, isInFloatingLayer } from './floating.ts';
85
85
  export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.tsx';
86
- export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.ts';
87
- export { startInputModality } from './modality.ts';
86
+ export type { LazyComponent } from './lazy.tsx';
87
+ export { LazyBoundary } from './LazyBoundary.tsx';
88
+ export type { LazyBoundaryProps } from './LazyBoundary.tsx';
89
+ export { UI_NAMESPACE, UI_RESOURCES } from './locales.ts';
88
90
  export { isApplePlatform } from './platform.ts';
89
- export { Prefetcher, prefetcher, type Loader, type WhenIdle } from './prefetch.ts';
91
+ export { prefetcher, type Loader, type WhenIdle } from './prefetch.ts';
90
92
  export { selectAllWithin, selectionWithin } from './selection.ts';
91
93
  export { EDIT_SHORTCUTS, KEY_SHORTCUTS, formatShortcut, isModHeld, matchesShortcut, shortcut, shortcutParts, type KeyLike, type Shortcut } from './shortcut.ts';
92
94
  export {
93
- SUCCESS_MS,
94
95
  UNDO_MS,
95
96
  createToastStore,
96
- elapsedOf,
97
97
  type Toast,
98
98
  type ToastAction,
99
99
  type ToastDeadline,
@@ -108,7 +108,6 @@ export { clampColumnSize, useColumnResize, type ColumnEdge, type ColumnResize, t
108
108
  export { useContentSize, type ContentSize } from './useContentSize.ts';
109
109
  export { useMeasuredWidth } from './useMeasuredWidth.ts';
110
110
  export { useNow, useTickingText } from './useNow.ts';
111
- export { ZOOM_PRESETS } from './zoom.ts';
112
111
 
113
112
  export { useFileTree, type FileTreeOptions } from './file-tree/use-file-tree.ts';
114
113
  export type { FileTreeRootProps } from './file-tree/parts.tsx';
@@ -130,10 +129,9 @@ export type { WorkspaceLayout } from './workspace/context.ts';
130
129
  export { DocumentTab, TabStrip, type DocumentTabProps, type TabStripProps, type TabStripItem } from './workspace/TabStrip.tsx';
131
130
  export { SplitView, type SplitViewProps, type SplitViewInfo, type SplitViewBounds, type SplitDropTarget, type SplitDrag } from './workspace/SplitView.tsx';
132
131
  export { DropPreview, type DropPreviewProps } from './workspace/DropPreview.tsx';
133
- export { dropPreviewPath, rectDropPreview, sameDropPreview, tabDropPreview, type DropPreviewShape, type TabDrop } from './workspace/preview.ts';
132
+ export { rectDropPreview, sameDropPreview, tabDropPreview, type DropPreviewShape, type TabDrop } from './workspace/preview.ts';
134
133
  export {
135
134
  createSplitLayout,
136
- normalizeSplitLayout,
137
135
  updateSplitLayout,
138
136
  splitPanes,
139
137
  type SplitAxis,
@@ -0,0 +1,21 @@
1
+ let landings = 0;
2
+ const listeners = new Set<() => void>();
3
+
4
+ /* Called once per module, when a `LoadedComponent` first has it. */
5
+ export function noteLanding(): void {
6
+ landings += 1;
7
+ for (const listener of listeners) {
8
+ listener();
9
+ }
10
+ }
11
+
12
+ export function subscribeLandings(listener: () => void): () => void {
13
+ listeners.add(listener);
14
+ return () => {
15
+ listeners.delete(listener);
16
+ };
17
+ }
18
+
19
+ export function readLandings(): number {
20
+ return landings;
21
+ }
package/src/lazy.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Suspense, lazy, useState, useSyncExternalStore, type ComponentProps, type ComponentType } from 'react';
2
+ import { noteLanding } from './lazy-landings.ts';
2
3
  import { prefetcher } from './prefetch.ts';
3
4
 
4
5
  /*
@@ -22,6 +23,7 @@ export class LoadedComponent<Component> {
22
23
  for (const listener of this.listeners) {
23
24
  listener();
24
25
  }
26
+ noteLanding();
25
27
  }
26
28
  return component;
27
29
  }
@@ -60,6 +62,9 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
60
62
  }
61
63
  }
62
64
 
65
+ /* A lazy component, with `preload` to have its module before the first render, so that render never suspends. */
66
+ export type LazyComponent<Props> = ComponentType<Props> & { readonly preload: () => Promise<void> };
67
+
63
68
  /*
64
69
  * `React.lazy` for a module that exports its component by name, `default` included. Every module
65
70
  * loaded through here, or through `lazyDialog`, registers with `prefetcher`, which loads it ahead of
@@ -68,7 +73,7 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
68
73
  export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(
69
74
  load: () => Promise<Module>,
70
75
  name: Name
71
- ): ComponentType<ComponentProps<Module[Name]>> {
76
+ ): LazyComponent<ComponentProps<Module[Name]>> {
72
77
  const loaded = new LoadedComponent(async () => (await load())[name]);
73
78
  prefetcher.register(() => loaded.load());
74
79
  const Lazy = lazy(() => open(loaded));
@@ -77,7 +82,11 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
77
82
  const Component: ComponentType<any> = loaded.current ?? Lazy;
78
83
  return <Component {...props} />;
79
84
  };
80
- return LazyNamed;
85
+ return Object.assign(LazyNamed, {
86
+ preload: async () => {
87
+ await loaded.load();
88
+ }
89
+ });
81
90
  }
82
91
 
83
92
  /* Stays true after the first opening, so every close after it still has something mounted to animate. */
@@ -1,4 +1,4 @@
1
- import { Suspense, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Tabs } from '@base-ui/react/tabs';
@@ -8,6 +8,7 @@ import { DialogDescription, DialogPopup, DialogRoot, DialogTitle } from '../dial
8
8
  import { ErrorBoundary } from '../ErrorBoundary.tsx';
9
9
  import { Icon } from '../Icon.tsx';
10
10
  import { IconButton } from '../IconButton.tsx';
11
+ import { LazyBoundary } from '../LazyBoundary.tsx';
11
12
  import { Input } from '../Input.tsx';
12
13
  import { Select } from '../Select.tsx';
13
14
  import { SettingsTargetContext } from './target.ts';
@@ -18,7 +19,7 @@ export interface SettingsSectionEntry {
18
19
  label: string;
19
20
  /* One line under the pane title that says what the pane is about. */
20
21
  description: string;
21
- /* Rendered in a Suspense, so a lazy component loads when its section is opened. */
22
+ /* Rendered in a `LazyBoundary`, so a lazy component loads when its section is opened. */
22
23
  pane: ComponentType;
23
24
  /* A list beside a detail that scrolls on its own (`MasterDetail`), instead of one padded column. */
24
25
  split?: boolean;
@@ -320,14 +321,14 @@ export function SettingsDialog({
320
321
  }}
321
322
  >
322
323
  <ErrorBoundary label={t('settings.failed')} className="min-h-0 grow">
323
- <Suspense fallback={null}>
324
+ <LazyBoundary>
324
325
  {Hero && (
325
326
  <div className="-mx-8 shrink-0 max-[960px]:-mx-4">
326
327
  <Hero />
327
328
  </div>
328
329
  )}
329
330
  <Pane />
330
- </Suspense>
331
+ </LazyBoundary>
331
332
  </ErrorBoundary>
332
333
  </Tabs.Panel>
333
334
  );
@@ -84,76 +84,6 @@ function flattenSplits(node: SplitNode): SplitNode {
84
84
  return { ...node, children, sizes: shares(sizes, children.length) };
85
85
  }
86
86
 
87
- /** Repairs persisted data at the application boundary; view payloads never enter the layout. */
88
- export function normalizeSplitLayout(value: unknown): SplitLayout {
89
- const ids = new Set<string>();
90
- const views = new Set<string>();
91
- const seen = new Set<object>();
92
- let serial = 0;
93
- const nextId = (candidate: unknown): string => {
94
- let id = typeof candidate === 'string' && candidate.length > 0 ? candidate : `pane-${serial++}`;
95
- while (ids.has(id)) {
96
- id = `pane-${serial++}`;
97
- }
98
- ids.add(id);
99
- return id;
100
- };
101
- const read = (input: unknown, depth: number): SplitNode | null => {
102
- if (input === null || typeof input !== 'object' || seen.has(input) || depth > 64) {
103
- return null;
104
- }
105
- seen.add(input);
106
- const row = input as Record<string, unknown>;
107
- if (row.type === 'pane') {
108
- const items = Array.isArray(row.views)
109
- ? row.views.filter((view): view is string => {
110
- if (typeof view !== 'string' || view.length === 0 || views.has(view)) {
111
- return false;
112
- }
113
- views.add(view);
114
- return true;
115
- })
116
- : [];
117
- return {
118
- type: 'pane',
119
- id: nextId(row.id),
120
- views: items,
121
- active: typeof row.active === 'string' && items.includes(row.active) ? row.active : (items[0] ?? null)
122
- };
123
- }
124
- if (row.type !== 'split' || !Array.isArray(row.children)) {
125
- return null;
126
- }
127
- const children: SplitNode[] = [];
128
- const sizes: number[] = [];
129
- row.children.forEach((child, i) => {
130
- const result = read(child, depth + 1);
131
- if (result) {
132
- children.push(result);
133
- sizes.push(Array.isArray(row.sizes) ? Number(row.sizes[i]) : 1);
134
- }
135
- });
136
- if (children.length < 2) {
137
- return children[0] ?? null;
138
- }
139
- return {
140
- type: 'split',
141
- id: nextId(row.id),
142
- axis: row.axis === 'vertical' ? 'vertical' : 'horizontal',
143
- children,
144
- sizes: shares(sizes, children.length)
145
- };
146
- };
147
- const input = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
148
- const root = flattenSplits(read(input.root, 0) ?? createSplitLayout().root);
149
- const panes = splitPanes(root);
150
- return {
151
- root,
152
- focused: panes.find((pane) => pane.id === input.focused)?.id ?? panes[0]!.id,
153
- maximized: panes.find((pane) => pane.id === input.maximized)?.id ?? null
154
- };
155
- }
156
-
157
87
  function replace(node: SplitNode, id: string, transform: (node: SplitNode) => SplitNode | null): SplitNode | null {
158
88
  if (node.id === id) {
159
89
  return transform(node);