@getaster/elements 0.3.5 → 0.4.5

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 +1810 -308
  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 +181 -10
  84. package/styles/base.css +14 -0
  85. package/styles/contract.ts +16 -0
  86. package/styles/elements.css +167 -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
@@ -1,20 +1,48 @@
1
1
  import { AsterElement, type View } from "../base/element.ts";
2
2
  /**
3
- * A modal dialog over a native `<dialog>`: the rest of the page is inert,
4
- * focus goes in on open and returns to the opener on close, and Escape or
5
- * a click on the backdrop closes it. One dialog morphs between views
6
- * (`showView`) rather than stacking modals.
3
+ * A dialog over a native `<dialog>`: modal by default, so the rest of the
4
+ * page is inert and focus stays inside; non-modal with `modal = false`, where
5
+ * the page stays usable. One dialog morphs between views (`showView`)
6
+ * rather than stacking dialogs. Escape and a backdrop press dismiss it unless
7
+ * the host turns those off.
7
8
  */
8
9
  export declare class Dialog extends AsterElement {
9
10
  #private;
10
11
  /** The dialog's contents; usually a template reading the current view. */
11
12
  get content(): View;
12
13
  set content(v: View);
13
- /** The id of the element that names the dialog. */
14
+ /** The id of the element that names the dialog. It wins over `title`. */
14
15
  get labelledBy(): string | undefined;
15
16
  set labelledBy(v: string | undefined);
17
+ /** The dialog's title, as text. Setting it shows a header unless `header` is false. Default `""`. */
18
+ get title(): string;
19
+ set title(v: string);
20
+ /** Whether the header bar is drawn. Default: shown when `title` is set. */
21
+ get header(): boolean | undefined;
22
+ set header(v: boolean | undefined);
23
+ /** The footer contents, shown below the content when set. */
24
+ get footer(): View;
25
+ set footer(v: View);
26
+ /** Whether the header carries a Close control. Default `false`. */
27
+ get showCloseButton(): boolean | undefined;
28
+ set showCloseButton(v: boolean | undefined);
29
+ /** Whether the dialog is modal. Default `true`; non-modal leaves the page usable. */
30
+ get modal(): boolean;
31
+ set modal(v: boolean);
32
+ /** Whether Escape closes the dialog. Default `true`. */
33
+ get dismissOnEscape(): boolean;
34
+ set dismissOnEscape(v: boolean);
35
+ /** Whether a press on the backdrop closes a modal dialog. Default `true`. */
36
+ get dismissOnBackdrop(): boolean;
37
+ set dismissOnBackdrop(v: boolean);
38
+ /**
39
+ * Where focus goes on open: the first focusable element (default), the
40
+ * dialog itself, nowhere, or a function returning the element to focus.
41
+ */
42
+ get initialFocus(): "first" | "dialog" | "none" | (() => HTMLElement | null) | undefined;
43
+ set initialFocus(v: "first" | "dialog" | "none" | (() => HTMLElement | null) | undefined);
16
44
  get open(): boolean;
17
- /** Opens modally. `focus` is the element to focus inside; otherwise the first focusable. */
45
+ /** Opens the dialog. `focus` is still accepted and wins over `initialFocus`. */
18
46
  show(opener?: HTMLElement | null, focus?: () => HTMLElement | null): void;
19
47
  close(): void;
20
48
  /** Changes view in place: the panel morphs, then the new content inks in. */
@@ -1,15 +1,27 @@
1
1
  import { html, nothing } from "lit-html";
2
- import { AsterElement, define } from "../base/element.js";
2
+ import { AsterElement, define, uid } from "../base/element.js";
3
3
  import { morph } from "../motion.js";
4
+ import { dialogCloseShown, dialogHeaderShown } from "./config.js";
5
+ const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]";
4
6
  /**
5
- * A modal dialog over a native `<dialog>`: the rest of the page is inert,
6
- * focus goes in on open and returns to the opener on close, and Escape or
7
- * a click on the backdrop closes it. One dialog morphs between views
8
- * (`showView`) rather than stacking modals.
7
+ * A dialog over a native `<dialog>`: modal by default, so the rest of the
8
+ * page is inert and focus stays inside; non-modal with `modal = false`, where
9
+ * the page stays usable. One dialog morphs between views (`showView`)
10
+ * rather than stacking dialogs. Escape and a backdrop press dismiss it unless
11
+ * the host turns those off.
9
12
  */
