@getaster/elements 0.3.0 → 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.
Files changed (93) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +13 -4
  3. package/bin/aster-theme-check.js +35 -4
  4. package/dist/base/element.d.ts +8 -1
  5. package/dist/base/element.js +7 -2
  6. package/dist/button/button.d.ts +17 -2
  7. package/dist/button/button.js +42 -8
  8. package/dist/composition/builtins.d.ts +623 -1
  9. package/dist/composition/builtins.js +232 -4
  10. package/dist/date/calendar.d.ts +3 -0
  11. package/dist/date/calendar.js +8 -2
  12. package/dist/date/date-field.d.ts +23 -3
  13. package/dist/date/date-field.js +77 -9
  14. package/dist/date/dates.d.ts +10 -2
  15. package/dist/date/dates.js +23 -3
  16. package/dist/dialog/config.d.ts +4 -0
  17. package/dist/dialog/config.js +8 -0
  18. package/dist/dialog/dialog.d.ts +34 -6
  19. package/dist/dialog/dialog.js +151 -14
  20. package/dist/field/bound-field.d.ts +17 -1
  21. package/dist/field/bound-field.js +68 -7
  22. package/dist/field/check.js +1 -0
  23. package/dist/field/field.js +1 -1
  24. package/dist/field/input.d.ts +1 -0
  25. package/dist/field/input.js +2 -2
  26. package/dist/field/number-field.js +2 -2
  27. package/dist/filter/filter-builder.d.ts +7 -1
  28. package/dist/filter/filter-builder.js +8 -1
  29. package/dist/form/controls.d.ts +40 -8
  30. package/dist/form/controls.js +58 -15
  31. package/dist/form/custom-field.d.ts +1 -0
  32. package/dist/form/custom-field.js +23 -0
  33. package/dist/form/features.d.ts +27 -0
  34. package/dist/form/features.js +52 -0
  35. package/dist/form/form.d.ts +67 -7
  36. package/dist/form/form.js +267 -24
  37. package/dist/form/policy.d.ts +29 -0
  38. package/dist/form/policy.js +16 -0
  39. package/dist/grid/appearance.d.ts +365 -0
  40. package/dist/grid/appearance.js +814 -0
  41. package/dist/grid/features.d.ts +38 -0
  42. package/dist/grid/features.js +78 -0
  43. package/dist/grid/grid.d.ts +216 -4
  44. package/dist/grid/grid.js +1853 -301
  45. package/dist/grid/policy.d.ts +58 -0
  46. package/dist/grid/policy.js +17 -0
  47. package/dist/grid/view.d.ts +32 -2
  48. package/dist/grid/view.js +89 -4
  49. package/dist/index.d.ts +14 -11
  50. package/dist/index.js +2 -1
  51. package/dist/menu/config.d.ts +7 -0
  52. package/dist/menu/config.js +44 -0
  53. package/dist/menu/menu.d.ts +23 -0
  54. package/dist/menu/menu.js +69 -17
  55. package/dist/picker/picker.js +8 -3
  56. package/dist/section/accordion.d.ts +26 -0
  57. package/dist/section/accordion.js +86 -8
  58. package/dist/section/policy.d.ts +8 -0
  59. package/dist/section/policy.js +17 -0
  60. package/dist/section/section.d.ts +30 -0
  61. package/dist/section/section.js +106 -3
  62. package/dist/select/choice-element.d.ts +12 -0
  63. package/dist/select/choice-element.js +36 -0
  64. package/dist/select/choice.d.ts +12 -2
  65. package/dist/select/choice.js +93 -28
  66. package/dist/select/select.d.ts +12 -0
  67. package/dist/select/select.js +37 -1
  68. package/dist/styles/contract.d.ts +14 -0
  69. package/dist/styles/contract.js +15 -0
  70. package/dist/switch/switch.d.ts +16 -0
  71. package/dist/switch/switch.js +56 -6
  72. package/dist/tree/navigator.d.ts +3 -0
  73. package/dist/tree/navigator.js +26 -2
  74. package/dist/window/chrome.d.ts +5 -1
  75. package/dist/window/chrome.js +4 -3
  76. package/dist/window/config.d.ts +71 -0
  77. package/dist/window/config.js +40 -0
  78. package/dist/window/window.d.ts +51 -13
  79. package/dist/window/window.js +233 -38
  80. package/dist/yaml.d.ts +19 -0
  81. package/dist/yaml.js +57 -0
  82. package/package.json +10 -4
  83. package/styles/aster-ui.css +233 -10
  84. package/styles/base.css +36 -0
  85. package/styles/contract.ts +16 -0
  86. package/styles/elements.css +197 -10
  87. package/styles/fonts/OFL-bricolage-grotesque.txt +93 -0
  88. package/styles/fonts/OFL-fraunces.txt +93 -0
  89. package/styles/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
  90. package/styles/fonts/fraunces-latin-full-normal.woff2 +0 -0
  91. package/styles/fonts.css +17 -0
  92. package/styles/theme-harbour.css +5 -0
  93. package/styles/theme-lamplight.css +5 -0
@@ -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
- if (opts.options) {
19
- return html `<aster-picker style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared} .options=${opts.options}></aster-picker>`;
20
- }
21
- if (model.type.field(name)?.type === "Edm.Boolean") {
22
- return html `<aster-check style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-check>`;
23
- }
24
- if (model.type.field(name)?.allowedValues) {
25
- return html `<aster-select style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-select>`;
26
- }
27
- if (model.type.field(name)?.type === "Edm.Date") {
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
- if (["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64", "Edm.Decimal", "Edm.Single", "Edm.Double"].includes(model.type.field(name)?.type ?? "")) {
31
- return html `<aster-number-field style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-number-field>`;
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
+ }
@@ -1,14 +1,33 @@
1
1
  import type { Collection, EditModel } from "@getaster/data";
2
- import type { ReadonlySignal } from "@preact/signals-core";
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 only the arrangement
29
- * (sections, columns, spans); each field's control, label, type, required
30
- * marker, allowed values and state come from the model and its metadata:
31
- * allowed values become a select, a foreign key with `options` a picker,
32
- * anything else a field.
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
- /** Focuses the first field with an error, or the first field. */
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 only the arrangement
9
- * (sections, columns, spans); each field's control, label, type, required
10
- * marker, allowed values and state come from the model and its metadata:
11
- * allowed values become a select, a foreign key with `options` a picker,
12
- * anything else a field.
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
- /** Focuses the first field with an error, or the first field. */
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 target = this.querySelector("[aria-invalid='true']") ??
42
- this.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])");
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
- // A read-only reason that applies to several fields shows once, above them.
50
- const counts = new Map();
51
- for (const item of this.#sections.value.flatMap((s) => s.items).filter(isField)) {
52
- const state = model.state(nameOf(item)).value;
53
- const reason = state.visible && state.readOnly ? state.reasons.readOnly : undefined;
54
- if (reason !== undefined)
55
- counts.set(reason, (counts.get(reason) ?? 0) + 1);
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 ?? 2) })}>
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 options = typeof item === "object" ? item.options : undefined;
76
- return controlFor(model, nameOf(item), { options, labelled: true, span, sharedReasons: this.#shared });
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);