@adecore/ui 0.24.1 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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;
@@ -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
@@ -67,6 +67,9 @@ export type { ResetKeys } from './error-boundary.ts';
67
67
  export { FILE_TREE_ICONS } from './file-icon.ts';
68
68
  export { cameThroughPortal, isInFloatingLayer } from './floating.ts';
69
69
  export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.tsx';
70
+ export type { LazyComponent } from './lazy.tsx';
71
+ export { LazyBoundary } from './LazyBoundary.tsx';
72
+ export type { LazyBoundaryProps } from './LazyBoundary.tsx';
70
73
  export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.ts';
71
74
  export { startInputModality } from './modality.ts';
72
75
  export { isApplePlatform } from './platform.ts';
package/dist/index.js CHANGED
@@ -65,6 +65,7 @@ export { messageOf } from './error-message.js';
65
65
  export { FILE_TREE_ICONS } from './file-icon.js';
66
66
  export { cameThroughPortal, isInFloatingLayer } from './floating.js';
67
67
  export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.js';
68
+ export { LazyBoundary } from './LazyBoundary.js';
68
69
  export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.js';
69
70
  export { startInputModality } from './modality.js';
70
71
  export { isApplePlatform } from './platform.js';
@@ -0,0 +1,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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
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",
@@ -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
+ }
@@ -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;
@@ -151,3 +151,14 @@ export const labelCollator = (): Intl.Collator => {
151
151
  collators.set(locale, collator);
152
152
  return collator;
153
153
  };
154
+
155
+ let nameCollators: { loose: Intl.Collator; exact: Intl.Collator } | null = null;
156
+
157
+ /*
158
+ * The order of names that are no words, such as files: the runtime's own locale, numbers by value and
159
+ * case only to break a tie. It is what `localeCompare` did, which built a collator on every call.
160
+ */
161
+ export const nameOrder = (left: string, right: string): number => {
162
+ nameCollators ??= { loose: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), exact: new Intl.Collator() };
163
+ return nameCollators.loose.compare(left, right) || nameCollators.exact.compare(left, right);
164
+ };
package/src/index.ts CHANGED
@@ -83,6 +83,9 @@ export type { ResetKeys } from './error-boundary.ts';
83
83
  export { FILE_TREE_ICONS } from './file-icon.ts';
84
84
  export { cameThroughPortal, isInFloatingLayer } from './floating.ts';
85
85
  export { LoadedComponent, lazyDialog, lazyNamed, onLazyOpenError } from './lazy.tsx';
86
+ export type { LazyComponent } from './lazy.tsx';
87
+ export { LazyBoundary } from './LazyBoundary.tsx';
88
+ export type { LazyBoundaryProps } from './LazyBoundary.tsx';
86
89
  export { UI_NAMESPACE, UI_RESOURCES, addUiResources } from './locales.ts';
87
90
  export { startInputModality } from './modality.ts';
88
91
  export { isApplePlatform } from './platform.ts';
@@ -0,0 +1,21 @@
1
+ let landings = 0;
2
+ const listeners = new Set<() => void>();
3
+
4
+ /* Called once per module, when a `LoadedComponent` first has it. */
5
+ export function noteLanding(): void {
6
+ landings += 1;
7
+ for (const listener of listeners) {
8
+ listener();
9
+ }
10
+ }
11
+
12
+ export function subscribeLandings(listener: () => void): () => void {
13
+ listeners.add(listener);
14
+ return () => {
15
+ listeners.delete(listener);
16
+ };
17
+ }
18
+
19
+ export function readLandings(): number {
20
+ return landings;
21
+ }
package/src/lazy.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Suspense, lazy, useState, useSyncExternalStore, type ComponentProps, type ComponentType } from 'react';
2
+ import { noteLanding } from './lazy-landings.ts';
2
3
  import { prefetcher } from './prefetch.ts';
3
4
 
4
5
  /*
@@ -22,6 +23,7 @@ export class LoadedComponent<Component> {
22
23
  for (const listener of this.listeners) {
23
24
  listener();
24
25
  }
26
+ noteLanding();
25
27
  }
26
28
  return component;
27
29
  }
@@ -60,6 +62,9 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
60
62
  }
61
63
  }
62
64
 
65
+ /* A lazy component, with `preload` to have its module before the first render, so that render never suspends. */
66
+ export type LazyComponent<Props> = ComponentType<Props> & { readonly preload: () => Promise<void> };
67
+
63
68
  /*
64
69
  * `React.lazy` for a module that exports its component by name, `default` included. Every module
65
70
  * loaded through here, or through `lazyDialog`, registers with `prefetcher`, which loads it ahead of
@@ -68,7 +73,7 @@ async function open<Component>(loaded: LoadedComponent<Component>): Promise<{ de
68
73
  export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name extends keyof Module>(
69
74
  load: () => Promise<Module>,
70
75
  name: Name
71
- ): 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
  );