10
13
  export class Dialog extends AsterElement {
11
14
  #content = this.prop(nothing);
12
15
  #label = this.prop(undefined);
16
+ #title = this.prop("");
17
+ #header = this.prop(undefined);
18
+ #footer = this.prop(nothing);
19
+ #showClose = this.prop(undefined);
20
+ #modal = this.prop(true);
21
+ #dismissOnEscape = this.prop(true);
22
+ #dismissOnBackdrop = this.prop(true);
23
+ #initialFocus = this.prop(undefined);
24
+ #id = uid("aster-dialog");
13
25
  #opener = null;
14
26
  /** The dialog's contents; usually a template reading the current view. */
15
27
  get content() {
@@ -18,26 +30,98 @@ export class Dialog extends AsterElement {
18
30
  set content(v) {
19
31
  this.#content.value = v;
20
32
  }
21
- /** The id of the element that names the dialog. */
33
+ /** The id of the element that names the dialog. It wins over `title`. */
22
34
  get labelledBy() {
23
35
  return this.#label.value;
24
36
  }
25
37
  set labelledBy(v) {
26
38
  this.#label.value = v;
27
39
  }
40
+ /** The dialog's title, as text. Setting it shows a header unless `header` is false. Default `""`. */
41
+ get title() {
42
+ return this.#title.value;
43
+ }
44
+ set title(v) {
45
+ if (typeof v !== "string")
46
+ throw new TypeError("title must be a string");
47
+ this.#title.value = v;
48
+ }
49
+ /** Whether the header bar is drawn. Default: shown when `title` is set. */
50
+ get header() {
51
+ return this.#header.value;
52
+ }
53
+ set header(v) {
54
+ if (v !== undefined && typeof v !== "boolean")
55
+ throw new TypeError("header must be a boolean");
56
+ this.#header.value = v;
57
+ }
58
+ /** The footer contents, shown below the content when set. */
59
+ get footer() {
60
+ return this.#footer.value;
61
+ }
62
+ set footer(v) {
63
+ this.#footer.value = v;
64
+ }
65
+ /** Whether the header carries a Close control. Default `false`. */
66
+ get showCloseButton() {
67
+ return this.#showClose.value;
68
+ }
69
+ set showCloseButton(v) {
70
+ if (v !== undefined && typeof v !== "boolean")
71
+ throw new TypeError("showCloseButton must be a boolean");
72
+ this.#showClose.value = v;
73
+ }
74
+ /** Whether the dialog is modal. Default `true`; non-modal leaves the page usable. */
75
+ get modal() {
76
+ return this.#modal.value;
77
+ }
78
+ set modal(v) {
79
+ if (typeof v !== "boolean")
80
+ throw new TypeError("modal must be a boolean");
81
+ this.#modal.value = v;
82
+ }
83
+ /** Whether Escape closes the dialog. Default `true`. */
84
+ get dismissOnEscape() {
85
+ return this.#dismissOnEscape.value;
86
+ }
87
+ set dismissOnEscape(v) {
88
+ this.#dismissOnEscape.value = v !== false;
89
+ }
90
+ /** Whether a press on the backdrop closes a modal dialog. Default `true`. */
91
+ get dismissOnBackdrop() {
92
+ return this.#dismissOnBackdrop.value;
93
+ }
94
+ set dismissOnBackdrop(v) {
95
+ this.#dismissOnBackdrop.value = v !== false;
96
+ }
97
+ /**
98
+ * Where focus goes on open: the first focusable element (default), the
99
+ * dialog itself, nowhere, or a function returning the element to focus.
100
+ */
101
+ get initialFocus() {
102
+ return this.#initialFocus.value;
103
+ }
104
+ set initialFocus(v) {
105
+ if (v !== undefined && v !== "first" && v !== "dialog" && v !== "none" && typeof v !== "function") {
106
+ throw new TypeError('initialFocus must be "first", "dialog", "none" or a function');
107
+ }
108
+ this.#initialFocus.value = v;
109
+ }
28
110
  get open() {
29
111
  return this.#dialog()?.open ?? false;
30
112
  }
31
- /** Opens modally. `focus` is the element to focus inside; otherwise the first focusable. */
113
+ /** Opens the dialog. `focus` is still accepted and wins over `initialFocus`. */
32
114
  show(opener = document.activeElement, focus) {
33
115
  const dialog = this.#dialog();
34
116
  if (!dialog || dialog.open)
35
117
  return;
118
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
36
119
  this.#opener = opener;
37
- dialog.showModal();
38
- const target = focus?.();
39
- if (target)
40
- target.focus();
120
+ if (this.#modal.value)
121
+ dialog.showModal();
122
+ else
123
+ dialog.show();
124
+ this.#focusInitial(focus?.(), previous);
41
125
  }
42
126
  close() {
43
127
  const dialog = this.#dialog();
@@ -50,12 +134,62 @@ export class Dialog extends AsterElement {
50
134
  morph(change);
51
135
  }
52
136
  render() {
53
- return html `<dialog class="aster-dialog" aria-labelledby=${this.#label.value ?? nothing}
137
+ const title = this.#title.value;
138
+ const header = dialogHeaderShown(title, this.#header.value);
139
+ const close = dialogCloseShown(header, this.#showClose.value);
140
+ const titleId = `${this.#id}-title`;
141
+ const labelledBy = this.#label.value ?? (header && title ? titleId : undefined);
142
+ return html `<dialog class="aster-dialog" aria-labelledby=${labelledBy ?? nothing}
143
+ aria-label=${labelledBy ? nothing : title || nothing} tabindex="-1"
54
144
  @close=${() => this.#closed()}
55
- @click=${(e) => e.target === e.currentTarget && this.close()}>
56
- <div class="aster-dialog-panel">${this.#content.value}</div>
145
+ @cancel=${(e) => {
146
+ e.preventDefault();
147
+ if (this.#dismissOnEscape.peek())
148
+ this.close();
149
+ }}
150
+ @keydown=${(e) => {
151
+ if (e.key !== "Escape" || e.defaultPrevented)
152
+ return;
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ if (this.#dismissOnEscape.value)
156
+ this.close();
157
+ }}
158
+ @click=${(e) => e.target === e.currentTarget && this.#dismissOnBackdrop.value && this.close()}>
159
+ <div class="aster-dialog-panel">
160
+ ${header ? html `<div class="aster-dialog-head">
161
+ ${title ? html `<h2 class="aster-dialog-title" id=${titleId}>${title}</h2>` : nothing}
162
+ ${close ? html `<button type="button" class="aster-dialog-close" aria-label="Close"
163
+ @click=${() => this.close()}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>` : nothing}
164
+ </div>` : nothing}
165
+ ${this.#content.value}
166
+ ${this.#footer.value === nothing ? nothing : html `<div class="aster-dialog-footer">${this.#footer.value}</div>`}
167
+ </div>
57
168
  </dialog>`;
58
169
  }
170
+ /** An explicit function wins, then a configured policy; otherwise the native focus steps stand. */
171
+ #focusInitial(explicit, previous) {
172
+ const dialog = this.#dialog();
173
+ if (!dialog || (!explicit && this.#initialFocus.peek() === undefined))
174
+ return;
175
+ const target = explicit ?? this.#policyTarget(dialog, previous);
176
+ target?.focus();
177
+ }
178
+ #policyTarget(dialog, previous) {
179
+ const mode = this.#initialFocus.peek();
180
+ if (typeof mode === "function")
181
+ return mode() ?? undefined;
182
+ switch (mode) {
183
+ case "none":
184
+ // A modal dialog must contain focus; otherwise restore what was focused before show().
185
+ return this.#modal.peek() ? dialog : (previous?.isConnected && !dialog.contains(previous) ? previous : undefined);
186
+ case "dialog":
187
+ return dialog;
188
+ case "first":
189
+ case undefined:
190
+ return dialog.querySelector("[autofocus]") ?? [...dialog.querySelectorAll(FOCUSABLE)].find(isFocusable) ?? dialog;
191
+ }
192
+ }
59
193
  #closed() {
60
194
  const opener = this.#opener;
61
195
  this.#opener = null;
@@ -67,4 +201,7 @@ export class Dialog extends AsterElement {
67
201
  return this.querySelector("dialog");
68
202
  }
69
203
  }
204
+ function isFocusable(element) {
205
+ return element.tabIndex >= 0 && !element.hasAttribute("disabled") && element.getAttribute("aria-disabled") !== "true";
206
+ }
70
207
  define("aster-dialog", Dialog);
@@ -1,5 +1,7 @@
1
1
  import type { EditModel, Field as FieldMeta, FieldState, Value } from "@getaster/data";
2
2
  import { AsterElement, type View } from "../base/element.ts";
3
+ /** When a bound field revalidates its own value. */
4
+ export type ValidateOn = "submit" | "change" | "exit";
3
5
  /** What a control needs to render itself inside a bound field. */
4
6
  export interface ControlContext {
5
7
  readonly id: string;
@@ -7,7 +9,7 @@ export interface ControlContext {
7
9
  readonly meta: FieldMeta | undefined;
8
10
  readonly value: Value | undefined;
9
11
  readonly state: FieldState;
10
- /** `aria-describedby` for the control: the error, then the read-only reason. */
12
+ /** `aria-describedby` for the control: its error, hint, reasons and pending note. */
11
13
  readonly describedBy: string | undefined;
12
14
  readonly invalid: boolean;
13
15
  readonly label: string;
@@ -43,6 +45,20 @@ export declare abstract class BoundField extends AsterElement {
43
45
  */
44
46
  get sharedReasons(): ReadonlyMap<string, string>;
45
47
  set sharedReasons(v: ReadonlyMap<string, string>);
48
+ /** Help text under the control, in addition to the label. */
49
+ get hint(): string;
50
+ set hint(v: string);
51
+ /** Whether to draw the error under the control. Default `true`; the control still reports `aria-invalid`. */
52
+ get showErrors(): boolean;
53
+ set showErrors(v: boolean);
54
+ /**
55
+ * When the field revalidates itself: only when the host validates
56
+ * (`"submit"`, the default), after every edit (`"change"`), or when focus
57
+ * leaves it (`"exit"`).
58
+ */
59
+ get validateOn(): ValidateOn;
60
+ set validateOn(v: ValidateOn);
61
+ connectedCallback(): void;
46
62
  protected abstract control(c: ControlContext): View;
47
63
  /** Control-specific validation supplements the model's errors. */
48
64
  protected validation(_meta: FieldMeta | undefined, _value: Value | undefined): string | undefined;
@@ -21,6 +21,9 @@ export class BoundField extends AsterElement {
21
21
  #label = this.prop("");
22
22
  #labelled = this.prop(false);
23
23
  #shared = this.prop(new Map());
24
+ #hint = this.prop("");
25
+ #showErrors = this.prop(true);
26
+ #validateOn = this.prop("submit");
24
27
  #uid = uid("aster-field");
25
28
  get model() {
26
29
  return this.#model.value;
@@ -58,6 +61,47 @@ export class BoundField extends AsterElement {
58
61
  set sharedReasons(v) {
59
62
  this.#shared.value = v;
60
63
  }
64
+ /** Help text under the control, in addition to the label. */
65
+ get hint() {
66
+ return this.#hint.value;
67
+ }
68
+ set hint(v) {
69
+ this.#hint.value = v;
70
+ }
71
+ /** Whether to draw the error under the control. Default `true`; the control still reports `aria-invalid`. */
72
+ get showErrors() {
73
+ return this.#showErrors.value;
74
+ }
75
+ set showErrors(v) {
76
+ this.#showErrors.value = v;
77
+ }
78
+ /**
79
+ * When the field revalidates itself: only when the host validates
80
+ * (`"submit"`, the default), after every edit (`"change"`), or when focus
81
+ * leaves it (`"exit"`).
82
+ */
83
+ get validateOn() {
84
+ return this.#validateOn.value;
85
+ }
86
+ set validateOn(v) {
87
+ if (v !== "submit" && v !== "change" && v !== "exit")
88
+ throw new TypeError('validateOn must be "submit", "change" or "exit"');
89
+ this.#validateOn.value = v;
90
+ }
91
+ connectedCallback() {
92
+ super.connectedCallback();
93
+ this.listen(this, "focusout", (event) => {
94
+ if (this.#validateOn.peek() !== "exit")
95
+ return;
96
+ const next = event.relatedTarget;
97
+ if (next instanceof Node && this.contains(next))
98
+ return;
99
+ const model = this.#model.peek();
100
+ const name = this.#field.peek();
101
+ if (model?.type.field(name))
102
+ model.validateField(name);
103
+ });
104
+ }
61
105
  /** Control-specific validation supplements the model's errors. */
62
106
  validation(_meta, _value) { return undefined; }
63
107
  /** The frame and anything beside it; a subclass can wrap the control. */
@@ -76,7 +120,13 @@ export class BoundField extends AsterElement {
76
120
  </label>`;
77
121
  }
78
122
  write(value) {
79
- this.#model.peek()?.set(this.#field.peek(), value);
123
+ const model = this.#model.peek();
124
+ if (!model)
125
+ return;
126
+ const name = this.#field.peek();
127
+ model.set(name, value);
128
+ if (this.#validateOn.peek() === "change")
129
+ model.validateField(name);
80
130
  }
81
131
  render() {
82
132
  const model = this.#model.value;
@@ -89,10 +139,17 @@ export class BoundField extends AsterElement {
89
139
  const hidden = model?.hidden.value.has(name) ?? false;
90
140
  const error = model?.errors.value[name]?.[0] ?? this.validation(meta, model?.values.value[name]);
91
141
  const pending = model && meta ? model.pending(name).value : false;
142
+ const hint = this.#hint.value;
143
+ const showErrors = this.#showErrors.value;
92
144
  const reason = state.readOnly ? state.reasons.readOnly : undefined;
93
- const sharedId = reason === undefined ? undefined : this.#shared.value.get(reason);
94
- const errorId = `${this.#uid}-error`;
95
- const reasonId = sharedId ?? `${this.#uid}-reason`;
145
+ const disabledReason = state.enabled ? undefined : state.reasons.enabled;
146
+ const sharedId = (text) => (text === undefined ? undefined : this.#shared.value.get(text));
147
+ const hintId = hint ? `${this.#uid}-hint` : undefined;
148
+ const errorId = showErrors && error ? `${this.#uid}-error` : undefined;
149
+ const reasonShared = sharedId(reason);
150
+ const disabledShared = sharedId(disabledReason);
151
+ const reasonId = reason ? reasonShared ?? `${this.#uid}-reason` : undefined;
152
+ const disabledId = disabledReason ? disabledShared ?? `${this.#uid}-disabled` : undefined;
96
153
  const pendingId = `${this.#uid}-pending`;
97
154
  const suggestedId = `${this.#uid}-suggested`;
98
155
  const suggested = !hidden && !state.readOnly && (model?.derived(name).value ?? false);
@@ -106,12 +163,14 @@ export class BoundField extends AsterElement {
106
163
  meta,
107
164
  value: model?.values.value[name],
108
165
  state,
109
- describedBy: [error ? errorId : "", reason ? reasonId : "", changed ? pendingId : "", suggested ? suggestedId : ""].filter(Boolean).join(" ") || undefined,
166
+ describedBy: [errorId ?? "", hintId ?? "", reasonId ?? "", disabledId ?? "", changed ? pendingId : "", suggested ? suggestedId : ""].filter(Boolean).join(" ") || undefined,
110
167
  invalid: error !== undefined,
111
168
  label,
112
169
  };
170
+ this.setAttribute("data-field", name);
113
171
  this.toggleAttribute("data-pending", pending);
114
172
  this.toggleAttribute("data-readonly", state.readOnly);
173
+ this.toggleAttribute("data-disabled", !state.enabled);
115
174
  this.toggleAttribute("data-value-hidden", hidden);
116
175
  this.toggleAttribute("data-suggested", suggested);
117
176
  const was = this.describe(meta, model?.baseline.value[name]) || "empty";
@@ -131,9 +190,11 @@ export class BoundField extends AsterElement {
131
190
  </div>`
132
191
  : nothing}
133
192
  ${hidden ? this.#hiddenValue(c) : this.row(c)}
193
+ ${hint && hintId ? html `<p class="aster-hint" id=${hintId}>${hint}</p>` : nothing}
134
194
  ${suggested ? html `<p class="aster-hint" id=${suggestedId}>Suggested value</p>` : nothing}
135
195
  ${changed ? html `<span class="aster-sr-only" id=${pendingId}>${pendingText}</span>` : nothing}
136
- ${reason && !sharedId ? html `<p class="aster-hint" id=${reasonId}>${reason}</p>` : nothing}
137
- ${error ? html `<p class="aster-error" id=${errorId} role="alert">${error}</p>` : nothing}`;
196
+ ${reason && reasonId && !reasonShared ? html `<p class="aster-hint" id=${reasonId}>${reason}</p>` : nothing}
197
+ ${disabledReason && disabledId && !disabledShared ? html `<p class="aster-hint" id=${disabledId}>${disabledReason}</p>` : nothing}
198
+ ${showErrors && error && errorId ? html `<p class="aster-error" id=${errorId} role="alert">${error}</p>` : nothing}`;
138
199
  }
139
200
  }
@@ -16,6 +16,7 @@ export class Check extends BoundField {
16
16
  aria-label=${this.labelled ? nothing : c.label}
17
17
  aria-readonly=${c.state.readOnly ? "true" : nothing}
18
18
  aria-required=${c.state.required ? "true" : nothing}
19
+ ?disabled=${!c.state.enabled}
19
20
  @click=${(e) => c.state.readOnly && e.preventDefault()}
20
21
  aria-invalid=${c.invalid ? "true" : nothing}
21
22
  aria-describedby=${c.describedBy ?? nothing}
@@ -64,7 +64,7 @@ export class Field extends BoundField {
64
64
  const kind = inputKind(c.meta);
65
65
  return inputView({ id: c.id, value: text, type: kind.type, keyboard: this.#keyboard.value || kind.inputmode || "",
66
66
  ...(this.labelled ? {} : { label: c.label }), placeholder: this.#placeholder.value, readOnly, required: c.state.required,
67
- invalid: c.invalid, ...(c.describedBy ? { describedBy: c.describedBy } : {}), ...(isProse(c.meta) ? { rows: 2 } : {}),
67
+ disabled: !c.state.enabled, invalid: c.invalid, ...(c.describedBy ? { describedBy: c.describedBy } : {}), ...(isProse(c.meta) ? { rows: 2 } : {}),
68
68
  input: (text) => this.write(parseInput(c.meta, text)) });
69
69
  }
70
70
  }
@@ -8,6 +8,7 @@ export declare function inputView(p: {
8
8
  readonly keyboard?: string;
9
9
  readonly placeholder?: string;
10
10
  readonly readOnly?: boolean;
11
+ readonly disabled?: boolean;
11
12
  readonly required?: boolean;
12
13
  readonly invalid?: boolean;
13
14
  readonly describedBy?: string;
@@ -4,12 +4,12 @@ import { live } from "lit-html/directives/live.js";
4
4
  export function inputView(p) {
5
5
  return html `<label class=${p.rows ? "aster-field aster-field-prose" : "aster-field"} data-ring-owner>
6
6
  ${p.rows ? html `<textarea id=${p.id ?? nothing} rows=${p.rows} .value=${live(p.value)}
7
- aria-label=${p.label ?? nothing} placeholder=${p.placeholder || nothing} ?readonly=${p.readOnly}
7
+ aria-label=${p.label ?? nothing} placeholder=${p.placeholder || nothing} ?readonly=${p.readOnly} ?disabled=${p.disabled}
8
8
  aria-required=${p.required ? "true" : nothing} aria-invalid=${p.invalid ? "true" : nothing}
9
9
  aria-describedby=${p.describedBy ?? nothing}
10
10
  @input=${(e) => p.input(e.target.value)}></textarea>`
11
11
  : html `<input id=${p.id ?? nothing} type=${p.type ?? "text"} inputmode=${p.keyboard || nothing} .value=${live(p.value)}
12
- aria-label=${p.label ?? nothing} placeholder=${p.placeholder || nothing} ?readonly=${p.readOnly}
12
+ aria-label=${p.label ?? nothing} placeholder=${p.placeholder || nothing} ?readonly=${p.readOnly} ?disabled=${p.disabled}
13
13
  aria-required=${p.required ? "true" : nothing} aria-invalid=${p.invalid ? "true" : nothing}
14
14
  aria-describedby=${p.describedBy ?? nothing} autocomplete="off" spellcheck="false"
15
15
  @focus=${(e) => p.readOnly && e.target.select()}
@@ -152,7 +152,7 @@ export class NumberField extends BoundField {
152
152
  <input id=${c.id} type="text" inputmode=${this.#kind(c.meta) === "integer" ? "numeric" : "decimal"}
153
153
  .value=${live(text)} aria-label=${this.labelled ? nothing : c.label} aria-describedby=${c.describedBy ?? nothing}
154
154
  aria-required=${c.state.required ? "true" : nothing} aria-invalid=${c.invalid ? "true" : nothing}
155
- ?readonly=${readOnly} placeholder=${this.#placeholder.value || nothing} autocomplete="off" spellcheck="false"
155
+ ?readonly=${readOnly} ?disabled=${!c.state.enabled} placeholder=${this.#placeholder.value || nothing} autocomplete="off" spellcheck="false"
156
156
  @focus=${(e) => {
157
157
  this.#focused.value = true;
158
158
  if (display !== entered)
@@ -167,7 +167,7 @@ export class NumberField extends BoundField {
167
167
  }}>
168
168
  ${this.#suffix.value ? html `<span class="aster-number-affix">${this.#suffix.value}</span>` : nothing}
169
169
  ${this.#buttons.value ? html `<span class="aster-number-steps">
170
- ${[-1, 1].map(d => html `<button type="button" aria-label=${`${d === 1 ? "Increase" : "Decrease"} ${c.label}`} ?disabled=${readOnly || this.#next(d, c) === undefined}
170
+ ${[-1, 1].map(d => html `<button type="button" aria-label=${`${d === 1 ? "Increase" : "Decrease"} ${c.label}`} ?disabled=${!c.state.enabled || readOnly || this.#next(d, c) === undefined}
171
171
  @mousedown=${(e) => { if (e.button === 0)
172
172
  e.preventDefault(); }} @click=${() => step(d)}>
173
173
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d=${d === 1 ? "M3 8h10M8 3v10" : "M3 8h10"}/></svg>
@@ -1,4 +1,4 @@
1
- import { type FilterDraft } from "@getaster/data";
1
+ import { type FilterDraft, type Field } from "@getaster/data";
2
2
  import { AsterElement, type View } from "../base/element.ts";
3
3
  /** A standalone metadata-driven Criteria editor, with local drafts and cancellable apply/cancel events. */
4
4
  export declare class FilterBuilder extends AsterElement {
@@ -12,6 +12,12 @@ export declare class FilterBuilder extends AsterElement {
12
12
  /** A reason shown instead of editable controls. */
13
13
  get readOnlyReason(): string | undefined;
14
14
  set readOnlyReason(value: string | undefined);
15
+ /**
16
+ * The operators offered for a field, such as a page's per-field allowlist.
17
+ * Default: the metadata's operators, from [[staticMethod:filterOperators]].
18
+ */
19
+ get operators(): ((field: Field | undefined) => readonly string[]) | undefined;
20
+ set operators(value: ((field: Field | undefined) => readonly string[]) | undefined);
15
21
  focus(options?: FocusOptions): void;
16
22
  protected render(): View;
17
23
  protected updated(): void;
@@ -20,6 +20,7 @@ export class FilterBuilder extends AsterElement {
20
20
  #model = this.prop(undefined);
21
21
  #label = this.prop("Advanced filter");
22
22
  #readOnly = this.prop(undefined);
23
+ #operators = this.prop(undefined);
23
24
  #choices = new Map();
24
25
  #lists = new Map();
25
26
  #customRelative = signal(new Set());
@@ -41,6 +42,12 @@ export class FilterBuilder extends AsterElement {
41
42
  /** A reason shown instead of editable controls. */
42
43
  get readOnlyReason() { return this.#readOnly.value; }
43
44
  set readOnlyReason(value) { this.#readOnly.value = value; }
45
+ /**
46
+ * The operators offered for a field, such as a page's per-field allowlist.
47
+ * Default: the metadata's operators, from [[staticMethod:filterOperators]].
48
+ */
49
+ get operators() { return this.#operators.value; }
50
+ set operators(value) { this.#operators.value = value; }
44
51
  focus(options) { this.querySelector("[role=combobox], input, button")?.focus(options); }
45
52
  render() {
46
53
  const model = this.#model.value;
@@ -86,7 +93,7 @@ export class FilterBuilder extends AsterElement {
86
93
  const remove = !root ? html `<button type="button" class="aster-list-edit-action" aria-label=${`Remove ${name}`} @click=${() => this.#remove(node.id)}>Remove</button>` : nothing;
87
94
  if (node.kind === "condition") {
88
95
  const field = this.#field(type, node.field);
89
- const operators = filterOperators(field);
96
+ const operators = this.#operators.value ? this.#operators.value(field) : filterOperators(field);
90
97
  return html `<div class="aster-filter-condition" role="group" aria-label=${name} data-clause=${node.id}>
91
98
  <div class="aster-filter-condition-fields">
92
99
  ${this.#choice(`${node.id}-field`, `${name}: field`, node.field, this.#fields(type), (field) => model.change(node.id, { field }), errorId)}
@@ -1,20 +1,52 @@
1
- import type { Collection, EditModel } from "@getaster/data";
1
+ import type { Collection, EditModel, Value } from "@getaster/data";
2
2
  import type { PickerOptions } from "../picker/options.ts";
3
3
  import { type TemplateResult } from "lit-html";
4
+ import type { View } from "../base/element.ts";
5
+ import type { ControlContext } from "../field/bound-field.ts";
4
6
  import "../date/date-field.ts";
5
7
  import "../field/check.ts";
6
8
  import "../field/field.ts";
7
9
  import "../field/number-field.ts";
8
10
  import "../picker/picker.ts";
9
11
  import "../select/select.ts";
12
+ import "./custom-field.ts";
13
+ import type { ValidateOn } from "./features.ts";
14
+ /** The editors the form can be told to use for a field. */
15
+ export type FormControlName = "text" | "number" | "date" | "check" | "select" | "picker";
16
+ /** What a custom editor gets: the bound field's context plus a write that respects the field's state. */
17
+ export interface FormControlContext extends ControlContext {
18
+ readonly write: (value: Value) => void;
19
+ }
20
+ /** A JavaScript-only editor renderer; composition catalogs reject it. */
21
+ export type FormControlRender = (context: FormControlContext) => View;
22
+ /** A typed editor override: a control name, or a renderer for a custom control. */
23
+ export type FormControl = FormControlName | FormControlRender;
24
+ /** What [[staticMethod:controlFor]] accepts. Only `labelled` is required. */
25
+ export interface ControlOptions {
26
+ /** A foreign key's choices; when given the default control is a picker. */
27
+ readonly options?: Collection | PickerOptions | undefined;
28
+ readonly labelled: boolean;
29
+ readonly span?: number | undefined;
30
+ readonly sharedReasons?: ReadonlyMap<string, string> | undefined;
31
+ /** A label override; default: the field's metadata title. */
32
+ readonly title?: string | undefined;
33
+ /** Help text under the control. */
34
+ readonly hint?: string | undefined;
35
+ /** Placeholder text, for the controls that have one. */
36
+ readonly placeholder?: string | undefined;
37
+ /** A typed editor override, or a custom renderer (JavaScript only). */
38
+ readonly control?: FormControl | undefined;
39
+ /** When to revalidate the field. Default `"submit"`. */
40
+ readonly validateOn?: ValidateOn;
41
+ /** Draw the error under the field. Default `true`. */
42
+ readonly showErrors?: boolean;
43
+ }
44
+ /** The control metadata picks for a field, before any override. */
45
+ export declare function metadataControl(model: EditModel, name: string, options: ControlOptions["options"]): FormControlName;
10
46
  /**
11
47
  * The control for one field, chosen from metadata: a foreign key with
12
48
  * `options` gives a picker, a Boolean a checkbox, allowed values a select,
13
- * a Date the date field, anything else a field.
49
+ * a Date the date field, anything else a field. `control` replaces that
50
+ * choice with a named editor, or with a renderer that draws its own.
14
51
  */
15
- export declare function controlFor(model: EditModel, name: string, opts: {
16
- options?: Collection | PickerOptions | undefined;
17
- labelled: boolean;
18
- span?: number;
19
- sharedReasons?: ReadonlyMap<string, string>;
20
- }): TemplateResult;
52
+ export declare function controlFor(model: EditModel, name: string, opts: ControlOptions): TemplateResult;