@getaster/elements 0.3.5 → 0.4.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/NOTICE +4 -0
- package/README.md +13 -4
- package/bin/aster-theme-check.js +35 -4
- package/dist/base/element.d.ts +8 -1
- package/dist/base/element.js +7 -2
- package/dist/button/button.d.ts +17 -2
- package/dist/button/button.js +42 -8
- package/dist/composition/builtins.d.ts +623 -1
- package/dist/composition/builtins.js +232 -4
- package/dist/date/calendar.d.ts +3 -0
- package/dist/date/calendar.js +8 -2
- package/dist/date/date-field.d.ts +23 -3
- package/dist/date/date-field.js +77 -9
- package/dist/date/dates.d.ts +10 -2
- package/dist/date/dates.js +23 -3
- package/dist/dialog/config.d.ts +4 -0
- package/dist/dialog/config.js +8 -0
- package/dist/dialog/dialog.d.ts +34 -6
- package/dist/dialog/dialog.js +151 -14
- package/dist/field/bound-field.d.ts +17 -1
- package/dist/field/bound-field.js +68 -7
- package/dist/field/check.js +1 -0
- package/dist/field/field.js +1 -1
- package/dist/field/input.d.ts +1 -0
- package/dist/field/input.js +2 -2
- package/dist/field/number-field.js +2 -2
- package/dist/filter/filter-builder.d.ts +7 -1
- package/dist/filter/filter-builder.js +8 -1
- package/dist/form/controls.d.ts +40 -8
- package/dist/form/controls.js +58 -15
- package/dist/form/custom-field.d.ts +1 -0
- package/dist/form/custom-field.js +23 -0
- package/dist/form/features.d.ts +27 -0
- package/dist/form/features.js +52 -0
- package/dist/form/form.d.ts +67 -7
- package/dist/form/form.js +267 -24
- package/dist/form/policy.d.ts +29 -0
- package/dist/form/policy.js +16 -0
- package/dist/grid/appearance.d.ts +365 -0
- package/dist/grid/appearance.js +814 -0
- package/dist/grid/features.d.ts +38 -0
- package/dist/grid/features.js +78 -0
- package/dist/grid/grid.d.ts +216 -4
- package/dist/grid/grid.js +1810 -308
- package/dist/grid/policy.d.ts +58 -0
- package/dist/grid/policy.js +17 -0
- package/dist/grid/view.d.ts +32 -2
- package/dist/grid/view.js +89 -4
- package/dist/index.d.ts +14 -11
- package/dist/index.js +2 -1
- package/dist/menu/config.d.ts +7 -0
- package/dist/menu/config.js +44 -0
- package/dist/menu/menu.d.ts +23 -0
- package/dist/menu/menu.js +69 -17
- package/dist/picker/picker.js +8 -3
- package/dist/section/accordion.d.ts +26 -0
- package/dist/section/accordion.js +86 -8
- package/dist/section/policy.d.ts +8 -0
- package/dist/section/policy.js +17 -0
- package/dist/section/section.d.ts +30 -0
- package/dist/section/section.js +106 -3
- package/dist/select/choice-element.d.ts +12 -0
- package/dist/select/choice-element.js +36 -0
- package/dist/select/choice.d.ts +12 -2
- package/dist/select/choice.js +93 -28
- package/dist/select/select.d.ts +12 -0
- package/dist/select/select.js +37 -1
- package/dist/styles/contract.d.ts +14 -0
- package/dist/styles/contract.js +15 -0
- package/dist/switch/switch.d.ts +16 -0
- package/dist/switch/switch.js +56 -6
- package/dist/tree/navigator.d.ts +3 -0
- package/dist/tree/navigator.js +26 -2
- package/dist/window/chrome.d.ts +5 -1
- package/dist/window/chrome.js +4 -3
- package/dist/window/config.d.ts +71 -0
- package/dist/window/config.js +40 -0
- package/dist/window/window.d.ts +51 -13
- package/dist/window/window.js +233 -38
- package/dist/yaml.d.ts +19 -0
- package/dist/yaml.js +57 -0
- package/package.json +10 -4
- package/styles/aster-ui.css +181 -10
- package/styles/base.css +14 -0
- package/styles/contract.ts +16 -0
- package/styles/elements.css +167 -10
- package/styles/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/styles/fonts/OFL-fraunces.txt +93 -0
- package/styles/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
- package/styles/fonts/fraunces-latin-full-normal.woff2 +0 -0
- package/styles/fonts.css +17 -0
- package/styles/theme-harbour.css +5 -0
- package/styles/theme-lamplight.css +5 -0
package/dist/form/controls.js
CHANGED
|
@@ -6,29 +6,72 @@ import "../field/field.js";
|
|
|
6
6
|
import "../field/number-field.js";
|
|
7
7
|
import "../picker/picker.js";
|
|
8
8
|
import "../select/select.js";
|
|
9
|
+
import "./custom-field.js";
|
|
9
10
|
const NONE = new Map();
|
|
11
|
+
/** The control metadata picks for a field, before any override. */
|
|
12
|
+
export function metadataControl(model, name, options) {
|
|
13
|
+
if (options)
|
|
14
|
+
return "picker";
|
|
15
|
+
const field = model.type.field(name);
|
|
16
|
+
if (field?.type === "Edm.Boolean")
|
|
17
|
+
return "check";
|
|
18
|
+
if (field?.allowedValues)
|
|
19
|
+
return "select";
|
|
20
|
+
switch (field?.type) {
|
|
21
|
+
case "Edm.Date":
|
|
22
|
+
return "date";
|
|
23
|
+
case "Edm.Byte":
|
|
24
|
+
case "Edm.SByte":
|
|
25
|
+
case "Edm.Int16":
|
|
26
|
+
case "Edm.Int32":
|
|
27
|
+
case "Edm.Int64":
|
|
28
|
+
case "Edm.Decimal":
|
|
29
|
+
case "Edm.Single":
|
|
30
|
+
case "Edm.Double":
|
|
31
|
+
return "number";
|
|
32
|
+
default:
|
|
33
|
+
return "text";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
10
36
|
/**
|
|
11
37
|
* The control for one field, chosen from metadata: a foreign key with
|
|
12
38
|
* `options` gives a picker, a Boolean a checkbox, allowed values a select,
|
|
13
|
-
* a Date the date field, anything else a field.
|
|
39
|
+
* a Date the date field, anything else a field. `control` replaces that
|
|
40
|
+
* choice with a named editor, or with a renderer that draws its own.
|
|
14
41
|
*/
|
|
15
42
|
export function controlFor(model, name, opts) {
|
|
16
43
|
const style = styleMap(opts.span ? { "--span": String(opts.span) } : {});
|
|
17
44
|
const shared = opts.sharedReasons ?? NONE;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return html `<aster-date style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-date>`;
|
|
45
|
+
const labelled = opts.labelled;
|
|
46
|
+
const label = opts.title ?? "";
|
|
47
|
+
const hint = opts.hint ?? "";
|
|
48
|
+
const placeholder = opts.placeholder ?? "";
|
|
49
|
+
const showErrors = opts.showErrors ?? true;
|
|
50
|
+
const validateOn = opts.validateOn ?? "submit";
|
|
51
|
+
const control = typeof opts.control === "function" ? opts.control : opts.control ?? metadataControl(model, name, opts.options);
|
|
52
|
+
if (typeof control === "function") {
|
|
53
|
+
return html `<aster-custom-field style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
54
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn} .renderControl=${control}></aster-custom-field>`;
|
|
29
55
|
}
|
|
30
|
-
|
|
31
|
-
|
|
56
|
+
switch (control) {
|
|
57
|
+
case "picker":
|
|
58
|
+
return html `<aster-picker style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
59
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn}
|
|
60
|
+
.placeholder=${placeholder} .options=${opts.options}></aster-picker>`;
|
|
61
|
+
case "check":
|
|
62
|
+
return html `<aster-check style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
63
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn}></aster-check>`;
|
|
64
|
+
case "select":
|
|
65
|
+
return html `<aster-select style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
66
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn} .placeholder=${placeholder}></aster-select>`;
|
|
67
|
+
case "date":
|
|
68
|
+
return html `<aster-date style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
69
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn} .placeholder=${placeholder}></aster-date>`;
|
|
70
|
+
case "number":
|
|
71
|
+
return html `<aster-number-field style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
72
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn} .placeholder=${placeholder}></aster-number-field>`;
|
|
73
|
+
case "text":
|
|
74
|
+
return html `<aster-field style=${style} .model=${model} .field=${name} .labelled=${labelled} .sharedReasons=${shared}
|
|
75
|
+
.label=${label} .hint=${hint} .showErrors=${showErrors} .validateOn=${validateOn} .placeholder=${placeholder}></aster-field>`;
|
|
32
76
|
}
|
|
33
|
-
return html `<aster-field style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-field>`;
|
|
34
77
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { nothing } from "lit-html";
|
|
2
|
+
import { define } from "../base/element.js";
|
|
3
|
+
import { BoundField } from "../field/bound-field.js";
|
|
4
|
+
/**
|
|
5
|
+
* A field whose editor is a renderer supplied by the page. The frame, label,
|
|
6
|
+
* hint, error and state handling stay [[class:BoundField]]'s; the renderer
|
|
7
|
+
* gets the control context and a write that respects read-only and disabled.
|
|
8
|
+
* Internal: [[object:FormField.control]] is the public surface.
|
|
9
|
+
*/
|
|
10
|
+
class CustomField extends BoundField {
|
|
11
|
+
#renderControl = this.prop(undefined);
|
|
12
|
+
get renderControl() {
|
|
13
|
+
return this.#renderControl.value;
|
|
14
|
+
}
|
|
15
|
+
set renderControl(v) {
|
|
16
|
+
this.#renderControl.value = v;
|
|
17
|
+
}
|
|
18
|
+
control(c) {
|
|
19
|
+
const render = this.#renderControl.value;
|
|
20
|
+
return render ? render({ ...c, write: (value) => this.write(value) }) : nothing;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
define("aster-custom-field", CustomField);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the form behaves. A descriptor says how, never whether: switching a
|
|
3
|
+
* presentation feature off is [[object:FormChrome]]'s job. Plain data, so a
|
|
4
|
+
* composition catalog can carry it.
|
|
5
|
+
*/
|
|
6
|
+
export type FormFeature = {
|
|
7
|
+
readonly kind: "validation";
|
|
8
|
+
readonly on?: "change" | "exit";
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "save";
|
|
11
|
+
};
|
|
12
|
+
/** When a field revalidates: only when the host validates, on change, or on exit. */
|
|
13
|
+
export type ValidateOn = "submit" | "change" | "exit";
|
|
14
|
+
/** One descriptor of `kind`, if the page gave one. */
|
|
15
|
+
export declare function formFeature<K extends FormFeature["kind"]>(features: readonly FormFeature[] | undefined, kind: K): Extract<FormFeature, {
|
|
16
|
+
kind: K;
|
|
17
|
+
}> | undefined;
|
|
18
|
+
/** The features with `next` replacing the first descriptor of its kind, or appended. */
|
|
19
|
+
export declare function withFormFeature(features: readonly FormFeature[] | undefined, next: FormFeature): FormFeature[];
|
|
20
|
+
/** The features without the descriptor of `kind`. */
|
|
21
|
+
export declare function withoutFormFeature(features: readonly FormFeature[] | undefined, kind: FormFeature["kind"]): FormFeature[];
|
|
22
|
+
/** The validation timing; absent means validation happens when the host validates or saves. */
|
|
23
|
+
export declare function validationOn(features: readonly FormFeature[] | undefined): ValidateOn;
|
|
24
|
+
/** Whether Enter in a single-line field saves the model. */
|
|
25
|
+
export declare function savesOnEnter(features: readonly FormFeature[] | undefined): boolean;
|
|
26
|
+
/** Checks a features value: known kinds, plain fields and valid unions. */
|
|
27
|
+
export declare function validateFormFeatures(value: unknown): asserts value is readonly FormFeature[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** One descriptor of `kind`, if the page gave one. */
|
|
2
|
+
export function formFeature(features, kind) {
|
|
3
|
+
return features?.find((entry) => entry.kind === kind);
|
|
4
|
+
}
|
|
5
|
+
/** The features with `next` replacing the first descriptor of its kind, or appended. */
|
|
6
|
+
export function withFormFeature(features, next) {
|
|
7
|
+
const list = [...(features ?? [])];
|
|
8
|
+
const at = list.findIndex((entry) => entry.kind === next.kind);
|
|
9
|
+
if (at < 0)
|
|
10
|
+
list.push(next);
|
|
11
|
+
else
|
|
12
|
+
list[at] = next;
|
|
13
|
+
return list;
|
|
14
|
+
}
|
|
15
|
+
/** The features without the descriptor of `kind`. */
|
|
16
|
+
export function withoutFormFeature(features, kind) {
|
|
17
|
+
return (features ?? []).filter((entry) => entry.kind !== kind);
|
|
18
|
+
}
|
|
19
|
+
/** The validation timing; absent means validation happens when the host validates or saves. */
|
|
20
|
+
export function validationOn(features) {
|
|
21
|
+
return formFeature(features, "validation")?.on ?? "submit";
|
|
22
|
+
}
|
|
23
|
+
/** Whether Enter in a single-line field saves the model. */
|
|
24
|
+
export function savesOnEnter(features) {
|
|
25
|
+
return formFeature(features, "save") !== undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Checks a features value: known kinds, plain fields and valid unions. */
|
|
28
|
+
export function validateFormFeatures(value) {
|
|
29
|
+
if (!Array.isArray(value))
|
|
30
|
+
throw new TypeError("features must be an array");
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
for (const entry of value) {
|
|
33
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
34
|
+
throw new TypeError("each feature must be a plain object");
|
|
35
|
+
const kind = entry.kind;
|
|
36
|
+
if (kind !== "validation" && kind !== "save")
|
|
37
|
+
throw new TypeError(`unknown feature kind: ${String(kind)}`);
|
|
38
|
+
if (seen.has(kind))
|
|
39
|
+
throw new TypeError(`feature "${kind}" appears more than once`);
|
|
40
|
+
seen.add(kind);
|
|
41
|
+
switch (kind) {
|
|
42
|
+
case "validation": {
|
|
43
|
+
const on = entry.on;
|
|
44
|
+
if (on !== undefined && on !== "change" && on !== "exit")
|
|
45
|
+
throw new TypeError('validation.on must be "change" or "exit"');
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "save":
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/form/form.d.ts
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
import type { Collection, EditModel } from "@getaster/data";
|
|
2
|
-
import type
|
|
2
|
+
import { type ReadonlySignal } from "@preact/signals-core";
|
|
3
3
|
import type { PickerOptions } from "../picker/options.ts";
|
|
4
4
|
import { AsterElement, type View } from "../base/element.ts";
|
|
5
5
|
import "../field/field.ts";
|
|
6
6
|
import "../section/section.ts";
|
|
7
|
+
import { type FormControl } from "./controls.ts";
|
|
8
|
+
import { type FormFeature, type ValidateOn } from "./features.ts";
|
|
9
|
+
import { type FormChrome, type FormPolicy } from "./policy.ts";
|
|
10
|
+
export type { FormFeature } from "./features.ts";
|
|
11
|
+
export type { FormChrome, FormPolicy } from "./policy.ts";
|
|
7
12
|
/** A field of the model, optionally spanning columns; `options` makes a foreign key a picker. */
|
|
8
13
|
export interface FormField {
|
|
9
14
|
readonly field: string;
|
|
10
15
|
readonly span?: number;
|
|
11
16
|
readonly options?: Collection | PickerOptions;
|
|
17
|
+
/** A label override; default: the field's title from metadata. */
|
|
18
|
+
readonly title?: string;
|
|
19
|
+
/** Help text under the control. */
|
|
20
|
+
readonly hint?: string;
|
|
21
|
+
/** Placeholder text, for the controls that have one. */
|
|
22
|
+
readonly placeholder?: string;
|
|
23
|
+
/** Starts read-only unless a page rule names read-only. */
|
|
24
|
+
readonly readOnly?: boolean;
|
|
25
|
+
/** Starts disabled unless a page rule names enabled. */
|
|
26
|
+
readonly disabled?: boolean;
|
|
27
|
+
/** Starts required unless a page rule names required. */
|
|
28
|
+
readonly required?: boolean;
|
|
29
|
+
/** A typed editor override, or a renderer for a custom control (JavaScript only). */
|
|
30
|
+
readonly control?: FormControl;
|
|
12
31
|
}
|
|
13
32
|
/** A value shown read-only, such as a figure derived from other fields. */
|
|
14
33
|
export interface FormValue {
|
|
@@ -24,12 +43,16 @@ export interface FormSection {
|
|
|
24
43
|
readonly open?: boolean;
|
|
25
44
|
readonly items: readonly FormItem[];
|
|
26
45
|
}
|
|
46
|
+
/** What the cancelable `form-submit` event carries. */
|
|
47
|
+
export interface FormSubmitDetail {
|
|
48
|
+
readonly model: EditModel;
|
|
49
|
+
}
|
|
27
50
|
/**
|
|
28
|
-
* Fields laid out from metadata. The app gives
|
|
29
|
-
* (sections, columns, spans)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
51
|
+
* Fields laid out from metadata. The app gives the arrangement
|
|
52
|
+
* (sections, columns, spans) and may override a field's label, hint,
|
|
53
|
+
* placeholder or editor; each field's control, type, allowed values and
|
|
54
|
+
* state still come from the model and its metadata. Static read-only,
|
|
55
|
+
* disabled and required values are defaults that page rules override.
|
|
33
56
|
*/
|
|
34
57
|
export declare class Form extends AsterElement {
|
|
35
58
|
#private;
|
|
@@ -40,7 +63,44 @@ export declare class Form extends AsterElement {
|
|
|
40
63
|
/** The sections' heading level in the page outline. */
|
|
41
64
|
get headingLevel(): 2 | 3 | 4;
|
|
42
65
|
set headingLevel(v: 2 | 3 | 4);
|
|
43
|
-
/**
|
|
66
|
+
/** Static edit defaults for the whole form, as [[object:FormPolicy]]. Default `{}`. */
|
|
67
|
+
get policy(): FormPolicy;
|
|
68
|
+
set policy(v: FormPolicy);
|
|
69
|
+
/** Sugar for `policy.readOnly`. Default `false`. */
|
|
70
|
+
get readOnly(): boolean;
|
|
71
|
+
set readOnly(v: boolean);
|
|
72
|
+
/** Sugar for `policy.disabled`. Default `false`. */
|
|
73
|
+
get disabled(): boolean;
|
|
74
|
+
set disabled(v: boolean);
|
|
75
|
+
/** Which presentation surfaces the form shows, as [[object:FormChrome]]. Default `{}`. */
|
|
76
|
+
get chrome(): FormChrome;
|
|
77
|
+
set chrome(v: FormChrome);
|
|
78
|
+
/** Sugar for `chrome.columns`: columns for sections that don't set their own. Default `2`. */
|
|
79
|
+
get columns(): number;
|
|
80
|
+
set columns(v: number);
|
|
81
|
+
/** Sugar for `chrome.titleOrientation`: labels above (`"top"`) or beside (`"start"`) their controls. Default `"top"`. */
|
|
82
|
+
get titleOrientation(): "top" | "start";
|
|
83
|
+
set titleOrientation(v: "top" | "start");
|
|
84
|
+
/** Sugar for `chrome.showInlineErrors`. Default `true`. */
|
|
85
|
+
get showInlineErrors(): boolean;
|
|
86
|
+
set showInlineErrors(v: boolean);
|
|
87
|
+
/** Sugar for `chrome.errorSummary`. Default `false`. */
|
|
88
|
+
get errorSummary(): boolean;
|
|
89
|
+
set errorSummary(v: boolean);
|
|
90
|
+
/** Sugar for `chrome.autoFocus`. Default `false`. */
|
|
91
|
+
get autoFocus(): boolean;
|
|
92
|
+
set autoFocus(v: boolean);
|
|
93
|
+
/** How the form behaves, as [[type:FormFeature]] descriptors. Default `[]`. */
|
|
94
|
+
get features(): readonly FormFeature[];
|
|
95
|
+
set features(v: readonly FormFeature[]);
|
|
96
|
+
/** Sugar for a `validation` feature's timing: `"submit"` (default), `"change"` or `"exit"`. */
|
|
97
|
+
get validateOn(): ValidateOn;
|
|
98
|
+
set validateOn(v: ValidateOn);
|
|
99
|
+
/** Sugar for a `save` feature: Enter in a single-line field saves. Default `false`. */
|
|
100
|
+
get saveOnEnter(): boolean;
|
|
101
|
+
set saveOnEnter(v: boolean);
|
|
102
|
+
connectedCallback(): void;
|
|
103
|
+
/** Focuses the first field with an error, or the first editable field. */
|
|
44
104
|
focusFirst(): void;
|
|
45
105
|
protected render(): View;
|
|
46
106
|
}
|
package/dist/form/form.js
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
|
+
import { effect } from "@preact/signals-core";
|
|
1
2
|
import { html, nothing } from "lit-html";
|
|
2
3
|
import { styleMap } from "lit-html/directives/style-map.js";
|
|
3
4
|
import { AsterElement, define, uid } from "../base/element.js";
|
|
4
5
|
import "../field/field.js";
|
|
5
6
|
import "../section/section.js";
|
|
6
7
|
import { controlFor } from "./controls.js";
|
|
8
|
+
import { savesOnEnter, validationOn, validateFormFeatures, withFormFeature, withoutFormFeature, } from "./features.js";
|
|
9
|
+
import { autoFocusOnError, errorSummaryShown, formColumns, inlineErrorsShown, titleOrientation, } from "./policy.js";
|
|
7
10
|
/**
|
|
8
|
-
* Fields laid out from metadata. The app gives
|
|
9
|
-
* (sections, columns, spans)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Fields laid out from metadata. The app gives the arrangement
|
|
12
|
+
* (sections, columns, spans) and may override a field's label, hint,
|
|
13
|
+
* placeholder or editor; each field's control, type, allowed values and
|
|
14
|
+
* state still come from the model and its metadata. Static read-only,
|
|
15
|
+
* disabled and required values are defaults that page rules override.
|
|
13
16
|
*/
|
|
14
17
|
export class Form extends AsterElement {
|
|
15
18
|
#shared = new Map();
|
|
16
19
|
#model = this.prop(undefined);
|
|
17
20
|
#sections = this.prop([]);
|
|
18
21
|
#level = this.prop(2);
|
|
22
|
+
#policy = this.prop({});
|
|
23
|
+
#chrome = this.prop({});
|
|
24
|
+
#features = this.prop([]);
|
|
19
25
|
#id = uid("aster-form");
|
|
20
26
|
get model() {
|
|
21
27
|
return this.#model.value;
|
|
@@ -36,44 +42,276 @@ export class Form extends AsterElement {
|
|
|
36
42
|
set headingLevel(v) {
|
|
37
43
|
this.#level.value = v;
|
|
38
44
|
}
|
|
39
|
-
/**
|
|
45
|
+
/** Static edit defaults for the whole form, as [[object:FormPolicy]]. Default `{}`. */
|
|
46
|
+
get policy() {
|
|
47
|
+
return this.#policy.value;
|
|
48
|
+
}
|
|
49
|
+
set policy(v) {
|
|
50
|
+
this.#policy.value = v;
|
|
51
|
+
}
|
|
52
|
+
/** Sugar for `policy.readOnly`. Default `false`. */
|
|
53
|
+
get readOnly() {
|
|
54
|
+
return this.#policy.value.readOnly === true;
|
|
55
|
+
}
|
|
56
|
+
set readOnly(v) {
|
|
57
|
+
this.#policy.value = { ...this.#policy.peek(), readOnly: v };
|
|
58
|
+
}
|
|
59
|
+
/** Sugar for `policy.disabled`. Default `false`. */
|
|
60
|
+
get disabled() {
|
|
61
|
+
return this.#policy.value.disabled === true;
|
|
62
|
+
}
|
|
63
|
+
set disabled(v) {
|
|
64
|
+
this.#policy.value = { ...this.#policy.peek(), disabled: v };
|
|
65
|
+
}
|
|
66
|
+
/** Which presentation surfaces the form shows, as [[object:FormChrome]]. Default `{}`. */
|
|
67
|
+
get chrome() {
|
|
68
|
+
return this.#chrome.value;
|
|
69
|
+
}
|
|
70
|
+
set chrome(v) {
|
|
71
|
+
this.#chrome.value = v;
|
|
72
|
+
}
|
|
73
|
+
/** Sugar for `chrome.columns`: columns for sections that don't set their own. Default `2`. */
|
|
74
|
+
get columns() {
|
|
75
|
+
return formColumns(this.#chrome.value);
|
|
76
|
+
}
|
|
77
|
+
set columns(v) {
|
|
78
|
+
if (!Number.isSafeInteger(v) || v < 1)
|
|
79
|
+
throw new TypeError("columns must be a positive integer");
|
|
80
|
+
this.#chrome.value = { ...this.#chrome.peek(), columns: v };
|
|
81
|
+
}
|
|
82
|
+
/** Sugar for `chrome.titleOrientation`: labels above (`"top"`) or beside (`"start"`) their controls. Default `"top"`. */
|
|
83
|
+
get titleOrientation() {
|
|
84
|
+
return titleOrientation(this.#chrome.value);
|
|
85
|
+
}
|
|
86
|
+
set titleOrientation(v) {
|
|
87
|
+
if (v !== "top" && v !== "start")
|
|
88
|
+
throw new TypeError('titleOrientation must be "top" or "start"');
|
|
89
|
+
this.#chrome.value = { ...this.#chrome.peek(), titleOrientation: v };
|
|
90
|
+
}
|
|
91
|
+
/** Sugar for `chrome.showInlineErrors`. Default `true`. */
|
|
92
|
+
get showInlineErrors() {
|
|
93
|
+
return inlineErrorsShown(this.#chrome.value);
|
|
94
|
+
}
|
|
95
|
+
set showInlineErrors(v) {
|
|
96
|
+
this.#chrome.value = { ...this.#chrome.peek(), showInlineErrors: v };
|
|
97
|
+
}
|
|
98
|
+
/** Sugar for `chrome.errorSummary`. Default `false`. */
|
|
99
|
+
get errorSummary() {
|
|
100
|
+
return errorSummaryShown(this.#chrome.value);
|
|
101
|
+
}
|
|
102
|
+
set errorSummary(v) {
|
|
103
|
+
this.#chrome.value = { ...this.#chrome.peek(), errorSummary: v };
|
|
104
|
+
}
|
|
105
|
+
/** Sugar for `chrome.autoFocus`. Default `false`. */
|
|
106
|
+
get autoFocus() {
|
|
107
|
+
return autoFocusOnError(this.#chrome.value);
|
|
108
|
+
}
|
|
109
|
+
set autoFocus(v) {
|
|
110
|
+
this.#chrome.value = { ...this.#chrome.peek(), autoFocus: v };
|
|
111
|
+
}
|
|
112
|
+
/** How the form behaves, as [[type:FormFeature]] descriptors. Default `[]`. */
|
|
113
|
+
get features() {
|
|
114
|
+
return this.#features.value;
|
|
115
|
+
}
|
|
116
|
+
set features(v) {
|
|
117
|
+
validateFormFeatures(v);
|
|
118
|
+
this.#features.value = v.map((entry) => ({ ...entry }));
|
|
119
|
+
}
|
|
120
|
+
/** Sugar for a `validation` feature's timing: `"submit"` (default), `"change"` or `"exit"`. */
|
|
121
|
+
get validateOn() {
|
|
122
|
+
return validationOn(this.#features.value);
|
|
123
|
+
}
|
|
124
|
+
set validateOn(v) {
|
|
125
|
+
if (v !== "submit" && v !== "change" && v !== "exit")
|
|
126
|
+
throw new TypeError('validateOn must be "submit", "change" or "exit"');
|
|
127
|
+
this.#features.value = v === "submit"
|
|
128
|
+
? withoutFormFeature(this.#features.peek(), "validation")
|
|
129
|
+
: withFormFeature(this.#features.peek(), { kind: "validation", on: v });
|
|
130
|
+
}
|
|
131
|
+
/** Sugar for a `save` feature: Enter in a single-line field saves. Default `false`. */
|
|
132
|
+
get saveOnEnter() {
|
|
133
|
+
return savesOnEnter(this.#features.value);
|
|
134
|
+
}
|
|
135
|
+
set saveOnEnter(v) {
|
|
136
|
+
if (typeof v !== "boolean")
|
|
137
|
+
throw new TypeError("saveOnEnter must be a boolean");
|
|
138
|
+
this.#features.value = v ? withFormFeature(this.#features.peek(), { kind: "save" }) : withoutFormFeature(this.#features.peek(), "save");
|
|
139
|
+
}
|
|
140
|
+
connectedCallback() {
|
|
141
|
+
super.connectedCallback();
|
|
142
|
+
let applied;
|
|
143
|
+
const stopPolicy = effect(() => {
|
|
144
|
+
const model = this.#model.value;
|
|
145
|
+
const policy = this.#editPolicy(this.#policy.value);
|
|
146
|
+
if (applied && applied !== model)
|
|
147
|
+
applied.applyPolicy({});
|
|
148
|
+
applied = model;
|
|
149
|
+
model?.applyPolicy(policy);
|
|
150
|
+
});
|
|
151
|
+
let hadErrors = false;
|
|
152
|
+
const stopFocus = effect(() => {
|
|
153
|
+
const model = this.#model.value;
|
|
154
|
+
if (!model || !autoFocusOnError(this.#chrome.value)) {
|
|
155
|
+
hadErrors = false;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const any = Object.keys(model.errors.value).length > 0;
|
|
159
|
+
if (any && !hadErrors)
|
|
160
|
+
requestAnimationFrame(() => this.focusFirst());
|
|
161
|
+
hadErrors = any;
|
|
162
|
+
});
|
|
163
|
+
this.listen(this, "keydown", (event) => this.#keydown(event));
|
|
164
|
+
this.onDisconnect(() => {
|
|
165
|
+
stopPolicy();
|
|
166
|
+
stopFocus();
|
|
167
|
+
applied?.applyPolicy({});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Focuses the first field with an error, or the first editable field. */
|
|
40
171
|
focusFirst() {
|
|
41
|
-
const
|
|
42
|
-
|
|
172
|
+
const invalid = [...this.querySelectorAll("[aria-invalid='true']")];
|
|
173
|
+
const target = invalid.find(isFocusable) ?? invalid[0] ?? this.#firstFocusable("input, textarea, [role=combobox]");
|
|
43
174
|
target?.focus();
|
|
44
175
|
}
|
|
45
176
|
render() {
|
|
46
177
|
const model = this.#model.value;
|
|
47
178
|
if (!model)
|
|
48
179
|
return nothing;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
const shared = new Map([...counts].filter(([, n]) => n > 1).map(([reason], i) => [reason, `${this.#id}-reason-${i}`]));
|
|
58
|
-
this.#shared = shared;
|
|
59
|
-
return html `${[...shared].map(([reason, id]) => html `<p class="aster-hint aster-form-reason" id=${id}>${reason}</p>`)}
|
|
180
|
+
const chrome = this.#chrome.value;
|
|
181
|
+
this.setAttribute("data-title-orientation", titleOrientation(chrome));
|
|
182
|
+
const showErrors = inlineErrorsShown(chrome);
|
|
183
|
+
this.#shared = this.#sharedReasons(model);
|
|
184
|
+
const labels = this.#fieldLabels(model);
|
|
185
|
+
const summary = errorSummaryShown(chrome) ? this.#summary(model, labels) : nothing;
|
|
186
|
+
const timing = validationOn(this.#features.value);
|
|
187
|
+
return html `${summary}${[...this.#shared].map(([reason, id]) => html `<p class="aster-hint aster-form-reason" id=${id}>${reason}</p>`)}
|
|
60
188
|
${this.#sections.value.map((section) => {
|
|
61
189
|
const changed = section.items.filter(isField).filter((i) => model.pending(nameOf(i)).value).length;
|
|
62
190
|
return html `<aster-section .heading=${section.title} .level=${this.#level.value} .open=${section.open ?? true}
|
|
63
191
|
.summary=${changed ? `${changed} ${changed === 1 ? "change" : "changes"}` : nothing}
|
|
64
|
-
.body=${html `<div class="aster-form-grid" style=${styleMap({ "--columns": String(section.columns ??
|
|
65
|
-
${section.items.map((item) => this.#item(model, item))}
|
|
192
|
+
.body=${html `<div class="aster-form-grid" style=${styleMap({ "--columns": String(section.columns ?? formColumns(chrome)) })}>
|
|
193
|
+
${section.items.map((item) => this.#item(model, item, timing, showErrors))}
|
|
66
194
|
</div>`}></aster-section>`;
|
|
67
195
|
})}`;
|
|
68
196
|
}
|
|
69
|
-
#item(model, item) {
|
|
197
|
+
#item(model, item, timing, showErrors) {
|
|
70
198
|
const span = typeof item === "object" ? (item.span ?? 1) : 1;
|
|
71
199
|
if (typeof item === "object" && "value" in item) {
|
|
72
200
|
return html `<aster-field style=${styleMap({ "--span": String(span) })} .labelled=${true} .label=${item.title}
|
|
73
201
|
.value=${item.value.value} .readOnly=${true}></aster-field>`;
|
|
74
202
|
}
|
|
75
|
-
const
|
|
76
|
-
return controlFor(model, nameOf(item), {
|
|
203
|
+
const configured = typeof item === "object" ? item : undefined;
|
|
204
|
+
return controlFor(model, nameOf(item), {
|
|
205
|
+
options: configured?.options,
|
|
206
|
+
labelled: true,
|
|
207
|
+
span,
|
|
208
|
+
sharedReasons: this.#shared,
|
|
209
|
+
title: configured?.title,
|
|
210
|
+
hint: configured?.hint,
|
|
211
|
+
placeholder: configured?.placeholder,
|
|
212
|
+
control: configured?.control,
|
|
213
|
+
validateOn: timing,
|
|
214
|
+
showErrors,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Reasons shown once for several fields: read-only and disabled reasons repeated across fields. */
|
|
218
|
+
#sharedReasons(model) {
|
|
219
|
+
const counts = new Map();
|
|
220
|
+
for (const item of this.#sections.value.flatMap((s) => s.items).filter(isField)) {
|
|
221
|
+
const state = model.state(nameOf(item)).value;
|
|
222
|
+
if (!state.visible)
|
|
223
|
+
continue;
|
|
224
|
+
for (const reason of [state.readOnly ? state.reasons.readOnly : undefined, state.enabled ? undefined : state.reasons.enabled]) {
|
|
225
|
+
if (reason !== undefined)
|
|
226
|
+
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return new Map([...counts].filter(([, n]) => n > 1).map(([reason], i) => [reason, `${this.#id}-reason-${i}`]));
|
|
230
|
+
}
|
|
231
|
+
/** Field names in the layout with the text the summary should use. */
|
|
232
|
+
#fieldLabels(model) {
|
|
233
|
+
const labels = new Map();
|
|
234
|
+
for (const item of this.#sections.value.flatMap((s) => s.items).filter(isField)) {
|
|
235
|
+
const name = nameOf(item);
|
|
236
|
+
labels.set(name, (typeof item === "object" ? item.title : undefined) || model.type.field(name)?.title || name);
|
|
237
|
+
}
|
|
238
|
+
return labels;
|
|
239
|
+
}
|
|
240
|
+
/** The errors above the sections, each a button that focuses its field. */
|
|
241
|
+
#summary(model, labels) {
|
|
242
|
+
const errors = Object.entries(model.errors.value).filter(([name]) => labels.has(name) && model.state(name).value.visible);
|
|
243
|
+
const formError = model.formError.value;
|
|
244
|
+
if (!errors.length && !formError)
|
|
245
|
+
return nothing;
|
|
246
|
+
return html `<div class="aster-form-summary" role="alert" tabindex="-1">
|
|
247
|
+
${errors.length ? html `<p class="aster-form-summary-title">${errors.length === 1 ? "There is 1 problem" : `There are ${errors.length} problems`}</p>` : nothing}
|
|
248
|
+
${formError ? html `<p class="aster-form-summary-form">${formError}</p>` : nothing}
|
|
249
|
+
${errors.length ? html `<ul>${errors.map(([name, messages]) => html `<li>${messages.map((message) => html `<button type="button" @click=${() => this.#focusField(name)}>${labels.get(name)}: ${message}</button>`)}</li>`)}</ul>` : nothing}
|
|
250
|
+
</div>`;
|
|
251
|
+
}
|
|
252
|
+
/** Focuses a field by name, opening its collapsed section first and waiting for the field to take focus. */
|
|
253
|
+
#focusField(name, frames = 5) {
|
|
254
|
+
requestAnimationFrame(() => {
|
|
255
|
+
const element = this.querySelector(`[data-field="${CSS.escape(name)}"]`);
|
|
256
|
+
const section = element?.closest("aster-section");
|
|
257
|
+
if (section && !section.open)
|
|
258
|
+
section.open = true;
|
|
259
|
+
element?.focus();
|
|
260
|
+
// Custom editors may not be focusable themselves; hand focus to their
|
|
261
|
+
// editor control, before any row chrome such as a revert button.
|
|
262
|
+
if (element && !(element === document.activeElement || element.contains(document.activeElement))) {
|
|
263
|
+
const editor = [...element.querySelectorAll("input:not([type=hidden]), textarea, select, [role=combobox], [contenteditable]")].find(isFocusable)
|
|
264
|
+
?? [...element.querySelectorAll("button, [tabindex]")].find(isFocusable);
|
|
265
|
+
editor?.focus();
|
|
266
|
+
}
|
|
267
|
+
// Succeeded only when the field took focus, not when it stayed on the summary button.
|
|
268
|
+
if (element && (element === document.activeElement || element.contains(document.activeElement)))
|
|
269
|
+
return;
|
|
270
|
+
if (frames > 0)
|
|
271
|
+
this.#focusField(name, frames - 1);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
#firstFocusable(selector) {
|
|
275
|
+
return [...this.querySelectorAll(selector)].find(isFocusable);
|
|
276
|
+
}
|
|
277
|
+
/** Static defaults the form adds to the model: form-wide entries, then each field's own. */
|
|
278
|
+
#editPolicy(policy) {
|
|
279
|
+
const fields = {};
|
|
280
|
+
for (const item of this.#sections.value.flatMap((s) => s.items)) {
|
|
281
|
+
if (typeof item !== "object" || !("field" in item))
|
|
282
|
+
continue;
|
|
283
|
+
if (item.readOnly === undefined && item.disabled === undefined && item.required === undefined)
|
|
284
|
+
continue;
|
|
285
|
+
fields[item.field] = {
|
|
286
|
+
...(item.readOnly === undefined ? {} : { readOnly: item.readOnly }),
|
|
287
|
+
...(item.disabled === undefined ? {} : { disabled: item.disabled }),
|
|
288
|
+
...(item.required === undefined ? {} : { required: item.required }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
...(policy.readOnly === undefined ? {} : { readOnly: policy.readOnly }),
|
|
293
|
+
...(policy.disabled === undefined ? {} : { disabled: policy.disabled }),
|
|
294
|
+
...(Object.keys(fields).length ? { fields } : {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
#keydown(event) {
|
|
298
|
+
if (!savesOnEnter(this.#features.peek()))
|
|
299
|
+
return;
|
|
300
|
+
if (event.defaultPrevented || event.key !== "Enter" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || event.isComposing)
|
|
301
|
+
return;
|
|
302
|
+
const target = event.target;
|
|
303
|
+
if (!(target instanceof HTMLElement))
|
|
304
|
+
return;
|
|
305
|
+
// A textarea takes its own newline; buttons, links and comboboxes have their own Enter.
|
|
306
|
+
if (target.closest("textarea, button, a, [role=button], [role=combobox], input[type=checkbox], input[type=radio]"))
|
|
307
|
+
return;
|
|
308
|
+
const model = this.#model.peek();
|
|
309
|
+
if (!model)
|
|
310
|
+
return;
|
|
311
|
+
if (!this.emit("form-submit", { model }))
|
|
312
|
+
return;
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
void model.save();
|
|
77
315
|
}
|
|
78
316
|
}
|
|
79
317
|
function isField(item) {
|
|
@@ -82,4 +320,9 @@ function isField(item) {
|
|
|
82
320
|
function nameOf(item) {
|
|
83
321
|
return typeof item === "string" ? item : item.field;
|
|
84
322
|
}
|
|
323
|
+
/** A control that can take focus: not natively disabled, ARIA-disabled or read-only. */
|
|
324
|
+
function isFocusable(element) {
|
|
325
|
+
return !element.hasAttribute("disabled") && element.getAttribute("aria-disabled") !== "true"
|
|
326
|
+
&& !element.hasAttribute("readonly") && element.getAttribute("aria-readonly") !== "true";
|
|
327
|
+
}
|
|
85
328
|
define("aster-form", Form);
|