@trunkjs/form 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # @trunkjs/form
2
+
3
+ > Die gepflegte Agent-Dokumentation liegt unter `skills/form-usage/SKILL.md`.
4
+
5
+ `@trunkjs/form` verwendet `FormDataAccessor` aus `@trunkjs/browser-utils` und ergänzt `<tj-form>` für global
6
+ registrierte Lifecycle-Callbacks. Verwende `data` zum Lesen/Schreiben, `entries` für direkten Elementzugriff und
7
+ `formData` zum Versenden. Ergänze keine Remote-Wrapper oder Value-Plugins, solange ein konkreter Control-Typ sie nicht
8
+ erfordert.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - `tj-form` auf eine kleine objektwertige `value`- und `getElements()`-API reduziert
6
+ - `FormDataAccessor` aus `@trunkjs/browser-utils` als gemeinsame DOM-Utility verwendet
7
+ - Benannte verschachtelte Forms werden als eigener Objektwert statt als doppelte flache Controls gelesen
8
+ - Bundle-übergreifende globale Preset-Registry für ein Default- oder benanntes Preset ergänzt
9
+ - `EnterNextPlugin` für konfigurierbare Validierung und Fokuswechsel per Enter ergänzt
10
+ - Native Form-, Fetch- und Validierungs-Lifecycle-Logik entfernt
11
+
12
+ ## 1.0.0
13
+
14
+ - Initiale Grundstruktur für `@trunkjs/form`
15
+ - Vite-Demo-Viewer-Setup ergänzt
16
+ - `FormScope` mit Plugin-System ergänzt
17
+ - Standard-Value-Plugins für `input`, `checkbox`, `radio`, `textarea` und `select` ergänzt
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @trunkjs/form
2
+
3
+ `<tj-form>` ist ein kleines Value-Element. Es sammelt die Werte benannter nativer oder kompatibler Custom Elements und
4
+ liefert sie über `value` immer als Objekt zurück. Validierung, Requests und weitere Abläufe gehören nicht zum Kern.
5
+
6
+ ## Value und verschachtelte Forms
7
+
8
+ ```html
9
+ <tj-form id="profile">
10
+ <input name="displayName" />
11
+ <tj-form name="address">
12
+ <input name="street" />
13
+ <input name="city" />
14
+ </tj-form>
15
+ </tj-form>
16
+ ```
17
+
18
+ ```ts
19
+ const form = document.querySelector<TjForm>('#profile')!;
20
+
21
+ form.value = {
22
+ displayName: 'Erika',
23
+ address: { street: 'Musterweg 1', city: 'Berlin' },
24
+ };
25
+
26
+ console.log(form.value);
27
+ // { displayName: 'Erika', address: { street: 'Musterweg 1', city: 'Berlin' } }
28
+ ```
29
+
30
+ Ein Element wird berücksichtigt, wenn es einen nicht leeren `name` und eine les-/schreibbare `value`-Property besitzt.
31
+ Damit funktionieren native Controls sowie kompatible Custom Elements wie `<nte-input>`. Ein benanntes Value-Element
32
+ besitzt seinen gesamten Wert; seine Unterelemente werden vom übergeordneten `FormDataAccessor` nicht erneut gelesen.
33
+
34
+ `entries` liefert dynamische Element-/Wert-Paare. `getElements()` gibt die aktuell sichtbaren Controls direkt zurück.
35
+
36
+ ## Globale Presets und Submit
37
+
38
+ Presets werden ausschließlich programmatisch registriert und im Markup über das Attribut `preset` ausgewählt. Die
39
+ Registry liegt auf `globalThis.__trunkjsFormRegistry`, sodass unabhängig gebaute Bundles dieselben Presets verwenden.
40
+
41
+ `registerFormPreset(preset)` registriert das Standard-Preset. Es gilt automatisch, wenn ein `<tj-form>` kein
42
+ `preset`-Attribut besitzt:
43
+
44
+ ```ts
45
+ import { enterNextPlugin, registerFormPreset } from '@trunkjs/form';
46
+
47
+ registerFormPreset({
48
+ value: { displayName: 'Erika' },
49
+ async onSubmit({ value, submitter, getElements }) {
50
+ getElements().forEach((element) => element.toggleAttribute('disabled', true));
51
+ await saveProfile(value, submitter);
52
+ },
53
+ });
54
+ ```
55
+
56
+ ```html
57
+ <tj-form>
58
+ <input name="displayName" required />
59
+ <button type="submit">Speichern</button>
60
+ </tj-form>
61
+ ```
62
+
63
+ Alternativ wird genau ein benanntes Preset ausgewählt:
64
+
65
+ ```ts
66
+ registerFormPreset('profile', {
67
+ value: { displayName: 'Erika' },
68
+ plugins: [enterNextPlugin()],
69
+ onSubmit({ value }) {
70
+ return saveProfile(value);
71
+ },
72
+ });
73
+ ```
74
+
75
+ ```html
76
+ <tj-form preset="profile">
77
+ <!-- controls -->
78
+ </tj-form>
79
+ ```
80
+
81
+ Ein fehlendes oder leeres `preset`-Attribut wählt das Standard-Preset. Es werden nie mehrere Presets für dasselbe
82
+ `<tj-form>` kombiniert.
83
+
84
+ `<tj-form>` erzeugt bewusst kein natives `<form>`. Ein Klick auf einen zugehörigen Submit-Button oder
85
+ `requestSubmit()` löst das abbrechbare Event `tj-form-submit` aus und ruft anschließend `onSubmit` auf. Buttons und
86
+ Events verschachtelter Forms bleiben bei ihrer nächstgelegenen Form.
87
+
88
+ `enterNextPlugin()` verhindert Submit per Enter, validiert das aktuelle Element und fokussiert bei Erfolg das nächste.
89
+ Für Custom Controls kann `validate` oder `focus` konfiguriert werden.
90
+
91
+ ## Demo lokal starten
92
+
93
+ ```bash
94
+ cd packages/form
95
+ npx vite
96
+ ```
97
+
98
+ Der Demo-Viewer ist anschließend unter `/__tdemo` verfügbar.
@@ -0,0 +1,36 @@
1
+ import { FormDataAccessorEntry } from '@trunkjs/browser-utils';
2
+ import { TjFormContext, TjFormRegistry } from '../../lib/TjFormRegistry';
3
+ export type TjFormSubmitDetail = TjFormContext;
4
+ /** A small named value container for native and custom form controls. */
5
+ export declare class TjForm extends HTMLElement {
6
+ registry: TjFormRegistry;
7
+ static get observedAttributes(): string[];
8
+ private readonly dataAccessor;
9
+ private unsubscribeRegistry;
10
+ private pluginCleanups;
11
+ private activePreset;
12
+ constructor(registry?: TjFormRegistry);
13
+ connectedCallback(): void;
14
+ disconnectedCallback(): void;
15
+ attributeChangedCallback(): void;
16
+ get name(): string;
17
+ set name(value: string);
18
+ get preset(): string;
19
+ set preset(value: string);
20
+ get value(): Record<string, unknown>;
21
+ set value(value: Record<string, unknown>);
22
+ get entries(): FormDataAccessorEntry[];
23
+ getElements(): HTMLElement[];
24
+ requestSubmit(submitter?: HTMLElement | null, sourceEvent?: Event | null): Promise<unknown>;
25
+ private readonly handleClick;
26
+ private isSubmitter;
27
+ private createContext;
28
+ private watchPreset;
29
+ private activatePreset;
30
+ private disconnectPlugins;
31
+ }
32
+ declare global {
33
+ interface HTMLElementTagNameMap {
34
+ 'tj-form': TjForm;
35
+ }
36
+ }
package/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './components/tj-form/tj-form';
2
+ export * from './lib/TjFormRegistry';
3
+ export * from './plugins/EnterNextPlugin';
package/index.js ADDED
@@ -0,0 +1,179 @@
1
+ import { FormDataAccessor as l } from "@trunkjs/browser-utils";
2
+ const u = "default";
3
+ class h {
4
+ constructor() {
5
+ this.presets = /* @__PURE__ */ new Map(), this.listeners = /* @__PURE__ */ new Map();
6
+ }
7
+ register(t, e) {
8
+ const s = this.normalizeName(t);
9
+ return this.presets.set(s, e), this.notify(s, e), this;
10
+ }
11
+ unregister(t) {
12
+ const e = t.trim(), s = this.presets.delete(e);
13
+ return s && this.notify(e, void 0), s;
14
+ }
15
+ get(t) {
16
+ return t ? this.presets.get(t.trim()) : void 0;
17
+ }
18
+ has(t) {
19
+ return this.presets.has(t.trim());
20
+ }
21
+ subscribe(t, e) {
22
+ const s = this.normalizeName(t), i = this.listeners.get(s) ?? /* @__PURE__ */ new Set();
23
+ return i.add(e), this.listeners.set(s, i), () => {
24
+ i.delete(e), i.size === 0 && this.listeners.delete(s);
25
+ };
26
+ }
27
+ normalizeName(t) {
28
+ const e = t.trim();
29
+ if (!e)
30
+ throw new Error("A form preset name must not be empty.");
31
+ return e;
32
+ }
33
+ notify(t, e) {
34
+ for (const s of this.listeners.get(t) ?? [])
35
+ s(e);
36
+ }
37
+ }
38
+ const c = globalThis.__trunkjsFormRegistry ?? (globalThis.__trunkjsFormRegistry = new h());
39
+ function g(n, t) {
40
+ if (typeof n == "string") {
41
+ if (!t)
42
+ throw new Error("A named form preset requires a preset definition.");
43
+ return c.register(n, t);
44
+ }
45
+ return c.register(u, n);
46
+ }
47
+ class d extends HTMLElement {
48
+ constructor(t = c) {
49
+ super(), this.registry = t, this.dataAccessor = new l(this), this.unsubscribeRegistry = null, this.pluginCleanups = [], this.handleClick = (e) => {
50
+ const s = e.target instanceof Element ? e.target : null, i = s == null ? void 0 : s.closest('button, input, [type="submit"]');
51
+ !i || i.closest("tj-form") !== this || !this.isSubmitter(i) || (e.preventDefault(), this.requestSubmit(i, e));
52
+ };
53
+ }
54
+ static get observedAttributes() {
55
+ return ["preset"];
56
+ }
57
+ connectedCallback() {
58
+ this.addEventListener("click", this.handleClick), this.watchPreset();
59
+ }
60
+ disconnectedCallback() {
61
+ var t;
62
+ this.removeEventListener("click", this.handleClick), (t = this.unsubscribeRegistry) == null || t.call(this), this.unsubscribeRegistry = null, this.disconnectPlugins();
63
+ }
64
+ attributeChangedCallback() {
65
+ this.isConnected && this.watchPreset();
66
+ }
67
+ get name() {
68
+ return this.getAttribute("name") ?? "";
69
+ }
70
+ set name(t) {
71
+ t ? this.setAttribute("name", t) : this.removeAttribute("name");
72
+ }
73
+ get preset() {
74
+ var t;
75
+ return ((t = this.getAttribute("preset")) == null ? void 0 : t.trim()) || u;
76
+ }
77
+ set preset(t) {
78
+ t && t !== u ? this.setAttribute("preset", t) : this.removeAttribute("preset");
79
+ }
80
+ get value() {
81
+ return this.dataAccessor.data;
82
+ }
83
+ set value(t) {
84
+ this.dataAccessor.data = t;
85
+ }
86
+ get entries() {
87
+ return this.dataAccessor.entries;
88
+ }
89
+ getElements() {
90
+ return this.entries.map(({ element: t }) => t);
91
+ }
92
+ async requestSubmit(t = null, e = null) {
93
+ var r, o;
94
+ const s = this.createContext(t, e);
95
+ if (this.dispatchEvent(
96
+ new CustomEvent("tj-form-submit", {
97
+ bubbles: !0,
98
+ cancelable: !0,
99
+ detail: s
100
+ })
101
+ ))
102
+ return (o = (r = this.activePreset) == null ? void 0 : r.onSubmit) == null ? void 0 : o.call(r, s);
103
+ }
104
+ isSubmitter(t) {
105
+ var e;
106
+ return t instanceof HTMLButtonElement ? (t.getAttribute("type") ?? "submit").toLowerCase() === "submit" : t instanceof HTMLInputElement ? ["submit", "image"].includes(t.type.toLowerCase()) : ((e = t.getAttribute("type")) == null ? void 0 : e.toLowerCase()) === "submit";
107
+ }
108
+ createContext(t, e) {
109
+ return {
110
+ form: this,
111
+ submitter: t,
112
+ sourceEvent: e,
113
+ value: this.value,
114
+ getElements: () => this.getElements()
115
+ };
116
+ }
117
+ watchPreset() {
118
+ var t;
119
+ (t = this.unsubscribeRegistry) == null || t.call(this), this.unsubscribeRegistry = this.registry.subscribe(this.preset, (e) => this.activatePreset(e)), this.activatePreset(this.registry.get(this.preset));
120
+ }
121
+ activatePreset(t) {
122
+ if (this.disconnectPlugins(), this.activePreset = t, !!t) {
123
+ t.value && (this.value = t.value);
124
+ for (const e of t.plugins ?? []) {
125
+ const s = e.connect(this);
126
+ s && this.pluginCleanups.push(s);
127
+ }
128
+ }
129
+ }
130
+ disconnectPlugins() {
131
+ this.pluginCleanups.splice(0).forEach((t) => t());
132
+ }
133
+ }
134
+ typeof customElements < "u" && !customElements.get("tj-form") && customElements.define("tj-form", d);
135
+ class m {
136
+ constructor(t = {}) {
137
+ this.options = t;
138
+ }
139
+ connect(t) {
140
+ const e = (s) => {
141
+ if (s.key !== "Enter" || s.defaultPrevented || s.isComposing)
142
+ return;
143
+ const i = s.target instanceof HTMLElement ? s.target : null;
144
+ if (!i || i.closest("tj-form") !== t || this.isMultiline(i))
145
+ return;
146
+ const r = t.getElements().filter((a) => !this.isDisabled(a)), o = r.find((a) => a === i || a.contains(i));
147
+ o && (s.preventDefault(), this.moveNext(t, o, r));
148
+ };
149
+ return t.addEventListener("keydown", e), () => t.removeEventListener("keydown", e);
150
+ }
151
+ async moveNext(t, e, s) {
152
+ if (!(this.options.validate ? await this.options.validate(e, t) : this.validate(e)))
153
+ return;
154
+ const r = s[s.indexOf(e) + 1];
155
+ r && (this.options.focus ?? ((o) => o.focus()))(r);
156
+ }
157
+ validate(t) {
158
+ const e = t;
159
+ return typeof e.reportValidity == "function" ? e.reportValidity() : typeof e.checkValidity == "function" ? e.checkValidity() : !t.matches(":invalid");
160
+ }
161
+ isDisabled(t) {
162
+ return t.hasAttribute("disabled") || t.matches(":disabled");
163
+ }
164
+ isMultiline(t) {
165
+ return t instanceof HTMLTextAreaElement || t.getAttribute("contenteditable") === "true";
166
+ }
167
+ }
168
+ function b(n = {}) {
169
+ return new m(n);
170
+ }
171
+ export {
172
+ u as DEFAULT_FORM_PRESET,
173
+ m as EnterNextPlugin,
174
+ d as TjForm,
175
+ h as TjFormRegistry,
176
+ b as enterNextPlugin,
177
+ g as registerFormPreset,
178
+ c as tjFormRegistry
179
+ };
@@ -0,0 +1,36 @@
1
+ import { TjForm } from '../components/tj-form/tj-form';
2
+ export interface TjFormContext {
3
+ readonly form: TjForm;
4
+ readonly submitter: HTMLElement | null;
5
+ readonly sourceEvent: Event | null;
6
+ readonly value: Record<string, unknown>;
7
+ getElements(): HTMLElement[];
8
+ }
9
+ export interface TjFormPlugin {
10
+ connect(form: TjForm): void | (() => void);
11
+ }
12
+ export interface TjFormPreset {
13
+ value?: Record<string, unknown>;
14
+ plugins?: readonly TjFormPlugin[];
15
+ onSubmit?: (context: TjFormContext) => unknown | Promise<unknown>;
16
+ }
17
+ export type TjFormRegistryListener = (preset: TjFormPreset | undefined) => void;
18
+ export declare const DEFAULT_FORM_PRESET = "default";
19
+ /** Stores reusable form values, submit callbacks, and opt-in plugins. */
20
+ export declare class TjFormRegistry {
21
+ private readonly presets;
22
+ private readonly listeners;
23
+ register(name: string, preset: TjFormPreset): this;
24
+ unregister(name: string): boolean;
25
+ get(name: string | null | undefined): TjFormPreset | undefined;
26
+ has(name: string): boolean;
27
+ subscribe(name: string, listener: TjFormRegistryListener): () => void;
28
+ private normalizeName;
29
+ private notify;
30
+ }
31
+ declare global {
32
+ var __trunkjsFormRegistry: TjFormRegistry | undefined;
33
+ }
34
+ export declare const tjFormRegistry: TjFormRegistry;
35
+ export declare function registerFormPreset(preset: TjFormPreset): TjFormRegistry;
36
+ export declare function registerFormPreset(name: string, preset: TjFormPreset): TjFormRegistry;
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@trunkjs/form",
3
+ "version": "1.0.0",
4
+ "main": "./index.js",
5
+ "repository": {
6
+ "directory": "packages/form",
7
+ "type": "git",
8
+ "url": "git+https://github.com/trunkjs/trunkjs-monorepo.git"
9
+ },
10
+ "dependencies": {
11
+ "@trunkjs/browser-utils": "*"
12
+ },
13
+ "devDependencies": {
14
+ "@trunkjs/demo-viewer": "*",
15
+ "@trunkjs/vite-demo-viewer": "*"
16
+ },
17
+ "type": "module",
18
+ "types": "./index.d.ts"
19
+ }
@@ -0,0 +1,17 @@
1
+ import { TjForm } from '../components/tj-form/tj-form';
2
+ import { TjFormPlugin } from '../lib/TjFormRegistry';
3
+ export interface EnterNextPluginOptions {
4
+ validate?: (element: HTMLElement, form: TjForm) => boolean | Promise<boolean>;
5
+ focus?: (element: HTMLElement) => void;
6
+ }
7
+ /** Moves Enter to the next named control after the current control validates. */
8
+ export declare class EnterNextPlugin implements TjFormPlugin {
9
+ private readonly options;
10
+ constructor(options?: EnterNextPluginOptions);
11
+ connect(form: TjForm): () => void;
12
+ private moveNext;
13
+ private validate;
14
+ private isDisabled;
15
+ private isMultiline;
16
+ }
17
+ export declare function enterNextPlugin(options?: EnterNextPluginOptions): EnterNextPlugin;
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: form-usage
3
+ description: Use @trunkjs/form TjForm and <tj-form> (programmatic API and element), TjFormRegistry and registerFormPreset() (programmatic APIs), and EnterNextPlugin and enterNextPlugin() (programmatic APIs).
4
+ ---
5
+
6
+ # @trunkjs/form usage
7
+
8
+ - `TjForm` / `<tj-form>` — Named object-valued container for native and custom controls, nested forms, element access,
9
+ and custom submit dispatch. See [README](../../README.md) and [demo](../../demo/basic.demo.ts).
10
+ - `TjFormRegistry` / `registerFormPreset()` — Registers a globally shared default or named preset containing initial
11
+ values, a submit callback, and plugins for declarative `preset` selection. See [README](../../README.md).
12
+ - `EnterNextPlugin` / `enterNextPlugin()` — Opt-in Enter validation and focus-next behavior. See
13
+ [demo](../../demo/basic.demo.ts).