@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 +86 -0
- package/NOTICE.md +18 -0
- package/README.md +118 -2
- package/dist/editors.d.ts +71 -0
- package/dist/editors.js +222 -0
- package/dist/format.d.ts +11 -0
- package/dist/format.js +97 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +790 -0
- package/dist/markdown.d.ts +3 -0
- package/dist/markdown.js +61 -0
- package/dist/styles.css +1371 -0
- package/package.json +46 -4
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
|
-
#
|
|
1
|
+
# Forming React renderer
|
|
2
2
|
|
|
3
|
-
|
|
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 {};
|
package/dist/editors.js
ADDED
|
@@ -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
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -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
|
+
}
|