@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/dialog/dialog.d.ts
CHANGED
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
import { AsterElement, type View } from "../base/element.ts";
|
|
2
2
|
/**
|
|
3
|
-
* A
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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. */
|
package/dist/dialog/dialog.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
56
|
-
|
|
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:
|
|
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()
|
|
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
|
|
94
|
-
const
|
|
95
|
-
const
|
|
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: [
|
|
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 && !
|
|
137
|
-
${
|
|
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
|
}
|
package/dist/field/check.js
CHANGED
|
@@ -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}
|
package/dist/field/field.js
CHANGED
|
@@ -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
|
}
|
package/dist/field/input.d.ts
CHANGED
package/dist/field/input.js
CHANGED
|
@@ -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)}
|
package/dist/form/controls.d.ts
CHANGED
|
@@ -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;
|