@ultimat3/ui 20.2.1 → 22.0.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/CLAUDE.md +13 -15
- package/README.md +9 -0
- package/package.json +5 -5
- package/src/components/AsyncRegion.tsx +1 -2
- package/src/components/CopyButton.tsx +13 -4
- package/src/components/DataTable.tsx +2 -2
- package/src/components/Dialog.tsx +11 -2
- package/src/components/Drawer.tsx +9 -1
- package/src/components/InfiniteScroll.tsx +15 -1
- package/src/components/async-branch.ts +7 -13
- package/src/components/copy-write.ts +22 -0
- package/src/components/date-time-view.ts +11 -6
- package/src/components/load-more-trigger.ts +25 -0
- package/src/index.ts +9 -28
package/CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @ultimat3/ui — agent notes
|
|
2
2
|
|
|
3
|
-
Tier 4
|
|
3
|
+
Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**, which `package.json` does not declare: that is why `src/form/` re-declares Standard Schema's one member structurally (`FormSchema`) and copies `formatPath` as `formatFieldPath`. Never `http`, `action`, `render`, `admin` — `render` is tier 4 too, which keeps the static bundle graph out of the design system.
|
|
4
4
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
@@ -14,17 +14,12 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
14
14
|
- **No raw colours.** Only `t.role('<role>')` / `var(--color-*)`. Canonical roles live in `src/tokens/_colors.scss`; `tokens.ts` mirrors them and `tokens.test.ts` fails on drift.
|
|
15
15
|
- **Logical properties only.** `margin-inline`, `inset-inline-start`, `text-align: start`. A `left`/`right` in a stylesheet is a bug.
|
|
16
16
|
- **solid-js is a type-only import.** All reactive access goes through `src/theme/solid-adapter.ts`. Never `import { createSignal } from 'solid-js'`.
|
|
17
|
-
- **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid
|
|
18
|
-
- **The barrel is the ONE import path
|
|
19
|
-
- **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (
|
|
17
|
+
- **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid-adapter.ts` reaches `../errors` and so core's error registry; the slot imports nothing, so an island that only calls `setSolidRuntime` pays 72 B, not 5,719. `barrel-bytes.test.ts` holds the ceiling at 1 kB.
|
|
18
|
+
- **The barrel is the ONE import path; component subpath exports are refused on measurement** (issue #275). A barrel import and a deep path emit the same modules and bytes, so `@ultimat3/ui/button` would be a second idiom for zero bytes. The two subpaths that exist are not exceptions: `./icons/*` is 1,767 data modules no bundler can split, and `./jsx-probe` is test-only. `barrel-bytes.test.ts` compares the two paths and names `schema-error-codes.ts` (Bun 1.4.0's load-correlated shake, issues #273 #276) rather than allowing bytes for it.
|
|
19
|
+
- **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (issue #490). `context.ts` imports no value from `@ultimat3/i18n` or `@ultimat3/time` (the i18n barrel installs the framework catalog at import: 16.1 kB per island). `src/theme/ambient.ts` registers `ambientUiContext` into `ambient-slot.ts` at import; `index.ts` imports it BARE above every re-export, and `package.json`'s `browser` field maps it to `ambient.browser.ts` (bundlers only — `bun test` and SSR get the server's). A test reaching components by module path must `import './ambient'` itself, as `inert-render.test.ts` does. `fallbackTranslator` is held member-for-member against `createTranslator({})` by `context.test.ts`. `errors.ts` is pure: `registerErrorCodes()` is `src/error-registry.ts`, imported bare. `barrel-bytes.test.ts` pins the theme island's retained-module list.
|
|
20
20
|
- **`solid()` always answers off-DOM.** No registered runtime and no DOM is a *server render*, and it gets `INERT_SOLID_RUNTIME` (`src/theme/inert-runtime.ts`) — signals hold, memos recompute on read, effects never run, `useContext` returns the default. No DOM means no reactivity to lose; a **DOM** with no runtime is still `X_UI_RUNTIME_MISSING`, because that one is the theme toggle that does nothing. Never widen this to "no runtime, never throw": that is the silent degradation the split exists to prevent.
|
|
21
|
-
- **`useUi()` reads the request on the server**, via `ambientUiContext()` — `currentLocale()`, `currentTimeZone()`, `useI18n()
|
|
22
|
-
- **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.**
|
|
23
|
-
`initialsOf(name, locale)` reads `useUi().locale`, exactly as `DateTime` does. A bare
|
|
24
|
-
`toLocaleUpperCase()` reads the RUNTIME's default — a server's `LANG`, a browser's UI language —
|
|
25
|
-
which is the ambient locale this package does not have; Turkish is where the absence shows,
|
|
26
|
-
because dotted `i` uppercases to `İ`, so a Turkish member's avatar read `I` on a server in
|
|
27
|
-
Ireland. **Breaking**: `initialsOf`'s second parameter is required.
|
|
21
|
+
- **`useUi()` reads the request on the server**, via `ambientUiContext()` — `currentLocale()`, `currentTimeZone()`, `useI18n()`, which read **core's own `Ctx.locale` / `Ctx.tz`** (written once per request by `@ultimat3/http`'s `locale` stage). Never add a second ambient store here or in `i18n`/`time`. `theme`/`currency` have no ambient source at all.
|
|
22
|
+
- **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.** `initialsOf(name, locale)` reads `useUi().locale`; a bare `toLocaleUpperCase()` reads the runtime's default (Turkish dotted `i` → `İ`).
|
|
28
23
|
- **`UiProvider` is client-only and throws on the server** (`providerNeedsRuntimeError`, the same `X_UI_RUNTIME_MISSING`). A Provider in an inert tree reaches no descendant — the tree is built before the renderer walks it, so consumers are walked outside every owner and read the context default even with a real Solid runtime registered. Rendering the children anyway would drop its locale, zone, currency and translator silently. Making it work needs the *renderer* to scope a context around the walk; until then it refuses.
|
|
29
24
|
- **A component may call `solid()` freely.** Its effects must stay DOM-only work — they simply never run on the server.
|
|
30
25
|
- **No `{...rest}` prop spreading.** Splitting props reactively needs solid's `splitProps` (a value import), so components declare explicit props and read `props.x` inside JSX.
|
|
@@ -35,7 +30,7 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
35
30
|
- **Icons are generated, never authored.** `src/icons/glyphs/*.ts` is one module per Lucide icon, written by `bun run icons` from `lucide-static@LUCIDE_VERSION`. Never hand-edit a glyph, never add a hand-drawn one, never introduce a second icon source. An upstream fix is a version bump plus a re-run. `lucide` itself is NOT a dependency — the data is committed, so the package still installs, typechecks and renders offline with zero runtime deps.
|
|
36
31
|
- **One icon, one module.** The `Icon` component takes a `glyph`, not a name: a `name → glyph` map would be one module holding 1767 icons, and no bundler can split that. Per-icon imports are the whole point (1 icon = 104 B minified, 50 = 8.9 kB).
|
|
37
32
|
- **No client runtime exists yet.** A new interactive component must be correct server-rendered and usable with scripting off — `<details>` for disclosure, `<input list>`+`<datalist>` for suggestions, a real `rel="next"` link for paging — with listeners and observers as additive extras. A component that renders nothing until JS runs does not ship.
|
|
38
|
-
- **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render`
|
|
33
|
+
- **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render` installs a process-global `.tsx` `onLoad` plugin at import, so in one `bun test` process a component may compile to render's `h`. The walker recognises both (`Symbol.for('ultimate.render.jsx')`), and the first test asserts a recognised node — otherwise the file renders `"[object Object]"` and passes by shard packing.
|
|
39
34
|
- **Components are not unit-tested through a renderer.** `.tsx` compiles to `@ultimat3/render`'s `h`, which this package may not import, so every rule lives in a pure module beside the component (`icon-glyph.ts`, `accordion-view.ts`, `combobox-filter.ts`, `infinite-scroll-view.ts`) and *that* is what the tests assert.
|
|
40
35
|
- Formatting logic lives in a pure `*-view.ts` next to the component (`money-view.ts`, `date-time-view.ts`) so it is testable with no renderer. Every other renderer-free core follows the same rule under its own name (`sort-state.ts`, `image-source.ts`) — the `.tsx` holds markup, never a rule.
|
|
41
36
|
- **The rule being pure is not enough — the WIRING has to be tested too.** `createRovingTabindex` was correct and `Menu` handed it `[role="menuitem"]`, so a disabled item made every item after it unreachable and every assertion in the package still passed. `src/jsx-probe.ts` reads the props an element actually carries (a `tabindex`, an `aria-live`, an `onKeyDown`, a `ref`) and `src/fake-dom.ts` gives it a DOM where a disabled control REFUSES focus, exactly as the real one does. Both are test-only and neither is in `index.ts`. `jsx-probe`'s `probe`/`unprobe` are the one exception, reachable at the subpath `@ultimat3/ui/jsx-probe` and still absent from the barrel: `globalThis.React` is ONE property, so its install/restore counter has to be ONE counter — `@ultimat3/admin` (tier 5) kept a second pair, and interleaved installs restored the two harnesses in the wrong order, leaving the global holding a harness the run had already torn down. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
|
|
@@ -45,7 +40,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
45
40
|
(`src/components/async-branch.ts`) is the ONLY place the `(pending, failed, empty, data)` decision
|
|
46
41
|
is made — `AsyncRegion` renders it and `DataTable` calls it, so a table and a card list cannot
|
|
47
42
|
disagree about what "loading with stale rows" looks like. The property is structural: an
|
|
48
|
-
`AsyncState` in `
|
|
43
|
+
`AsyncState` (declared in `@ultimat3/core` since 21.0.0 — imported here, never re-exported, so
|
|
44
|
+
it has one import path) in `pending` carries no data, so nothing can be found empty in it, and "No results"
|
|
49
45
|
for one frame before the first page arrives is unconstructible rather than discouraged.
|
|
50
46
|
`<AsyncRegion>`'s `empty` and `ready` are REQUIRED props, so forgetting the empty state is a type
|
|
51
47
|
error. `refreshing` CARRIES the previous data — a refetch dims what is on screen (`aria-busy`) and
|
|
@@ -93,8 +89,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
93
89
|
- **An issue that matches no declared field is SURFACED, never swallowed.** It goes to `formErrors`, which `<Form error>` announces and focuses; a near miss (`items` against a form holding `items[0].price`) goes there too, because an error rendered against the wrong input is a lie the user acts on. Two issues on one field both survive in the state — `Field`'s one slot renders the first, `messagesFor` has the rest.
|
|
94
90
|
- **The framework ships the mapping and no copy table.** `FormIssue.message` is a schema's diagnostic text and is never user-facing; `messageFor` is required and is the app's. There is deliberately no `ui.form.*` key: a built-in wording would be one app's convention shipped to every other. A `messageFor` that answers `''` or throws falls back to the diagnostic text — the loud-but-safe answer `t()` already gives with `⟦key⟧`, because the alternatives are a control marked `aria-invalid` with nothing to read, or a submit abandoned mid-flight holding the user's input.
|
|
95
91
|
- **The wire carries no structured issue list, `As of 2026-08-24`.** `InputInvalidError` renders the issues into `cause` and puts nothing in `meta`, and `toProblem` has no `issues` member, so `issuesFromRejection` reads `meta.issues` where there is one and parses the cause line where there is not. It binds a fragment to a field only when the fragment's head is a DECLARED path, so a mis-split degrades to a form-level error rather than to a wrong control. Delete the line parser the day the wire carries the list.
|
|
96
|
-
- **Generated source is a CODE sink.** `build-icons.ts` writes modules every app
|
|
97
|
-
- **The icon NAME is the third sink, and it has no escape.**
|
|
92
|
+
- **Generated source is a CODE sink.** `build-icons.ts` writes modules every app executes, from network data: attribute values go through `JSON.stringify`, and `SAFE_ATTR_VALUE` refuses anything that is not glyph geometry. Malformed upstream data is `X_UI_INVALID_VALUE`; only real environment faults (no network, no biome) are `X_UI_RUNTIME_MISSING`.
|
|
93
|
+
- **The icon NAME is the third sink, and it has no escape.** It becomes a path under `GLYPHS_DIR` (which `buildIcons` clears), an identifier and a banner, so `SAFE_ICON_NAME` (`/^[a-z0-9]+(-[a-z0-9]+)*$/`) is checked in `parseIconNodes` before `out.set`; `build-icons.test.ts` asserts all 1767 committed names pass.
|
|
98
94
|
|
|
99
95
|
## Files
|
|
100
96
|
|
|
@@ -154,3 +150,5 @@ bun run --filter @ultimat3/ui icons # regenerate the glyph set (network; d
|
|
|
154
150
|
`tsconfig` that maps `@ultimat3/*` to `packages/*/src` needs the more specific entry
|
|
155
151
|
`"@ultimat3/ui/icons/*": ["./packages/ui/src/icons/glyphs/*"]` for TypeScript to follow it —
|
|
156
152
|
runtime resolution is the `exports` map either way.
|
|
153
|
+
|
|
154
|
+
Why each rule above is shaped the way it is: [`docs/history/ui.md`](../../docs/history/ui.md).
|
package/README.md
CHANGED
|
@@ -545,6 +545,15 @@ of truth for the one thing the server decides.
|
|
|
545
545
|
| `X_UI_QR_CAPACITY` | a `<QrCode>` value over version 3's 42-byte ceiling (byte mode, error-correction level M). The encoder draws versions 1–3 only; the cause names the byte count and the ceiling |
|
|
546
546
|
| `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
|
|
547
547
|
|
|
548
|
+
### Error classes
|
|
549
|
+
|
|
550
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
551
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
552
|
+
|
|
553
|
+
| Class | Code | Declared in |
|
|
554
|
+
|---|---|---|
|
|
555
|
+
| `UiError` | any `UiErrorCode` — `UI_ERROR_CODES` | `src/errors.ts` |
|
|
556
|
+
|
|
548
557
|
## Commands
|
|
549
558
|
|
|
550
559
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"icons": "bun run src/icons/build-icons.ts"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@ultimat3/core": "
|
|
52
|
-
"@ultimat3/i18n": "
|
|
53
|
-
"@ultimat3/money": "
|
|
54
|
-
"@ultimat3/time": "
|
|
51
|
+
"@ultimat3/core": "22.0.0",
|
|
52
|
+
"@ultimat3/i18n": "22.0.0",
|
|
53
|
+
"@ultimat3/money": "22.0.0",
|
|
54
|
+
"@ultimat3/time": "22.0.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"solid-js": "^1.9.0"
|
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
// placeholder is derived from `reserve` so it cannot mismatch the loaded box, and the failure
|
|
8
8
|
// renders through `ErrorState`, which is the one thing allowed to phrase an error.
|
|
9
9
|
|
|
10
|
-
import { finiteCount } from '@ultimat3/core';
|
|
10
|
+
import { type AsyncState, finiteCount } from '@ultimat3/core';
|
|
11
11
|
import type { JSX } from 'solid-js';
|
|
12
12
|
import { ariaBool } from '../a11y';
|
|
13
13
|
import { cx } from '../cx';
|
|
14
14
|
import styles from './AsyncRegion.module.scss';
|
|
15
15
|
import {
|
|
16
16
|
type AsyncBranch,
|
|
17
|
-
type AsyncState,
|
|
18
17
|
asyncBranch,
|
|
19
18
|
isBusyBranch,
|
|
20
19
|
type ReserveBox,
|
|
@@ -12,6 +12,7 @@ import { iconCopy } from '../icons/glyphs/copy';
|
|
|
12
12
|
import { useUi } from '../theme/context';
|
|
13
13
|
import { solid } from '../theme/solid-adapter';
|
|
14
14
|
import styles from './CopyButton.module.scss';
|
|
15
|
+
import { writeToClipboard } from './copy-write';
|
|
15
16
|
import { Icon } from './Icon';
|
|
16
17
|
import type { Size } from './variants';
|
|
17
18
|
|
|
@@ -36,12 +37,20 @@ export function CopyButton(props: CopyButtonProps): JSX.Element {
|
|
|
36
37
|
const label = (): string => props.label ?? ui.t(UI_KEYS.copy);
|
|
37
38
|
const copiedLabel = (): string => props.copiedLabel ?? ui.t(UI_KEYS.copied);
|
|
38
39
|
|
|
40
|
+
let reset: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
// The "copied" timer does not outlive the button: firing a setter on an unmounted component is
|
|
42
|
+
// a write into a disposed owner.
|
|
43
|
+
rt.onCleanup(() => clearTimeout(reset));
|
|
44
|
+
|
|
39
45
|
const onClick = async (): Promise<void> => {
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
46
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
47
|
+
// "Copied" only on a write that RESOLVED: no clipboard used to show the check mark anyway, and
|
|
48
|
+
// a refused write escaped as an unhandled rejection through the `void` below.
|
|
49
|
+
if (!(await writeToClipboard(props.value, clipboard))) return;
|
|
43
50
|
setCopied(true);
|
|
44
|
-
|
|
51
|
+
clearTimeout(reset);
|
|
52
|
+
reset = setTimeout(() => setCopied(false), COPIED_MS);
|
|
53
|
+
// `announce` needs a document that can find its live region.
|
|
45
54
|
if (typeof document !== 'undefined' && typeof document.getElementById === 'function') {
|
|
46
55
|
announce(copiedLabel());
|
|
47
56
|
}
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
// table and a card list cannot disagree about what "loading with stale rows" looks like. Only the
|
|
7
7
|
// PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
|
|
8
8
|
|
|
9
|
-
import { finiteCount } from '@ultimat3/core';
|
|
9
|
+
import { type AsyncState, finiteCount } from '@ultimat3/core';
|
|
10
10
|
import type { JSX } from 'solid-js';
|
|
11
11
|
import { ariaBool } from '../a11y';
|
|
12
12
|
import { cx } from '../cx';
|
|
13
13
|
import { UI_KEYS } from '../i18n-keys';
|
|
14
14
|
import { useUi } from '../theme/context';
|
|
15
|
-
import { type AsyncBranch,
|
|
15
|
+
import { type AsyncBranch, asyncBranch, isBusyBranch } from './async-branch';
|
|
16
16
|
import styles from './DataTable.module.scss';
|
|
17
17
|
import { EmptyState } from './EmptyState';
|
|
18
18
|
import { ErrorState } from './ErrorState';
|
|
@@ -30,6 +30,9 @@ export function Dialog(props: DialogProps): JSX.Element {
|
|
|
30
30
|
const rt = solid();
|
|
31
31
|
const titleId = useId('dialog-title');
|
|
32
32
|
let element: HTMLDialogElement | undefined;
|
|
33
|
+
// Where the press STARTED. A drag-select begun in the panel and released over the backdrop
|
|
34
|
+
// fires `click` on the <dialog> itself, and closed the dialog mid-selection.
|
|
35
|
+
let pressedBackdrop = false;
|
|
33
36
|
|
|
34
37
|
rt.createEffect(() => {
|
|
35
38
|
const dialog = element;
|
|
@@ -52,9 +55,15 @@ export function Dialog(props: DialogProps): JSX.Element {
|
|
|
52
55
|
event.preventDefault();
|
|
53
56
|
props.onClose();
|
|
54
57
|
}}
|
|
58
|
+
onPointerDown={(event) => {
|
|
59
|
+
pressedBackdrop = event.target === element;
|
|
60
|
+
}}
|
|
55
61
|
onClick={(event) => {
|
|
56
|
-
// A click on the backdrop lands on the <dialog> itself, never a child
|
|
57
|
-
|
|
62
|
+
// A click on the backdrop lands on the <dialog> itself, never a child — and so must the
|
|
63
|
+
// press that began it, or this is the end of a drag that started in the panel.
|
|
64
|
+
const dismiss = pressedBackdrop && event.target === element;
|
|
65
|
+
pressedBackdrop = false;
|
|
66
|
+
if (props.dismissOnBackdrop !== false && dismiss) props.onClose();
|
|
58
67
|
}}
|
|
59
68
|
>
|
|
60
69
|
<div class={styles['panel']}>
|
|
@@ -30,6 +30,9 @@ export function Drawer(props: DrawerProps): JSX.Element {
|
|
|
30
30
|
const rt = solid();
|
|
31
31
|
const titleId = useId('drawer-title');
|
|
32
32
|
let element: HTMLDialogElement | undefined;
|
|
33
|
+
// Where the press STARTED — the `Dialog` rule: a drag-select begun in the panel and released
|
|
34
|
+
// over the backdrop fires `click` on the <dialog> itself, and closed the drawer mid-selection.
|
|
35
|
+
let pressedBackdrop = false;
|
|
33
36
|
|
|
34
37
|
rt.createEffect(() => {
|
|
35
38
|
const dialog = element;
|
|
@@ -53,8 +56,13 @@ export function Drawer(props: DrawerProps): JSX.Element {
|
|
|
53
56
|
event.preventDefault();
|
|
54
57
|
props.onClose();
|
|
55
58
|
}}
|
|
59
|
+
onPointerDown={(event) => {
|
|
60
|
+
pressedBackdrop = event.target === element;
|
|
61
|
+
}}
|
|
56
62
|
onClick={(event) => {
|
|
57
|
-
|
|
63
|
+
const dismiss = pressedBackdrop && event.target === element;
|
|
64
|
+
pressedBackdrop = false;
|
|
65
|
+
if (dismiss) props.onClose();
|
|
58
66
|
}}
|
|
59
67
|
>
|
|
60
68
|
<div class={styles['panel']}>
|
|
@@ -10,6 +10,7 @@ import { useUi } from '../theme/context';
|
|
|
10
10
|
import { solid } from '../theme/solid-adapter';
|
|
11
11
|
import styles from './InfiniteScroll.module.scss';
|
|
12
12
|
import { loadMoreState } from './infinite-scroll-view';
|
|
13
|
+
import { createLoadMoreTrigger } from './load-more-trigger';
|
|
13
14
|
import { Spinner } from './Spinner';
|
|
14
15
|
|
|
15
16
|
/** One viewport-ish of runway, so the next page is usually there before the current one ends. */
|
|
@@ -36,6 +37,9 @@ export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element {
|
|
|
36
37
|
const rt = solid();
|
|
37
38
|
let sentinel: HTMLSpanElement | undefined;
|
|
38
39
|
|
|
40
|
+
// Built once per mount; the observer reports into it and so does the `loading` edge below.
|
|
41
|
+
const trigger = createLoadMoreTrigger(() => props.onLoadMore?.());
|
|
42
|
+
|
|
39
43
|
rt.createEffect(() => {
|
|
40
44
|
const onLoadMore = props.onLoadMore;
|
|
41
45
|
if (onLoadMore === undefined || sentinel === undefined) return;
|
|
@@ -43,7 +47,10 @@ export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element {
|
|
|
43
47
|
if (!props.hasMore || typeof IntersectionObserver === 'undefined') return;
|
|
44
48
|
const observer = new IntersectionObserver(
|
|
45
49
|
(entries) => {
|
|
46
|
-
|
|
50
|
+
trigger.seen(
|
|
51
|
+
entries.some((entry) => entry.isIntersecting),
|
|
52
|
+
props.loading === true,
|
|
53
|
+
);
|
|
47
54
|
},
|
|
48
55
|
{ rootMargin: props.rootMargin ?? ROOT_MARGIN },
|
|
49
56
|
);
|
|
@@ -51,6 +58,13 @@ export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element {
|
|
|
51
58
|
rt.onCleanup(() => observer.disconnect());
|
|
52
59
|
});
|
|
53
60
|
|
|
61
|
+
// A page that loaded without pushing the sentinel out of view gets no new intersection report,
|
|
62
|
+
// so the loading edge re-asks — the list stalled here with its runway still showing.
|
|
63
|
+
rt.createEffect(() => {
|
|
64
|
+
const loading = props.loading === true;
|
|
65
|
+
if (props.hasMore) trigger.settled(loading);
|
|
66
|
+
});
|
|
67
|
+
|
|
54
68
|
const onClick = (event: MouseEvent): void => {
|
|
55
69
|
if (props.onLoadMore === undefined) return;
|
|
56
70
|
event.preventDefault();
|
|
@@ -4,18 +4,11 @@
|
|
|
4
4
|
// unreachable until a result has arrived, and a refetch keeps the previous data on screen.
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* What a caller hands an async region
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `refreshing` is the one that makes search feel fast — it CARRIES the previous data, so a refetch
|
|
12
|
-
* re-renders what is already on screen instead of tearing it down to a skeleton.
|
|
7
|
+
* What a caller hands an async region is `AsyncState` from `@ultimat3/core` — declared at tier 0
|
|
8
|
+
* because realtime's read hooks (tier 3) return it and ui (tier 4) consumes it, and neither may
|
|
9
|
+
* import the other that way. Imported, never re-exported: one home, one import path.
|
|
13
10
|
*/
|
|
14
|
-
|
|
15
|
-
| { readonly status: 'pending' }
|
|
16
|
-
| { readonly status: 'refreshing'; readonly data: T }
|
|
17
|
-
| { readonly status: 'ready'; readonly data: T }
|
|
18
|
-
| { readonly status: 'failed'; readonly error: unknown };
|
|
11
|
+
import type { AsyncState } from '@ultimat3/core';
|
|
19
12
|
|
|
20
13
|
/**
|
|
21
14
|
* The branch a region renders. `empty` and `ready` carry `busy`; `pending` and `failed` do not,
|
|
@@ -23,8 +16,9 @@ export type AsyncState<T> =
|
|
|
23
16
|
*
|
|
24
17
|
* There is no `{ kind: 'empty' }` reachable from `{ status: 'pending' }` — that is the whole
|
|
25
18
|
* point of this module. "No results" rendered for one frame before the first page arrives is the
|
|
26
|
-
* most common agent-authored UX bug in a list screen, and
|
|
27
|
-
* rather than merely discouraged: `pending` holds no data, so nothing can be found
|
|
19
|
+
* most common agent-authored UX bug in a list screen, and `AsyncState`'s union makes it
|
|
20
|
+
* unconstructible rather than merely discouraged: `pending` holds no data, so nothing can be found
|
|
21
|
+
* empty in it.
|
|
28
22
|
*/
|
|
29
23
|
export type AsyncBranch<T> =
|
|
30
24
|
| { readonly kind: 'pending' }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Single responsibility: one clipboard write, answered as a boolean. `CopyButton` claims "Copied"
|
|
2
|
+
// only on `true` — a missing clipboard (an insecure origin, an old browser, a harness) and a
|
|
3
|
+
// refused write (permission) are both a copy that did not happen, and neither may reject.
|
|
4
|
+
|
|
5
|
+
/** The one method used, structurally — `navigator.clipboard`, or a test's stand-in. */
|
|
6
|
+
export interface ClipboardWriter {
|
|
7
|
+
writeText(text: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function writeToClipboard(
|
|
11
|
+
value: string,
|
|
12
|
+
clipboard: ClipboardWriter | undefined,
|
|
13
|
+
): Promise<boolean> {
|
|
14
|
+
if (clipboard === undefined) return false;
|
|
15
|
+
try {
|
|
16
|
+
await clipboard.writeText(value);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
// Refused: a permission prompt denied, or a document without focus. The button stays as it was.
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// needs: the machine-readable ISO instant for `<time datetime>` and the human
|
|
3
3
|
// string formatted in the injected zone + locale.
|
|
4
4
|
|
|
5
|
+
import { isIsoDateTime } from '@ultimat3/core';
|
|
5
6
|
import {
|
|
6
7
|
type DateTimeStyle,
|
|
7
8
|
type FormatDateTimeOptions,
|
|
@@ -38,17 +39,21 @@ export interface DateTimeView {
|
|
|
38
39
|
readonly text: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/** A date-TIME string with no `Z` and no `±HH:MM`. A date-only string is not one: the spec parses
|
|
42
|
-
* `2026-08-14` as UTC, so it is already zone-independent. */
|
|
43
|
-
const OFFSETLESS_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?![\s\S]*(?:Z|[+-]\d{2}:?\d{2})$)/;
|
|
44
|
-
|
|
45
42
|
export function toDate(value: TimeInput): Date {
|
|
46
43
|
// `new Date('2026-08-14T09:00')` resolves in the HOST's zone — the one ambient default this
|
|
47
44
|
// package forbids, inside the one function that had it. It rendered `09:00` on a `TZ=UTC` runner
|
|
48
45
|
// and `00:00` on `TZ=Asia/Tokyo` with the same `timeZone="UTC"` prop and no error. Every
|
|
49
46
|
// FORMATTING path here was already zoned; only the parse was not.
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
//
|
|
48
|
+
// The predicate is `@ultimat3/schema`'s — the one `t.date` and `timestamp()` use — so a string is
|
|
49
|
+
// ISO-8601 in SHAPE as well as zoned: `'August 14, 2026 09:00'` and `'8/14/2026'` were not
|
|
50
|
+
// "ISO-shaped offset-less" and walked past a narrower local guard into `new Date`, in the host zone.
|
|
51
|
+
if (typeof value === 'string' && !isIsoDateTime(value)) {
|
|
52
|
+
throw invalidValueError(
|
|
53
|
+
'DateTime',
|
|
54
|
+
value,
|
|
55
|
+
'an ISO-8601 string: a date (2026-08-14) or a date-time carrying Z or a ±HH:MM offset',
|
|
56
|
+
);
|
|
52
57
|
}
|
|
53
58
|
const date = value instanceof Date ? value : new Date(value);
|
|
54
59
|
if (Number.isNaN(date.getTime())) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Single responsibility: when an endless list's sentinel asks for the next page. Kept apart from
|
|
2
|
+
// the observer because IntersectionObserver reports only CHANGES — a sentinel that never left the
|
|
3
|
+
// viewport produces no second callback, and the loading edge is the only other signal there is.
|
|
4
|
+
|
|
5
|
+
export interface LoadMoreTrigger {
|
|
6
|
+
/** The observer's report: is the sentinel in view, and is a page in flight right now? */
|
|
7
|
+
seen(visible: boolean, loading: boolean): void;
|
|
8
|
+
/** `loading` changed. Going false with the sentinel still in view is a request of its own. */
|
|
9
|
+
settled(loading: boolean): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLoadMoreTrigger(onLoadMore: () => void): LoadMoreTrigger {
|
|
13
|
+
let visible = false;
|
|
14
|
+
return {
|
|
15
|
+
seen(nowVisible, loading) {
|
|
16
|
+
visible = nowVisible;
|
|
17
|
+
if (visible && !loading) onLoadMore();
|
|
18
|
+
},
|
|
19
|
+
settled(loading) {
|
|
20
|
+
// The stall this exists for: a page too short to push the sentinel out left it in view, and
|
|
21
|
+
// no intersection CHANGE ever came to ask again.
|
|
22
|
+
if (!loading && visible) onLoadMore();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,6 @@ export {
|
|
|
28
28
|
focusableWithin,
|
|
29
29
|
LIVE_REGION_LEVELS,
|
|
30
30
|
liveRegionAttrs,
|
|
31
|
-
nextRovingIndex,
|
|
32
31
|
resetIdCounter,
|
|
33
32
|
useId,
|
|
34
33
|
} from './a11y';
|
|
@@ -45,13 +44,11 @@ export { AsyncRegion } from './components/AsyncRegion';
|
|
|
45
44
|
export type { AvatarProps } from './components/Avatar';
|
|
46
45
|
export { Avatar, initialsOf } from './components/Avatar';
|
|
47
46
|
export type { AccordionSection } from './components/accordion-view';
|
|
48
|
-
export { accordionOpenIds } from './components/accordion-view';
|
|
49
47
|
export type { ShellIds, ShellLandmark, ShellSlots } from './components/app-shell-view';
|
|
50
|
-
export { shellIds
|
|
48
|
+
export { shellIds } from './components/app-shell-view';
|
|
51
49
|
export type {
|
|
52
50
|
AsyncBranch,
|
|
53
51
|
AsyncFlags,
|
|
54
|
-
AsyncState,
|
|
55
52
|
ReserveBox,
|
|
56
53
|
} from './components/async-branch';
|
|
57
54
|
export {
|
|
@@ -70,7 +67,7 @@ export { Breadcrumb } from './components/Breadcrumb';
|
|
|
70
67
|
export type { ButtonProps } from './components/Button';
|
|
71
68
|
export { Button } from './components/Button';
|
|
72
69
|
export type { BarRect, ChartPoint } from './components/bar-chart-view';
|
|
73
|
-
export {
|
|
70
|
+
export { barRects } from './components/bar-chart-view';
|
|
74
71
|
export type { CardProps, Elevation } from './components/Card';
|
|
75
72
|
export { Card } from './components/Card';
|
|
76
73
|
export type { CheckboxProps } from './components/Checkbox';
|
|
@@ -84,7 +81,7 @@ export { Container } from './components/Container';
|
|
|
84
81
|
export type { CopyButtonProps } from './components/CopyButton';
|
|
85
82
|
export { COPIED_MS, CopyButton } from './components/CopyButton';
|
|
86
83
|
export type { ComboboxOption } from './components/combobox-filter';
|
|
87
|
-
export {
|
|
84
|
+
export { filterOptions, normalizeQuery } from './components/combobox-filter';
|
|
88
85
|
export type { CommandPaletteItem, PaletteKeyAction } from './components/command-palette-view';
|
|
89
86
|
export {
|
|
90
87
|
filterItems,
|
|
@@ -131,10 +128,7 @@ export type {
|
|
|
131
128
|
FileTarget,
|
|
132
129
|
} from './components/file-input-view';
|
|
133
130
|
export {
|
|
134
|
-
acceptMatches,
|
|
135
|
-
adoptDroppedFiles,
|
|
136
131
|
formatBytes,
|
|
137
|
-
progressPercent,
|
|
138
132
|
selectFiles,
|
|
139
133
|
} from './components/file-input-view';
|
|
140
134
|
export type { GridProps } from './components/Grid';
|
|
@@ -153,11 +147,10 @@ export { InfiniteScroll } from './components/InfiniteScroll';
|
|
|
153
147
|
export type { InputProps, InputType } from './components/Input';
|
|
154
148
|
export { Input } from './components/Input';
|
|
155
149
|
export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
|
|
156
|
-
export { ICON_TAGS, iconElements
|
|
150
|
+
export { ICON_TAGS, iconElements } from './components/icon-glyph';
|
|
157
151
|
export type { ImageBox, ImageLoadingHints, ImageVariant } from './components/image-source';
|
|
158
|
-
export { boxFor,
|
|
152
|
+
export { boxFor, srcsetFor } from './components/image-source';
|
|
159
153
|
export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
|
|
160
|
-
export { loadMoreState } from './components/infinite-scroll-view';
|
|
161
154
|
export type { KbdProps } from './components/Kbd';
|
|
162
155
|
export { Kbd } from './components/Kbd';
|
|
163
156
|
export type { LinkProps } from './components/Link';
|
|
@@ -165,14 +158,12 @@ export { Link } from './components/Link';
|
|
|
165
158
|
export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
|
|
166
159
|
export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
|
|
167
160
|
export type { LinkTarget } from './components/link-target';
|
|
168
|
-
export { linkTarget } from './components/link-target';
|
|
169
161
|
export type { MenuItem, MenuProps } from './components/Menu';
|
|
170
162
|
export { Menu } from './components/Menu';
|
|
171
163
|
export type { MeterProps } from './components/Meter';
|
|
172
164
|
export { Meter } from './components/Meter';
|
|
173
165
|
export type { MoneyProps } from './components/Money';
|
|
174
166
|
export { Money } from './components/Money';
|
|
175
|
-
export { meterShare, meterWidth } from './components/meter-view';
|
|
176
167
|
export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/money-view';
|
|
177
168
|
// --- formatting cores (pure, renderer-free) ----------------------------------
|
|
178
169
|
export { moneyText, toMoney } from './components/money-view';
|
|
@@ -191,7 +182,6 @@ export { Radio } from './components/Radio';
|
|
|
191
182
|
export type { RelativeTimeProps } from './components/RelativeTime';
|
|
192
183
|
export { RelativeTime } from './components/RelativeTime';
|
|
193
184
|
export type { RelativeTimeOptions } from './components/relative-time-view';
|
|
194
|
-
export { relativeTimeText } from './components/relative-time-view';
|
|
195
185
|
export type { SectionProps } from './components/Section';
|
|
196
186
|
export { Section } from './components/Section';
|
|
197
187
|
export type { SelectOption, SelectProps } from './components/Select';
|
|
@@ -209,11 +199,10 @@ export { StatTile } from './components/StatTile';
|
|
|
209
199
|
export type { SwitchProps } from './components/Switch';
|
|
210
200
|
export { Switch } from './components/Switch';
|
|
211
201
|
export type { SortDirection, SortState } from './components/sort-state';
|
|
212
|
-
export { ariaSortFor, nextSortState } from './components/sort-state';
|
|
213
202
|
export type { SparkPoint } from './components/sparkline-view';
|
|
214
|
-
export {
|
|
203
|
+
export { sparklinePath } from './components/sparkline-view';
|
|
215
204
|
export type { StatDelta, StatTrend } from './components/stat-delta';
|
|
216
|
-
export {
|
|
205
|
+
export { deltaOf } from './components/stat-delta';
|
|
217
206
|
export type { TableProps } from './components/Table';
|
|
218
207
|
export { Table } from './components/Table';
|
|
219
208
|
export type { TabItem, TabsProps } from './components/Tabs';
|
|
@@ -270,11 +259,9 @@ export type {
|
|
|
270
259
|
export { issuesFromRejection, issuesFromValidation } from './form/form-issue';
|
|
271
260
|
export type { FormErrors, FormState, FormStatus } from './form/form-state';
|
|
272
261
|
export {
|
|
273
|
-
distributeIssues,
|
|
274
262
|
errorOf,
|
|
275
263
|
firstInvalidField,
|
|
276
264
|
IDLE_FORM_STATE,
|
|
277
|
-
messagesOf,
|
|
278
265
|
NO_FORM_ERRORS,
|
|
279
266
|
} from './form/form-state';
|
|
280
267
|
export type { FormTouch } from './form/form-touch';
|
|
@@ -283,7 +270,6 @@ export {
|
|
|
283
270
|
markDirty,
|
|
284
271
|
markTouched,
|
|
285
272
|
NO_FORM_TOUCH,
|
|
286
|
-
sameFieldValue,
|
|
287
273
|
} from './form/form-touch';
|
|
288
274
|
export { valuesOfForm } from './form/form-values';
|
|
289
275
|
export { useForm } from './form/use-form';
|
|
@@ -320,7 +306,7 @@ export type { UiProviderProps } from './theme/provider';
|
|
|
320
306
|
export { UiProvider } from './theme/provider';
|
|
321
307
|
// The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
|
|
322
308
|
// @ultimat3/core's error registry — into an island chunk. `barrel-bytes.test.ts` holds the ceiling.
|
|
323
|
-
export {
|
|
309
|
+
export { hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
|
|
324
310
|
export type { Accessor, Setter, SolidContext, SolidRuntime } from './theme/solid-adapter';
|
|
325
311
|
export { solid } from './theme/solid-adapter';
|
|
326
312
|
export type { ThemeEnv } from './theme/theme';
|
|
@@ -350,11 +336,8 @@ export type {
|
|
|
350
336
|
} from './toast/toast-state';
|
|
351
337
|
// --- toasts: the queue behind ToastRegion ------------------------------------
|
|
352
338
|
export {
|
|
353
|
-
collapsedToasts,
|
|
354
|
-
EMPTY_TOAST_QUEUE,
|
|
355
339
|
TOAST_DWELL_MS,
|
|
356
340
|
TOAST_MAX_VISIBLE,
|
|
357
|
-
visibleToasts,
|
|
358
341
|
} from './toast/toast-state';
|
|
359
342
|
export type { ToastEnv, ToastStore } from './toast/toast-store';
|
|
360
343
|
export { browserToastEnv, createToastStore, INERT_TOAST_ENV } from './toast/toast-store';
|
|
@@ -363,15 +346,13 @@ export type { Channels } from './tokens/contrast';
|
|
|
363
346
|
export {
|
|
364
347
|
AA_LARGE,
|
|
365
348
|
AA_TEXT,
|
|
366
|
-
CHANNELS_PATTERN,
|
|
367
349
|
contrastRatio,
|
|
368
350
|
meetsContrast,
|
|
369
|
-
parseChannels,
|
|
370
351
|
relativeLuminance,
|
|
371
352
|
roleContrast,
|
|
372
353
|
} from './tokens/contrast';
|
|
373
354
|
export type { ContrastPair } from './tokens/contrast-pairs';
|
|
374
|
-
export { CONTRAST_PAIRS
|
|
355
|
+
export { CONTRAST_PAIRS } from './tokens/contrast-pairs';
|
|
375
356
|
export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
|
|
376
357
|
// --- tokens ------------------------------------------------------------------
|
|
377
358
|
export {
|