@forming/react 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,86 @@
1
+ Forming Source Available License 1.0
2
+
3
+ Copyright (c) 2026 Forming contributors.
4
+
5
+ 1. Scope and definitions
6
+
7
+ This license covers the Forming implementation, tools, examples and other
8
+ original material supplied with this notice (the Software). Each copyright
9
+ holder grants rights only to material they own or are authorized to license.
10
+ Third-party components and material expressly offered under another license
11
+ remain governed by those terms. See NOTICE.md and the attribution notices.
12
+
13
+ An Internal Use is use by your employees or contractors solely for your own
14
+ organization's operations. Customers and other external users are not internal
15
+ users. A Commercial Product is a product or service sold, licensed for a fee,
16
+ monetized through advertising, or offered to support a paid business offering,
17
+ including its free tiers, trials and bundled features.
18
+
19
+ 2. Permission
20
+
21
+ Subject to the conditions below, you may use, inspect, copy and modify the
22
+ Software for personal, educational, noncommercial and Internal Use. Internal
23
+ Use by a commercial business is permitted without payment.
24
+
25
+ You may also use the Software on the backend of a Commercial Product to compile
26
+ definitions, validate data or process results, provided that you do not offer
27
+ the Software's capabilities as a service or use the Software in that product's
28
+ external user interface as described in section 3.
29
+
30
+ You may redistribute unmodified or modified copies without charge for uses
31
+ permitted by this license. You must include this license and all applicable
32
+ copyright, attribution and third-party notices. Identify changes you make.
33
+ Recipients receive their rights from the applicable copyright holders under
34
+ these same terms; you may not grant broader rights to the Software.
35
+
36
+ 3. Uses requiring separate written permission
37
+
38
+ Without a separate written agreement from the applicable copyright holders,
39
+ you may not, directly or through another party:
40
+
41
+ (a) sell, rent, sublicense for a fee, or otherwise charge for copies of or
42
+ access to the Software, including modified, rebranded or bundled versions;
43
+
44
+ (b) offer the Software or its form authoring, rendering, compilation, validation
45
+ or runtime capabilities to external users as a hosted, managed, API or other
46
+ commercial service, including through a wrapper or a free tier of that service;
47
+
48
+ (c) embed, distribute or otherwise use the Software to provide any part of a
49
+ Commercial Product's external user interface, including ordinary rendered
50
+ forms, widgets, editors, visual builders and white-label interfaces.
51
+
52
+ These restrictions apply even if the Software is only one part of an offering
53
+ or is not separately priced. They do not prohibit Internal Use or the backend
54
+ integration expressly allowed in section 2. Charging for consulting, installation
55
+ or support is allowed when the resulting use itself complies with this license.
56
+
57
+ 4. Your material and other rights
58
+
59
+ You retain ownership of your independently authored form definitions, submitted
60
+ data and other original output. This license does not require you to disclose
61
+ your application's source code. Output that contains Software code remains
62
+ subject to this license to the extent it contains that code. No trademark rights
63
+ are granted. Rights not expressly granted are reserved.
64
+
65
+ 5. Termination
66
+
67
+ Your permissions end if you violate this license. If you correct your first
68
+ violation within 30 days of receiving written notice from a copyright holder,
69
+ your permissions are restored. Further violations require express written
70
+ reinstatement. Termination does not remove obligations that apply to copies
71
+ already distributed.
72
+
73
+ 6. Warranty and liability
74
+
75
+ TO THE EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT
76
+ WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
77
+ NONINFRINGEMENT. COPYRIGHT HOLDERS AND CONTRIBUTORS ARE NOT LIABLE FOR CLAIMS,
78
+ DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE, WHETHER IN
79
+ CONTRACT, TORT OR OTHERWISE.
80
+
81
+ 7. Additional permission
82
+
83
+ Contact the project maintainers through the repository from which you obtained
84
+ the Software to request a commercial agreement. An unanswered request does not
85
+ grant permission. A separate written agreement controls only to the extent it
86
+ expressly grants different rights.
package/NOTICE.md ADDED
@@ -0,0 +1,18 @@
1
+ # Licensing notices
2
+
3
+ The Forming implementation is covered by the [Forming Source Available License](LICENSE).
4
+ This license permits internal business use and commercial backend integration,
5
+ but requires separate written permission for resale, commercial hosted Forming
6
+ services and external UI use in commercial products. It is not an open-source
7
+ license. The license text controls over this summary.
8
+
9
+ The specifications and adapted specification text retain their existing
10
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) terms and attribution.
11
+ See `core/ATTRIBUTION.md` and `website/ATTRIBUTION.md` in the source repository.
12
+ The implementation license does not replace those rights or revoke rights
13
+ granted under earlier licenses.
14
+
15
+ Dependencies retain their own licenses. Rust dependency notices are collected
16
+ under `tools/skills/notices/` in the source repository and included with packaged
17
+ WASM distributions. JavaScript and Go dependency notices are supplied by their
18
+ respective packages. Preserve applicable notices when redistributing bundles.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # Forming React renderer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Independent React/TypeScript development package. The Rust/WASM runtime remains the owner of validation, calculations, branching, provider freshness, row identity and Submit. This renderer is implemented and tested, but is not a published or launch-certified package.
4
+
5
+ ## Presentation plugins
6
+
7
+ Presentation plugins are ordinary plugins with a `present` capability. Register them with `createExtensions(...)` alongside the rest and pass that registry as `<FormingForm extensions={plugins}>`. `choiceCards({identity, fields})` supplies selectable sections with headings and multiline text for local options, preserving native radio/checkbox behavior and exact values. Custom `present.choice` callbacks use the same presentation-only contract. See the [plugin API and example](../../extensions/README.md#selectable-sections-in-react).
8
+
9
+ ## Use
10
+
11
+ `@forming/integrations` is a peer dependency: install it beside the renderer so your app, the renderer and any plugins share one copy of its exact-number `JsonNumber`. Until the packages are published, install packed tarballs (`npm pack` in this folder and in `core/hosts/javascript`); `npm run test:pack` checks that install in a fresh app outside this checkout.
12
+
13
+ ```sh
14
+ npm install react react-dom ./forming-integrations-0.1.0-dev.1.tgz ./forming-react-0.1.0-dev.1.tgz
15
+ ```
16
+
17
+ Build the sibling Rust/WASM project and provide its long-lived `FormRuntime` instance:
18
+
19
+ ```tsx
20
+ import {createController, FormingForm} from '@forming/react';
21
+ import '@forming/react/styles.css';
22
+
23
+ const controller = createController(runtime);
24
+ // Keep this controller stable for the lifetime of the runtime.
25
+ <FormingForm controller={controller} />;
26
+ ```
27
+
28
+ The optional stylesheet provides a scoped, responsive theme without styling the rest of your application. The default `presentation="embedded"` uses compact controls. Use `presentation="focused"` for larger questions, generous spacing and native radio/checkbox cards for up to eight local choices. Short local lists are native radios and checkboxes in both presentations: up to five single-choice options and yes/no questions (labelled Yes/No) in embedded mode, up to eight in focused mode, and consent (`mustBe true`) is a single “I accept” box. Multiple choice with up to eight local options is a checkbox list in both presentations, under a "Choose as many as you like" hint (`text.chooseMany`): a multi-select list box hides that several answers are allowed. Letter keys (A–H) pick choices in focused presentation only. Remote and longer option lists remain dropdowns. Switching presentation preserves answers and pending editor buffers.
29
+
30
+ Focused mode follows your authored pages; it does not turn every field into a page or auto-advance after answering. Put questions inside `page` blocks with Next/Back actions when you want a step-by-step experience. Keyboard focus follows page navigation, without stealing focus on initial mount.
31
+
32
+ For a one-question-at-a-time flow, put one question per `page`; a page that holds only `display` content (restricted Markdown) renders as a welcome or closing screen. Keyboard: Enter activates the page's Continue (or Submit), Cmd/Ctrl+Enter does so from a textarea, and A–H pick a focused-mode choice card (in the focused question, or anywhere on a page whose only card question it is). Letters typed into text controls stay text. Focused mode shows a "Press Enter ↵" hint on pointer devices. Blocked Next/Submit/Validate buttons stay activatable (`aria-disabled`) so the core can reveal what is missing; a page reached by Next shows no errors until the respondent tries to continue from it, and the error summary counts the current page only. Pages slide in the direction of travel.
33
+
34
+ `detail="standard"` (default) shows respondent-facing controls only. `detail="exact"` adds the data-level controls: explicit Set to null, Use empty list, the portable date/time text beside the pickers and locale echoes of values that already read the same. In standard detail dates and times are edited with the pickers alone, unless saved text is something the pickers cannot show. Fields with `maxLength` show a counter (always for textareas, near the limit otherwise).
35
+
36
+ Customize the theme on a parent element using CSS variables such as `--forming-primary`, `--forming-on-primary`, `--forming-surface`, `--forming-soft`, `--forming-ink`, `--forming-muted`, `--forming-border`, `--forming-error`, `--forming-error-background`, `--forming-success`, `--forming-radius`, `--forming-control-radius`, `--forming-font` and `--forming-width`. Hover, focus-ring, stronger-border and tinted shades are derived from these with `color-mix()`, so a handful of tokens re-themes every state. Layouts space their children with `gap` rather than per-field margins, and repeat items render as cards with an item header and icon tools (icon-only in narrow containers). For dark themes also set `--forming-color-scheme: dark` and suitable error/background colors. You can omit the stylesheet and style the renderer's data attributes yourself.
37
+
38
+ Before freeing the runtime, call `controller.dispose()` and dispose connected hosts. A disposed controller ignores late editor cleanup and events, so component teardown cannot read freed WASM memory.
39
+
40
+ For async providers, connect the [provider host](../../core/hosts/javascript/README.md) and pass its `dispatch` as createController's second argument. On host callbacks call `controller.refresh()`. The host remains responsible for save/submit transport and journal durability. The renderer never frees the runtime or starts network requests. Dispose hosts, persist unresolved obligations, then unmount/free according to the application's lifecycle.
41
+
42
+ Supported views: text/textarea/password, exact numeric text buffers, boolean/enum choices, remote choices with search/lookup/loading/retry, explicit clear/null, object creation, repeated insertion/removal/move, readonly calculated values, page navigation, stack/columns/grid, field errors and action availability.
43
+
44
+ Scalar arrays without choices now have accessible add/remove/reorder row editors, including nullable items and exact numeric buffers. Missing, null and empty arrays remain distinct. New incomplete rows stay local and block actions other than Reset; they cancel waiting Submit. Lists above 1,000 items and complex arrays fall back to lossless JSON editing without truncation. Local editor row keys are not runtime repeat identities.
45
+
46
+ Date/time fields retain authoritative portable text and add native picker helpers (unless an explicit text/password/textarea view opts out). Datetimes require an explicit offset; helpers never infer a timezone or convert through JavaScript Date. Nanosecond fractions are preserved when editing dates/offsets and when the visible time fraction is unchanged. Helper labels participate in renderer translations. Full cross-browser/mobile/screen-reader acceptance remains a release gate.
47
+
48
+ Conditional pages/layouts/displays consume the core's `validation.viewStates`; children inherit effective state through `fieldStates`. Display title/content are plain, translatable text, with authored line breaks retained. Hidden pages follow the runtime's selected route. Explicit field IDs remain opaque and include generated placement/row suffixes when present. Real-WASM DOM tests cover layout visibility/editability, retained answers, translated display safety and hidden-page navigation.
49
+
50
+ File fields now render a native file picker, transient/verified status, removal, explicit retry and portable reselection notices. Supply the [upload host](../../core/hosts/javascript/uploads.md) as createController's third argument and its dispatch as the second. Handles go directly to that host, never into JSON answers or drafts. Selection is disabled without a host or ready inputs; file fields do not expose raw JSON, generic clear or null editors. The core owns verification, limits, answer commits and waiting Submit.
51
+
52
+ For the [S3 extension](../../extensions/s3-upload/README.md), connect the adapter through the same upload host. The `onChange` callback refreshes asynchronous transfer and inspection results in the renderer:
53
+
54
+ ```tsx
55
+ import {createS3UploadAdapter, provider} from '@forming/extension-s3-upload';
56
+ import {connectUploads} from '@forming/integrations/uploads';
57
+ import {createController, FormingForm, type Controller} from '@forming/react';
58
+
59
+ let controller: Controller | undefined;
60
+ const uploads = connectUploads(runtime, {
61
+ adapters: new Map([
62
+ [
63
+ provider,
64
+ createS3UploadAdapter({
65
+ prepare: request => backend.prepareUpload(request),
66
+ inspect: request => backend.inspectUpload(request),
67
+ release: request => backend.releaseUpload(request),
68
+ }),
69
+ ],
70
+ ]),
71
+ journal: durableUploadJournal,
72
+ onChange: () => controller?.refresh(),
73
+ });
74
+ controller = createController(runtime, uploads.dispatch, uploads);
75
+ <FormingForm controller={controller} />;
76
+ ```
77
+
78
+ The S3 renderer integration test covers selection, direct PUT, waiting Submit, verified status and removal/release using the real runtime and application transport doubles. The backend SDK's simpler registration API does not change the renderer's upload-host contract.
79
+
80
+ All answer/schema/provider numbers use the shared lossless JSON decoder. Never round-trip them through ordinary JSON.parse/stringify. Numeric choice matching treats equivalent decimal spellings equally. Incomplete numeric/array buffers are not committed, cancel waiting Submit through `submit.cancel`, and block actions other than Reset; correcting/clearing the buffer releases that block. Already-issued requests cannot be undone. Buffers are transient, not drafts: Reset and fields becoming hidden/disabled/readonly discard their uncommitted editor text without altering stored answers. IME/mobile and all browser editing paths still need acceptance testing.
81
+
82
+ Repeated React keys are runtime stable IDs, never array indexes. Validation state is joined by node ID. Labels, helper text, errors and translations are React text, never injected HTML. Field inputs have associated labels, required/invalid metadata and described errors. Locale lang/dir comes from the runtime; pass translated renderer-owned button/status copy through `text`. Keyboard, responsive layout and screen-reader audits remain launch gates.
83
+
84
+ Catalog `renderer.<key>` messages now supply button/status and true/false copy, with explicit `text` props taking precedence. Projected provider rejection/error text is supported. IME composition holds text locally, blocks stale Submit and commits on composition end; reset/hide/disable clears composition state. URL/phone fields request appropriate mobile keyboards without native-input normalization. Grid/columns views wrap on narrow screens and fieldsets may shrink. DOM tests cover composition/reset and translated renderer copy; the website's opt-in Chromium smoke gate adds keyboard, accessible-name and mobile-containment evidence, not screen-reader or cross-browser certification.
85
+
86
+ ## Local checks
87
+
88
+ From this package, run `rtk npm ci --ignore-scripts`, then `rtk npm run lint && rtk npm test`. The type check also compiles an integration using the S3 adapter, upload host and renderer without casts. Tests use real generated WASM bindings and a simulated DOM. Override `FORMING_WASM_BINDINGS` with an absolute path to test optimized bindings. These are not real-browser or production-backend tests.
89
+
90
+ Dependencies are pinned in package-lock.json. Both this package and the local host package are private development packages. Real storage/durable recovery, custom fields/payments, production deployment, package publication and browser distribution remain pending.
91
+
92
+ Presentation plugins can also register `present.decorations`, keyed by an owned
93
+ annotation export. Each callback receives the visible field/display identity, its
94
+ annotations and a `before` or `after` placement. It adds content without replacing
95
+ the normal editor or changing answers. See [media](../../extensions/media/README.md).
96
+
97
+ ## Automatic npm publishing
98
+
99
+ Publishes `@forming/react` from `.`.
100
+
101
+ `.github/workflows/publish.yml` publishes the JavaScript package on pushes to
102
+ `dev` (base version plus `-dev.<run>.<attempt>`, npm tag `dev`) and `main`
103
+ (stable package.json version, npm tag `latest`). Bump package.json and its lockfile
104
+ before another stable release. Manual runs support only these two branches.
105
+ The workflow creates no commits, Git tags or GitHub releases.
106
+
107
+ Set Actions variable `NPM_ACCESS` to `public` or `restricted`. Configure secret
108
+ `NPM_TOKEN`, or npm trusted publishing for this repository and workflow
109
+ `publish.yml` after the first publish. Private repositories publish with provenance
110
+ disabled. Packages exclude tests, source directories, build scripts and source maps.
111
+
112
+
113
+ ### npm release dependencies
114
+
115
+ Publishing uses npm packages, without private sibling checkouts or a
116
+ FORMING_REPOS_TOKEN. Core CI publishes `@forming/wasm@0.1.0` and
117
+ `@forming/integrations@0.1.0` first. The js-sdk repository then publishes
118
+ `@forming/runtime@0.1.0`, followed by the React renderer and extensions.
119
+ Approve any staged releases before publishing dependent packages.
@@ -0,0 +1,71 @@
1
+ import React from 'react';
2
+ import { type JsonValue } from '@forming/integrations/lossless-json';
3
+ export declare const editorText: {
4
+ item: string;
5
+ emptyList: string;
6
+ datePicker: string;
7
+ timePicker: string;
8
+ offset: string;
9
+ offsetHint: string;
10
+ listLimit: string;
11
+ };
12
+ export declare const MAX_LIST_ITEMS = 1000;
13
+ declare const icons: {
14
+ close: string;
15
+ check: string;
16
+ plus: string;
17
+ up: string;
18
+ down: string;
19
+ trash: string;
20
+ none: string;
21
+ };
22
+ export declare function Icon({ name }: {
23
+ name: keyof typeof icons;
24
+ }): React.JSX.Element;
25
+ type Text = typeof editorText & {
26
+ add: string;
27
+ remove: string;
28
+ up: string;
29
+ down: string;
30
+ setNull: string;
31
+ nullValue: string;
32
+ choose: string;
33
+ trueValue: string;
34
+ falseValue: string;
35
+ };
36
+ export interface ScalarSchema {
37
+ type?: string | string[];
38
+ format?: string;
39
+ }
40
+ export declare function baseType(schema?: ScalarSchema): string | undefined;
41
+ export declare function inputMode(schema: ScalarSchema): 'decimal' | 'email' | 'url' | 'tel' | undefined;
42
+ export declare function ScalarListEditor({ id, value, schema, disabled, reset, text, describedBy, commit, invalid, exact, }: {
43
+ id: string;
44
+ value: JsonValue | undefined;
45
+ schema: ScalarSchema;
46
+ disabled: boolean;
47
+ reset: string;
48
+ text: Text;
49
+ describedBy: string;
50
+ commit(value: JsonValue[]): boolean;
51
+ invalid(value: boolean): void;
52
+ /** Show null and empty-list controls. Respondent-facing forms usually hide them. */
53
+ exact?: boolean;
54
+ }): React.JSX.Element;
55
+ /** True when the native pickers can show this saved text without losing or hiding anything. */
56
+ export declare function pickerAccepts(value: string, format: string): boolean;
57
+ export declare function TemporalPicker({ id, title, value, format, disabled, reset, text, describedBy, change, incomplete, standalone, }: {
58
+ id: string;
59
+ title: string;
60
+ value: string;
61
+ format: string;
62
+ disabled: boolean;
63
+ reset: string;
64
+ text: Text;
65
+ describedBy: string;
66
+ change(value: string): void;
67
+ incomplete(): void;
68
+ /** The pickers are the only editor: the field label names them and the wrapper takes the field id. */
69
+ standalone?: boolean;
70
+ }): React.JSX.Element;
71
+ export {};
@@ -0,0 +1,222 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { JsonNumber, stringifyJson } from '@forming/integrations/lossless-json';
4
+ export const editorText = {
5
+ item: 'Item',
6
+ emptyList: 'Use empty list',
7
+ datePicker: 'Choose date',
8
+ timePicker: 'Choose time',
9
+ offset: 'UTC offset',
10
+ offsetHint: 'Z or +05:30',
11
+ listLimit: 'This list editor supports up to 1,000 items.',
12
+ };
13
+ export const MAX_LIST_ITEMS = 1000;
14
+ // Decorative stroke icons. Buttons keep their accessible name in text, aria-label or title.
15
+ const icons = {
16
+ close: 'M6 6l12 12M18 6L6 18',
17
+ check: 'M5 12.5l4.5 4.5L19 7',
18
+ plus: 'M12 5v14M5 12h14',
19
+ up: 'M12 19V5M6 11l6-6 6 6',
20
+ down: 'M12 5v14M18 13l-6 6-6-6',
21
+ trash: 'M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3',
22
+ none: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M5.6 18.4L18.4 5.6',
23
+ };
24
+ export function Icon({ name }) {
25
+ return (_jsx("svg", { "data-forming-icon": name, "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "16", height: "16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: icons[name] }) }));
26
+ }
27
+ export function baseType(schema) {
28
+ return (Array.isArray(schema?.type) ? schema.type : [schema?.type]).find(type => type !== 'null');
29
+ }
30
+ export function inputMode(schema) {
31
+ if (['integer', 'number'].includes(baseType(schema) ?? ''))
32
+ return 'decimal';
33
+ if (schema.format === 'email')
34
+ return 'email';
35
+ if (schema.format === 'uri')
36
+ return 'url';
37
+ if (schema.format === 'forming-international-phone' || schema.format?.startsWith('forming-phone:'))
38
+ return 'tel';
39
+ }
40
+ const display = (value) => value === undefined || value === null ? '' : typeof value === 'string' ? value : stringifyJson(value);
41
+ export function ScalarListEditor({ id, value, schema, disabled, reset, text, describedBy, commit, invalid, exact = true, }) {
42
+ const sequence = useRef(0);
43
+ const makeRows = () => (Array.isArray(value) ? value : []).map(value => ({
44
+ id: ++sequence.current,
45
+ raw: display(value),
46
+ value,
47
+ pending: false,
48
+ composing: false,
49
+ }));
50
+ const [rows, setRows] = useState(makeRows);
51
+ const stored = value === undefined ? 'missing' : stringifyJson(value);
52
+ const expected = useRef(stored);
53
+ const previous = useRef({ reset, disabled });
54
+ const focus = useRef(null);
55
+ const inputs = useRef(new Map());
56
+ const add = useRef(null);
57
+ const type = baseType(schema);
58
+ const nullable = exact && Array.isArray(schema.type) && schema.type.includes('null');
59
+ useEffect(() => {
60
+ const changed = previous.current.reset !== reset || previous.current.disabled !== disabled;
61
+ previous.current = { reset, disabled };
62
+ if (changed || stored !== expected.current) {
63
+ expected.current = stored;
64
+ focus.current = null;
65
+ setRows(makeRows());
66
+ invalid(false);
67
+ }
68
+ }, [stored, reset, disabled]);
69
+ useEffect(() => {
70
+ if (focus.current === 'add')
71
+ add.current?.focus();
72
+ else if (focus.current !== null)
73
+ inputs.current.get(focus.current)?.focus();
74
+ focus.current = null;
75
+ }, [rows]);
76
+ function publish(next) {
77
+ if (disabled)
78
+ return;
79
+ setRows(next);
80
+ if (next.some(row => row.pending || row.composing || row.value === undefined)) {
81
+ invalid(true);
82
+ return;
83
+ }
84
+ const values = next.map(row => row.value);
85
+ const serialized = stringifyJson(values);
86
+ // Own commits preserve editor keys and focus. External edits reset local drafts.
87
+ const before = expected.current;
88
+ expected.current = serialized;
89
+ if (commit(values))
90
+ invalid(false);
91
+ else {
92
+ expected.current = before;
93
+ invalid(true);
94
+ }
95
+ }
96
+ function update(rowId, raw, composing) {
97
+ publish(rows.map(row => {
98
+ if (row.id !== rowId)
99
+ return row;
100
+ const next = { ...row, raw, composing: composing ?? row.composing, pending: true };
101
+ if (next.composing)
102
+ return next;
103
+ try {
104
+ if (type === 'number' || type === 'integer')
105
+ next.value = new JsonNumber(raw);
106
+ else if (type === 'boolean') {
107
+ if (raw !== 'true' && raw !== 'false')
108
+ return next;
109
+ next.value = raw === 'true';
110
+ }
111
+ else
112
+ next.value = raw;
113
+ next.pending = false;
114
+ }
115
+ catch {
116
+ /* Keep incomplete numeric spelling local, never submit its old value. */
117
+ }
118
+ return next;
119
+ }));
120
+ }
121
+ return (_jsxs("div", { "data-scalar-list": "", children: [rows.map((row, index) => {
122
+ const rowId = `${id}:entry:${row.id}`;
123
+ const label = `${text.item} ${index + 1}`;
124
+ const common = {
125
+ id: rowId,
126
+ disabled,
127
+ 'aria-describedby': describedBy,
128
+ 'aria-invalid': row.pending || row.composing,
129
+ };
130
+ return (_jsxs("div", { "data-list-item": "", children: [_jsx("label", { htmlFor: rowId, children: label }), type === 'boolean' ? (_jsxs("select", { ...common, ref: element => {
131
+ if (element)
132
+ inputs.current.set(row.id, element);
133
+ else
134
+ inputs.current.delete(row.id);
135
+ }, value: row.pending ? '' : row.value === true ? 'true' : row.value === false ? 'false' : '', onChange: event => update(row.id, event.target.value), children: [_jsx("option", { value: "", children: text.choose }), _jsx("option", { value: "true", children: text.trueValue }), _jsx("option", { value: "false", children: text.falseValue })] })) : (_jsx("input", { ...common, type: "text", inputMode: inputMode(schema), value: row.raw, ref: element => {
136
+ if (element)
137
+ inputs.current.set(row.id, element);
138
+ else
139
+ inputs.current.delete(row.id);
140
+ }, onChange: event => update(row.id, event.target.value), onCompositionStart: () => publish(rows.map(other => (other.id === row.id ? { ...other, composing: true } : other))), onCompositionEnd: event => update(row.id, event.currentTarget.value, false) })), exact && row.value === null && !row.pending && _jsx("span", { "data-forming-null": "", children: text.nullValue }), _jsxs("div", { "data-list-tools": "", children: [_jsx("button", { type: "button", disabled: disabled || index === 0, "aria-label": `${text.up}: ${label}`, title: text.up, onClick: () => {
141
+ const next = [...rows];
142
+ [next[index - 1], next[index]] = [next[index], next[index - 1]];
143
+ publish(next);
144
+ }, children: _jsx(Icon, { name: "up" }) }), _jsx("button", { type: "button", disabled: disabled || index === rows.length - 1, "aria-label": `${text.down}: ${label}`, title: text.down, onClick: () => {
145
+ const next = [...rows];
146
+ [next[index + 1], next[index]] = [next[index], next[index + 1]];
147
+ publish(next);
148
+ }, children: _jsx(Icon, { name: "down" }) }), nullable && (_jsx("button", { type: "button", disabled: disabled, "aria-label": `${text.setNull}: ${label}`, title: text.setNull, onClick: () => publish(rows.map(other => other.id === row.id ? { ...other, raw: '', value: null, pending: false, composing: false } : other)), children: _jsx(Icon, { name: "none" }) })), _jsx("button", { type: "button", "data-danger": "", disabled: disabled, "aria-label": `${text.remove}: ${label}`, title: text.remove, onClick: () => {
149
+ focus.current = rows[index + 1]?.id ?? rows[index - 1]?.id ?? 'add';
150
+ publish(rows.filter(other => other.id !== row.id));
151
+ }, children: _jsx(Icon, { name: "trash" }) })] })] }, row.id));
152
+ }), _jsxs("button", { ref: add, type: "button", "data-forming-add": "", disabled: disabled || rows.length >= MAX_LIST_ITEMS, onClick: () => {
153
+ if (rows.length >= MAX_LIST_ITEMS)
154
+ return;
155
+ const id = ++sequence.current;
156
+ focus.current = id;
157
+ publish([...rows, { id, raw: '', value: undefined, pending: true, composing: false }]);
158
+ }, children: [_jsx(Icon, { name: "plus" }), text.add] }), rows.length >= MAX_LIST_ITEMS && _jsx("p", { role: "status", children: text.listLimit }), exact && (_jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, onClick: () => {
159
+ focus.current = 'add';
160
+ publish([]);
161
+ }, children: text.emptyList }))] }));
162
+ }
163
+ /** True when the native pickers can show this saved text without losing or hiding anything. */
164
+ export function pickerAccepts(value, format) {
165
+ if (format === 'date')
166
+ return /^\d{4}-\d{2}-\d{2}$/.test(value);
167
+ if (format === 'forming-local-time')
168
+ return /^\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?$/.test(value);
169
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value);
170
+ }
171
+ function parts(value, format) {
172
+ if (format === 'date')
173
+ return { date: value, time: '', offset: '' };
174
+ if (format === 'forming-local-time')
175
+ return { date: '', time: value, offset: '' };
176
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)(Z|[+-]\d{2}:\d{2})$/.exec(value);
177
+ return { date: match?.[1] ?? '', time: match?.[2] ?? '', offset: match?.[3] ?? '' };
178
+ }
179
+ // Native pickers are an editing aid. The portable text input remains authoritative:
180
+ // no Date objects, timezone inference, or browser normalization of stored values.
181
+ export function TemporalPicker({ id, title, value, format, disabled, reset, text, describedBy, change, incomplete, standalone = false, }) {
182
+ const [draft, setDraft] = useState(() => parts(value, format));
183
+ const [pending, setPending] = useState(false);
184
+ useEffect(() => {
185
+ setDraft(parts(value, format));
186
+ setPending(false);
187
+ }, [value, format, reset, disabled]);
188
+ function update(key, value) {
189
+ if (disabled)
190
+ return;
191
+ // Browsers expose milliseconds at most. Editing hours/minutes must not trim
192
+ // the unchanged fractional suffix of a nanosecond-precision stored time.
193
+ const fraction = /\.(\d{4,9})$/.exec(draft.time)?.[1];
194
+ const visibleFraction = /\.(\d{1,3})$/.exec(value)?.[1] ?? '';
195
+ if (key === 'time' &&
196
+ fraction &&
197
+ /^\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?$/.test(value) &&
198
+ visibleFraction.padEnd(3, '0') === fraction.slice(0, 3)) {
199
+ const whole = value.replace(/\.\d+$/, '');
200
+ value = (whole.length === 5 ? whole + ':00' : whole) + '.' + fraction;
201
+ }
202
+ const next = { ...draft, [key]: value };
203
+ setDraft(next);
204
+ const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(next.date);
205
+ const timeValid = /^\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/.test(next.time);
206
+ const offsetValid = /^(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(next.offset) && next.offset !== '-00:00';
207
+ const valid = format === 'date'
208
+ ? dateValid
209
+ : format === 'forming-local-time'
210
+ ? timeValid
211
+ : dateValid && timeValid && offsetValid;
212
+ setPending(!valid);
213
+ if (!valid) {
214
+ incomplete();
215
+ return;
216
+ }
217
+ const time = next.time.length === 5 ? `${next.time}:00` : next.time;
218
+ change(format === 'date' ? next.date : format === 'forming-local-time' ? time : `${next.date}T${time}${next.offset}`);
219
+ }
220
+ const common = { disabled, 'aria-invalid': pending, 'aria-describedby': describedBy };
221
+ return (_jsxs("div", { "data-temporal-picker": "", "data-standalone": standalone ? '' : undefined, id: standalone ? id : undefined, tabIndex: standalone ? -1 : undefined, style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }, children: [format !== 'forming-local-time' && (_jsxs("label", { htmlFor: `${id}:date`, children: [_jsx("span", { children: text.datePicker }), _jsx("input", { ...common, "aria-label": `${title}: ${text.datePicker}`, id: `${id}:date`, type: "date", min: "0001-01-01", max: "9999-12-31", value: draft.date, onChange: event => update('date', event.target.value) })] })), format !== 'date' && (_jsxs("label", { htmlFor: `${id}:time`, children: [_jsx("span", { children: text.timePicker }), _jsx("input", { ...common, "aria-label": `${title}: ${text.timePicker}`, id: `${id}:time`, type: "time", step: "any", value: draft.time.replace(/(\.\d{3})\d+$/, '$1'), onChange: event => update('time', event.target.value) })] })), format === 'date-time' && (_jsxs("label", { htmlFor: `${id}:offset`, children: [_jsx("span", { children: text.offset }), _jsx("input", { ...common, "aria-label": `${title}: ${text.offset}`, id: `${id}:offset`, type: "text", placeholder: text.offsetHint, value: draft.offset, onChange: event => update('offset', event.target.value) })] }))] }));
222
+ }
@@ -0,0 +1,11 @@
1
+ import { type JsonValue } from '@forming/integrations/lossless-json';
2
+ type Schema = {
3
+ type?: string | string[];
4
+ format?: string;
5
+ };
6
+ export declare function formatNumber(raw: string, locale?: string): string | null;
7
+ export declare function formatDate(text: string, locale?: string): string | null;
8
+ export declare function formatTime(text: string, locale?: string): string | null;
9
+ export declare function formatDateTime(text: string, locale?: string): string | null;
10
+ export declare function localized(value: JsonValue | undefined, schema: Schema, locale?: string): string | null;
11
+ export {};
package/dist/format.js ADDED
@@ -0,0 +1,97 @@
1
+ import { JsonNumber } from '@forming/integrations/lossless-json';
2
+ function locales(locale) {
3
+ try {
4
+ if (locale)
5
+ return Intl.getCanonicalLocales(locale)[0];
6
+ }
7
+ catch {
8
+ // Fall through to the browser default.
9
+ }
10
+ return undefined;
11
+ }
12
+ export function formatNumber(raw, locale) {
13
+ try {
14
+ // Intl.NumberFormat accepts decimal strings exactly (ES2023); older
15
+ // engines coerce to a double, so refuse when that would lose digits.
16
+ const formatter = new Intl.NumberFormat(locales(locale), { maximumFractionDigits: 20 });
17
+ const formatted = formatter.format(raw);
18
+ const digits = raw.replace(/^-|\.|e.*$/gi, '').replace(/^0+/, '');
19
+ return formatted
20
+ .replace(/[^0-9]/g, '')
21
+ .replace(/^0+/, '')
22
+ .endsWith(digits.replace(/0+$/, ''))
23
+ ? formatted
24
+ : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export function formatDate(text, locale) {
31
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
32
+ if (!match)
33
+ return null;
34
+ const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
35
+ if (Number.isNaN(date.getTime()) || date.getUTCDate() !== Number(match[3]))
36
+ return null;
37
+ try {
38
+ return new Intl.DateTimeFormat(locales(locale), { dateStyle: 'long', timeZone: 'UTC' }).format(date);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ export function formatTime(text, locale) {
45
+ const match = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(text);
46
+ if (!match)
47
+ return null;
48
+ const seconds = Number(match[3] ?? '0');
49
+ const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2]), seconds));
50
+ if (Number.isNaN(date.getTime()) || date.getUTCHours() !== Number(match[1]))
51
+ return null;
52
+ try {
53
+ const options = seconds || match[4] ? { timeStyle: 'medium', timeZone: 'UTC' } : { timeStyle: 'short', timeZone: 'UTC' };
54
+ const formatted = new Intl.DateTimeFormat(locales(locale), options).format(date);
55
+ return match[4] && /[1-9]/.test(match[4]) ? `${formatted} (.${match[4].replace(/0+$/, '')})` : formatted;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function formatDateTime(text, locale) {
62
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(text))
63
+ return null;
64
+ const date = new Date(text);
65
+ if (Number.isNaN(date.getTime()))
66
+ return null;
67
+ try {
68
+ return new Intl.DateTimeFormat(locales(locale), {
69
+ year: 'numeric',
70
+ month: 'short',
71
+ day: 'numeric',
72
+ hour: 'numeric',
73
+ minute: '2-digit',
74
+ timeZoneName: 'short',
75
+ }).format(date);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ // Returns the localized rendering of a committed scalar, or null when the
82
+ // value is not a formattable scalar of that schema.
83
+ export function localized(value, schema, locale) {
84
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
85
+ const type = types.find(entry => entry !== 'null');
86
+ if (value instanceof JsonNumber && (type === 'number' || type === 'integer'))
87
+ return formatNumber(value.raw, locale);
88
+ if (typeof value !== 'string' || type !== 'string')
89
+ return null;
90
+ if (schema.format === 'date')
91
+ return formatDate(value, locale);
92
+ if (schema.format === 'forming-local-time')
93
+ return formatTime(value, locale);
94
+ if (schema.format === 'date-time')
95
+ return formatDateTime(value, locale);
96
+ return null;
97
+ }