@ultimat3/ui 21.0.0 → 22.1.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 +11 -14
- package/README.md +9 -0
- package/package.json +5 -5
- package/src/components/CopyButton.tsx +13 -4
- package/src/components/Dialog.tsx +11 -2
- package/src/components/Drawer.tsx +9 -1
- package/src/components/InfiniteScroll.tsx +15 -1
- 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 -27
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.
|
|
@@ -94,8 +89,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
94
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.
|
|
95
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.
|
|
96
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.
|
|
97
|
-
- **Generated source is a CODE sink.** `build-icons.ts` writes modules every app
|
|
98
|
-
- **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.
|
|
99
94
|
|
|
100
95
|
## Files
|
|
101
96
|
|
|
@@ -155,3 +150,5 @@ bun run --filter @ultimat3/ui icons # regenerate the glyph set (network; d
|
|
|
155
150
|
`tsconfig` that maps `@ultimat3/*` to `packages/*/src` needs the more specific entry
|
|
156
151
|
`"@ultimat3/ui/icons/*": ["./packages/ui/src/icons/glyphs/*"]` for TypeScript to follow it —
|
|
157
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.1.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.1.0",
|
|
52
|
+
"@ultimat3/i18n": "22.1.0",
|
|
53
|
+
"@ultimat3/money": "22.1.0",
|
|
54
|
+
"@ultimat3/time": "22.1.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"solid-js": "^1.9.0"
|
|
@@ -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
|
}
|
|
@@ -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();
|
|
@@ -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,9 +44,8 @@ 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,
|
|
@@ -69,7 +67,7 @@ export { Breadcrumb } from './components/Breadcrumb';
|
|
|
69
67
|
export type { ButtonProps } from './components/Button';
|
|
70
68
|
export { Button } from './components/Button';
|
|
71
69
|
export type { BarRect, ChartPoint } from './components/bar-chart-view';
|
|
72
|
-
export {
|
|
70
|
+
export { barRects } from './components/bar-chart-view';
|
|
73
71
|
export type { CardProps, Elevation } from './components/Card';
|
|
74
72
|
export { Card } from './components/Card';
|
|
75
73
|
export type { CheckboxProps } from './components/Checkbox';
|
|
@@ -83,7 +81,7 @@ export { Container } from './components/Container';
|
|
|
83
81
|
export type { CopyButtonProps } from './components/CopyButton';
|
|
84
82
|
export { COPIED_MS, CopyButton } from './components/CopyButton';
|
|
85
83
|
export type { ComboboxOption } from './components/combobox-filter';
|
|
86
|
-
export {
|
|
84
|
+
export { filterOptions, normalizeQuery } from './components/combobox-filter';
|
|
87
85
|
export type { CommandPaletteItem, PaletteKeyAction } from './components/command-palette-view';
|
|
88
86
|
export {
|
|
89
87
|
filterItems,
|
|
@@ -130,10 +128,7 @@ export type {
|
|
|
130
128
|
FileTarget,
|
|
131
129
|
} from './components/file-input-view';
|
|
132
130
|
export {
|
|
133
|
-
acceptMatches,
|
|
134
|
-
adoptDroppedFiles,
|
|
135
131
|
formatBytes,
|
|
136
|
-
progressPercent,
|
|
137
132
|
selectFiles,
|
|
138
133
|
} from './components/file-input-view';
|
|
139
134
|
export type { GridProps } from './components/Grid';
|
|
@@ -152,11 +147,10 @@ export { InfiniteScroll } from './components/InfiniteScroll';
|
|
|
152
147
|
export type { InputProps, InputType } from './components/Input';
|
|
153
148
|
export { Input } from './components/Input';
|
|
154
149
|
export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
|
|
155
|
-
export { ICON_TAGS, iconElements
|
|
150
|
+
export { ICON_TAGS, iconElements } from './components/icon-glyph';
|
|
156
151
|
export type { ImageBox, ImageLoadingHints, ImageVariant } from './components/image-source';
|
|
157
|
-
export { boxFor,
|
|
152
|
+
export { boxFor, srcsetFor } from './components/image-source';
|
|
158
153
|
export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
|
|
159
|
-
export { loadMoreState } from './components/infinite-scroll-view';
|
|
160
154
|
export type { KbdProps } from './components/Kbd';
|
|
161
155
|
export { Kbd } from './components/Kbd';
|
|
162
156
|
export type { LinkProps } from './components/Link';
|
|
@@ -164,14 +158,12 @@ export { Link } from './components/Link';
|
|
|
164
158
|
export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
|
|
165
159
|
export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
|
|
166
160
|
export type { LinkTarget } from './components/link-target';
|
|
167
|
-
export { linkTarget } from './components/link-target';
|
|
168
161
|
export type { MenuItem, MenuProps } from './components/Menu';
|
|
169
162
|
export { Menu } from './components/Menu';
|
|
170
163
|
export type { MeterProps } from './components/Meter';
|
|
171
164
|
export { Meter } from './components/Meter';
|
|
172
165
|
export type { MoneyProps } from './components/Money';
|
|
173
166
|
export { Money } from './components/Money';
|
|
174
|
-
export { meterShare, meterWidth } from './components/meter-view';
|
|
175
167
|
export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/money-view';
|
|
176
168
|
// --- formatting cores (pure, renderer-free) ----------------------------------
|
|
177
169
|
export { moneyText, toMoney } from './components/money-view';
|
|
@@ -190,7 +182,6 @@ export { Radio } from './components/Radio';
|
|
|
190
182
|
export type { RelativeTimeProps } from './components/RelativeTime';
|
|
191
183
|
export { RelativeTime } from './components/RelativeTime';
|
|
192
184
|
export type { RelativeTimeOptions } from './components/relative-time-view';
|
|
193
|
-
export { relativeTimeText } from './components/relative-time-view';
|
|
194
185
|
export type { SectionProps } from './components/Section';
|
|
195
186
|
export { Section } from './components/Section';
|
|
196
187
|
export type { SelectOption, SelectProps } from './components/Select';
|
|
@@ -208,11 +199,10 @@ export { StatTile } from './components/StatTile';
|
|
|
208
199
|
export type { SwitchProps } from './components/Switch';
|
|
209
200
|
export { Switch } from './components/Switch';
|
|
210
201
|
export type { SortDirection, SortState } from './components/sort-state';
|
|
211
|
-
export { ariaSortFor, nextSortState } from './components/sort-state';
|
|
212
202
|
export type { SparkPoint } from './components/sparkline-view';
|
|
213
|
-
export {
|
|
203
|
+
export { sparklinePath } from './components/sparkline-view';
|
|
214
204
|
export type { StatDelta, StatTrend } from './components/stat-delta';
|
|
215
|
-
export {
|
|
205
|
+
export { deltaOf } from './components/stat-delta';
|
|
216
206
|
export type { TableProps } from './components/Table';
|
|
217
207
|
export { Table } from './components/Table';
|
|
218
208
|
export type { TabItem, TabsProps } from './components/Tabs';
|
|
@@ -269,11 +259,9 @@ export type {
|
|
|
269
259
|
export { issuesFromRejection, issuesFromValidation } from './form/form-issue';
|
|
270
260
|
export type { FormErrors, FormState, FormStatus } from './form/form-state';
|
|
271
261
|
export {
|
|
272
|
-
distributeIssues,
|
|
273
262
|
errorOf,
|
|
274
263
|
firstInvalidField,
|
|
275
264
|
IDLE_FORM_STATE,
|
|
276
|
-
messagesOf,
|
|
277
265
|
NO_FORM_ERRORS,
|
|
278
266
|
} from './form/form-state';
|
|
279
267
|
export type { FormTouch } from './form/form-touch';
|
|
@@ -282,7 +270,6 @@ export {
|
|
|
282
270
|
markDirty,
|
|
283
271
|
markTouched,
|
|
284
272
|
NO_FORM_TOUCH,
|
|
285
|
-
sameFieldValue,
|
|
286
273
|
} from './form/form-touch';
|
|
287
274
|
export { valuesOfForm } from './form/form-values';
|
|
288
275
|
export { useForm } from './form/use-form';
|
|
@@ -319,7 +306,7 @@ export type { UiProviderProps } from './theme/provider';
|
|
|
319
306
|
export { UiProvider } from './theme/provider';
|
|
320
307
|
// The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
|
|
321
308
|
// @ultimat3/core's error registry — into an island chunk. `barrel-bytes.test.ts` holds the ceiling.
|
|
322
|
-
export {
|
|
309
|
+
export { hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
|
|
323
310
|
export type { Accessor, Setter, SolidContext, SolidRuntime } from './theme/solid-adapter';
|
|
324
311
|
export { solid } from './theme/solid-adapter';
|
|
325
312
|
export type { ThemeEnv } from './theme/theme';
|
|
@@ -349,11 +336,8 @@ export type {
|
|
|
349
336
|
} from './toast/toast-state';
|
|
350
337
|
// --- toasts: the queue behind ToastRegion ------------------------------------
|
|
351
338
|
export {
|
|
352
|
-
collapsedToasts,
|
|
353
|
-
EMPTY_TOAST_QUEUE,
|
|
354
339
|
TOAST_DWELL_MS,
|
|
355
340
|
TOAST_MAX_VISIBLE,
|
|
356
|
-
visibleToasts,
|
|
357
341
|
} from './toast/toast-state';
|
|
358
342
|
export type { ToastEnv, ToastStore } from './toast/toast-store';
|
|
359
343
|
export { browserToastEnv, createToastStore, INERT_TOAST_ENV } from './toast/toast-store';
|
|
@@ -362,15 +346,13 @@ export type { Channels } from './tokens/contrast';
|
|
|
362
346
|
export {
|
|
363
347
|
AA_LARGE,
|
|
364
348
|
AA_TEXT,
|
|
365
|
-
CHANNELS_PATTERN,
|
|
366
349
|
contrastRatio,
|
|
367
350
|
meetsContrast,
|
|
368
|
-
parseChannels,
|
|
369
351
|
relativeLuminance,
|
|
370
352
|
roleContrast,
|
|
371
353
|
} from './tokens/contrast';
|
|
372
354
|
export type { ContrastPair } from './tokens/contrast-pairs';
|
|
373
|
-
export { CONTRAST_PAIRS
|
|
355
|
+
export { CONTRAST_PAIRS } from './tokens/contrast-pairs';
|
|
374
356
|
export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
|
|
375
357
|
// --- tokens ------------------------------------------------------------------
|
|
376
358
|
export {
|