@getaster/elements 0.0.0-stage → 0.1.0-preview.1

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 (163) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/bin/aster-theme-check.js +317 -0
  5. package/dist/base/drag.d.ts +45 -0
  6. package/dist/base/drag.js +139 -0
  7. package/dist/base/element.d.ts +51 -0
  8. package/dist/base/element.js +133 -0
  9. package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
  10. package/dist/breadcrumbs/breadcrumbs.js +51 -0
  11. package/dist/button/button.d.ts +37 -0
  12. package/dist/button/button.js +108 -0
  13. package/dist/caret/caret-group.d.ts +14 -0
  14. package/dist/caret/caret-group.js +71 -0
  15. package/dist/carousel/carousel.d.ts +28 -0
  16. package/dist/carousel/carousel.js +134 -0
  17. package/dist/component/component.d.ts +73 -0
  18. package/dist/component/component.js +190 -0
  19. package/dist/component/echarts.d.ts +46 -0
  20. package/dist/component/echarts.js +95 -0
  21. package/dist/composition/authoring.d.ts +157 -0
  22. package/dist/composition/authoring.js +291 -0
  23. package/dist/composition/builtins.d.ts +1130 -0
  24. package/dist/composition/builtins.js +318 -0
  25. package/dist/composition/command-bar.d.ts +18 -0
  26. package/dist/composition/command-bar.js +34 -0
  27. package/dist/composition/commands.d.ts +14 -0
  28. package/dist/composition/commands.js +59 -0
  29. package/dist/composition/composite.d.ts +48 -0
  30. package/dist/composition/composite.js +409 -0
  31. package/dist/composition/contracts.d.ts +63 -0
  32. package/dist/composition/contracts.js +97 -0
  33. package/dist/composition/definition.d.ts +94 -0
  34. package/dist/composition/definition.js +291 -0
  35. package/dist/composition/element.d.ts +35 -0
  36. package/dist/composition/element.js +51 -0
  37. package/dist/composition/registry.d.ts +83 -0
  38. package/dist/composition/registry.js +99 -0
  39. package/dist/composition/schema.d.ts +9 -0
  40. package/dist/composition/schema.js +139 -0
  41. package/dist/composition/sessions.d.ts +15 -0
  42. package/dist/composition/sessions.js +61 -0
  43. package/dist/dashboard/dashboard.d.ts +26 -0
  44. package/dist/dashboard/dashboard.js +296 -0
  45. package/dist/dashboard/layout.d.ts +19 -0
  46. package/dist/dashboard/layout.js +48 -0
  47. package/dist/dashboard/widget.d.ts +42 -0
  48. package/dist/dashboard/widget.js +165 -0
  49. package/dist/date/calendar.d.ts +34 -0
  50. package/dist/date/calendar.js +137 -0
  51. package/dist/date/date-field.d.ts +18 -0
  52. package/dist/date/date-field.js +81 -0
  53. package/dist/date/dates.d.ts +22 -0
  54. package/dist/date/dates.js +80 -0
  55. package/dist/dialog/dialog.d.ts +23 -0
  56. package/dist/dialog/dialog.js +70 -0
  57. package/dist/field/bound-field.d.ts +55 -0
  58. package/dist/field/bound-field.js +139 -0
  59. package/dist/field/check.d.ts +11 -0
  60. package/dist/field/check.js +26 -0
  61. package/dist/field/composite-field.d.ts +35 -0
  62. package/dist/field/composite-field.js +88 -0
  63. package/dist/field/field.d.ts +29 -0
  64. package/dist/field/field.js +75 -0
  65. package/dist/field/input.d.ts +15 -0
  66. package/dist/field/input.js +18 -0
  67. package/dist/field/number-field.d.ts +33 -0
  68. package/dist/field/number-field.js +179 -0
  69. package/dist/field/numeric.d.ts +4 -0
  70. package/dist/field/numeric.js +57 -0
  71. package/dist/field/values.d.ts +14 -0
  72. package/dist/field/values.js +60 -0
  73. package/dist/filter/filter-builder.d.ts +18 -0
  74. package/dist/filter/filter-builder.js +305 -0
  75. package/dist/form/controls.d.ts +20 -0
  76. package/dist/form/controls.js +34 -0
  77. package/dist/form/form.d.ts +46 -0
  78. package/dist/form/form.js +85 -0
  79. package/dist/format.d.ts +10 -0
  80. package/dist/format.js +57 -0
  81. package/dist/grid/filters.d.ts +16 -0
  82. package/dist/grid/filters.js +66 -0
  83. package/dist/grid/grid.d.ts +209 -0
  84. package/dist/grid/grid.js +2587 -0
  85. package/dist/grid/rows.d.ts +32 -0
  86. package/dist/grid/rows.js +89 -0
  87. package/dist/grid/view.d.ts +69 -0
  88. package/dist/grid/view.js +172 -0
  89. package/dist/index.d.ts +63 -0
  90. package/dist/index.js +61 -0
  91. package/dist/layout/stack.d.ts +112 -0
  92. package/dist/layout/stack.js +21 -0
  93. package/dist/list-edit/list-edit.d.ts +42 -0
  94. package/dist/list-edit/list-edit.js +220 -0
  95. package/dist/listbox/listbox.d.ts +45 -0
  96. package/dist/listbox/listbox.js +187 -0
  97. package/dist/menu/menu.d.ts +55 -0
  98. package/dist/menu/menu.js +286 -0
  99. package/dist/motion.d.ts +8 -0
  100. package/dist/motion.js +23 -0
  101. package/dist/overlay/anchor.d.ts +18 -0
  102. package/dist/overlay/anchor.js +41 -0
  103. package/dist/picker/options.d.ts +59 -0
  104. package/dist/picker/options.js +97 -0
  105. package/dist/picker/picker.d.ts +46 -0
  106. package/dist/picker/picker.js +492 -0
  107. package/dist/picker/tree-choices.d.ts +38 -0
  108. package/dist/picker/tree-choices.js +72 -0
  109. package/dist/picker/tree-picker.d.ts +29 -0
  110. package/dist/picker/tree-picker.js +282 -0
  111. package/dist/progress/policy.d.ts +5 -0
  112. package/dist/progress/policy.js +28 -0
  113. package/dist/progress/status.d.ts +20 -0
  114. package/dist/progress/status.js +38 -0
  115. package/dist/rule/operators.d.ts +8 -0
  116. package/dist/rule/operators.js +9 -0
  117. package/dist/rule/rule-editor.d.ts +57 -0
  118. package/dist/rule/rule-editor.js +290 -0
  119. package/dist/section/accordion.d.ts +24 -0
  120. package/dist/section/accordion.js +90 -0
  121. package/dist/section/section.d.ts +30 -0
  122. package/dist/section/section.js +99 -0
  123. package/dist/select/choice-element.d.ts +20 -0
  124. package/dist/select/choice-element.js +55 -0
  125. package/dist/select/choice.d.ts +34 -0
  126. package/dist/select/choice.js +122 -0
  127. package/dist/select/many.d.ts +13 -0
  128. package/dist/select/many.js +20 -0
  129. package/dist/select/select.d.ts +16 -0
  130. package/dist/select/select.js +40 -0
  131. package/dist/sidebar/sidebar.d.ts +47 -0
  132. package/dist/sidebar/sidebar.js +105 -0
  133. package/dist/slicer/slicer.d.ts +26 -0
  134. package/dist/slicer/slicer.js +140 -0
  135. package/dist/split/split.d.ts +41 -0
  136. package/dist/split/split.js +237 -0
  137. package/dist/styles/contract.d.ts +36 -0
  138. package/dist/styles/contract.js +39 -0
  139. package/dist/switch/switch.d.ts +22 -0
  140. package/dist/switch/switch.js +69 -0
  141. package/dist/tabs/tabs.d.ts +30 -0
  142. package/dist/tabs/tabs.js +202 -0
  143. package/dist/tile-grid/scroll.d.ts +9 -0
  144. package/dist/tile-grid/scroll.js +21 -0
  145. package/dist/tile-grid/tile-grid.d.ts +64 -0
  146. package/dist/tile-grid/tile-grid.js +734 -0
  147. package/dist/toast/toast.d.ts +25 -0
  148. package/dist/toast/toast.js +92 -0
  149. package/dist/tree/navigator.d.ts +41 -0
  150. package/dist/tree/navigator.js +228 -0
  151. package/dist/window/chrome.d.ts +3 -0
  152. package/dist/window/chrome.js +7 -0
  153. package/dist/window/window.d.ts +46 -0
  154. package/dist/window/window.js +347 -0
  155. package/dist/window/workspace.d.ts +29 -0
  156. package/dist/window/workspace.js +137 -0
  157. package/package.json +45 -4
  158. package/styles/aster-ui.css +1147 -0
  159. package/styles/base.css +88 -0
  160. package/styles/contract.ts +41 -0
  161. package/styles/elements.css +1058 -0
  162. package/styles/harbour.css +126 -0
  163. package/styles/lamplight.css +118 -0
@@ -0,0 +1,179 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { live } from "lit-html/directives/live.js";
3
+ import { define } from "../base/element.js";
4
+ import { BoundField } from "./bound-field.js";
5
+ import { formatValue, parseInput } from "./values.js";
6
+ import { canonicalNumeric, compareNumeric, numericAligned, stepNumeric } from "./numeric.js";
7
+ const INTEGER = new Set(["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64"]);
8
+ export class NumberField extends BoundField {
9
+ #value = this.prop(null);
10
+ #type = this.prop("decimal");
11
+ #min = this.prop(undefined);
12
+ #max = this.prop(undefined);
13
+ #step = this.prop(undefined);
14
+ #buttons = this.prop(false);
15
+ #prefix = this.prop("");
16
+ #suffix = this.prop("");
17
+ #placeholder = this.prop("");
18
+ #readOnly = this.prop(false);
19
+ #draft = this.prop(undefined);
20
+ #focused = this.prop(false);
21
+ #format = this.prop(undefined);
22
+ #last;
23
+ get value() { return this.#value.value; }
24
+ set value(v) { this.#draft.value = undefined; this.#value.value = v; }
25
+ get numericType() { return this.#type.value; }
26
+ set numericType(v) { if (!["decimal", "integer", "float"].includes(v))
27
+ throw new TypeError("Unknown numeric type"); this.#type.value = v; }
28
+ get min() { return this.#min.value; }
29
+ set min(v) { if (v !== undefined)
30
+ canonicalNumeric(v); this.#min.value = v; }
31
+ get max() { return this.#max.value; }
32
+ set max(v) { if (v !== undefined)
33
+ canonicalNumeric(v); this.#max.value = v; }
34
+ get step() { return this.#step.value; }
35
+ set step(v) { if (v !== undefined && compareNumeric(v, 0) <= 0)
36
+ throw new TypeError("Step must be positive"); this.#step.value = v; }
37
+ get stepButtons() { return this.#buttons.value; }
38
+ set stepButtons(v) { this.#buttons.value = v; }
39
+ get prefixText() { return this.#prefix.value; }
40
+ set prefixText(v) { this.#prefix.value = v; }
41
+ get suffixText() { return this.#suffix.value; }
42
+ set suffixText(v) { this.#suffix.value = v; }
43
+ get placeholder() { return this.#placeholder.value; }
44
+ set placeholder(v) { this.#placeholder.value = v; }
45
+ get readOnly() { return this.#readOnly.value; }
46
+ set readOnly(v) { this.#readOnly.value = v; }
47
+ get format() { return this.#format.value; }
48
+ set format(v) { this.#format.value = v; }
49
+ focus(options) { this.querySelector("input")?.focus(options); }
50
+ #kind(meta) {
51
+ return meta ? INTEGER.has(meta.type) ? "integer" : meta.type === "Edm.Decimal" ? "decimal" : "float" : this.#type.value;
52
+ }
53
+ #bounds(meta) {
54
+ let min = meta?.minimum, max = meta?.maximum;
55
+ if (this.#min.value !== undefined && (min === undefined || compareNumeric(this.#min.value, min) > 0))
56
+ min = this.#min.value;
57
+ if (this.#max.value !== undefined && (max === undefined || compareNumeric(this.#max.value, max) < 0))
58
+ max = this.#max.value;
59
+ return { ...(min === undefined ? {} : { min }), ...(max === undefined ? {} : { max }) };
60
+ }
61
+ validation(meta, boundValue) {
62
+ const value = this.model ? boundValue : this.#value.value;
63
+ try {
64
+ const { min, max } = this.#bounds(meta);
65
+ if (min !== undefined && max !== undefined && compareNumeric(min, max) > 0)
66
+ return "Minimum exceeds maximum";
67
+ if (this.#kind(meta) === "integer" && this.#step.value !== undefined && !numericAligned(this.#step.value, 1))
68
+ return "Integer fields require an integer step";
69
+ if (value === undefined || value === null || value === "")
70
+ return undefined;
71
+ if (typeof value === "boolean")
72
+ return "Enter a number";
73
+ canonicalNumeric(value);
74
+ if (this.#kind(meta) === "integer" && !numericAligned(value, 1))
75
+ return "Enter a whole number";
76
+ if (min !== undefined && (compareNumeric(value, min) < 0 || meta?.exclusiveMinimum && compareNumeric(value, meta.minimum) === 0))
77
+ return `Enter a number ${meta?.exclusiveMinimum ? "greater than" : "at least"} ${min}`;
78
+ if (max !== undefined && (compareNumeric(value, max) > 0 || meta?.exclusiveMaximum && compareNumeric(value, meta.maximum) === 0))
79
+ return `Enter a number ${meta?.exclusiveMaximum ? "less than" : "at most"} ${max}`;
80
+ if (this.#step.value !== undefined && !numericAligned(value, this.#step.value, min ?? 0))
81
+ return `Use increments of ${this.#step.value}${min === undefined ? "" : ` from ${min}`}`;
82
+ return undefined;
83
+ }
84
+ catch {
85
+ return "Enter a number";
86
+ }
87
+ }
88
+ #write(text, meta) {
89
+ let value;
90
+ if (meta)
91
+ value = meta.type === "Edm.Decimal" || meta.type === "Edm.Int64" ? text.trim() === "" ? null : text.trim() : parseInput(meta, text);
92
+ else {
93
+ value = text.trim() === "" ? null : text;
94
+ if (value !== null && this.#type.peek() === "float") {
95
+ try {
96
+ const number = Number(canonicalNumeric(value));
97
+ if (Number.isFinite(number))
98
+ value = number;
99
+ }
100
+ catch { /* Retain incomplete text. */ }
101
+ }
102
+ }
103
+ this.#last = value;
104
+ this.#draft.value = text;
105
+ if (this.model)
106
+ this.write(value);
107
+ else
108
+ this.#value.value = value;
109
+ this.emit("value-change", { value });
110
+ }
111
+ #next(direction, c) {
112
+ try {
113
+ const value = this.model ? c.value : this.#value.value;
114
+ if (value === undefined || typeof value === "boolean")
115
+ return undefined;
116
+ if (this.#kind(c.meta) === "integer" && this.#step.value !== undefined && !numericAligned(this.#step.value, 1))
117
+ return undefined;
118
+ const { min, max } = this.#bounds(c.meta);
119
+ if (min !== undefined && max !== undefined && compareNumeric(min, max) > 0)
120
+ return undefined;
121
+ let next = stepNumeric(value, direction, (this.#step.value ?? (c.meta?.scale ? `0.${"0".repeat(c.meta.scale - 1)}1` : 1)), min, max);
122
+ if (next !== undefined && (c.meta?.exclusiveMinimum && compareNumeric(next, c.meta.minimum) === 0 || c.meta?.exclusiveMaximum && compareNumeric(next, c.meta.maximum) === 0)) {
123
+ next = stepNumeric(next, direction, (this.#step.value ?? (c.meta?.scale ? `0.${"0".repeat(c.meta.scale - 1)}1` : 1)), min, max);
124
+ }
125
+ return next;
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ control(c) {
132
+ const current = this.model ? c.value : this.#value.value;
133
+ const draft = this.#draft.value;
134
+ const entered = draft !== undefined && current === this.#last ? draft : current === null || current === undefined ? "" : String(current);
135
+ let display = formatValue(c.meta, current);
136
+ const formatter = this.#format.value;
137
+ if (formatter && current !== undefined && typeof current !== "boolean") {
138
+ try {
139
+ if (current !== null)
140
+ canonicalNumeric(current);
141
+ display = formatter(current);
142
+ }
143
+ catch { /* Invalid text stays visible. */ }
144
+ }
145
+ const text = this.#focused.value ? entered : display;
146
+ const readOnly = this.#readOnly.value || c.state.readOnly;
147
+ const step = (direction) => { if (readOnly)
148
+ return; const next = this.#next(direction, c); if (next !== undefined)
149
+ this.#write(next, c.meta); };
150
+ return html `<div class="aster-field aster-number" data-ring-owner>
151
+ ${this.#prefix.value ? html `<span class="aster-number-affix">${this.#prefix.value}</span>` : nothing}
152
+ <input id=${c.id} type="text" inputmode=${this.#kind(c.meta) === "integer" ? "numeric" : "decimal"}
153
+ .value=${live(text)} aria-label=${this.labelled ? nothing : c.label} aria-describedby=${c.describedBy ?? nothing}
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"
156
+ @focus=${(e) => {
157
+ this.#focused.value = true;
158
+ if (display !== entered)
159
+ queueMicrotask(() => e.target.select());
160
+ }} @blur=${() => this.#focused.value = false}
161
+ @input=${(e) => this.#write(e.target.value, c.meta)}
162
+ @keydown=${(e) => {
163
+ if (readOnly || !this.#buttons.peek() || e.altKey || e.ctrlKey || e.metaKey || !["ArrowUp", "ArrowDown"].includes(e.key))
164
+ return;
165
+ e.preventDefault();
166
+ step(e.key === "ArrowUp" ? 1 : -1);
167
+ }}>
168
+ ${this.#suffix.value ? html `<span class="aster-number-affix">${this.#suffix.value}</span>` : nothing}
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}
171
+ @mousedown=${(e) => { if (e.button === 0)
172
+ e.preventDefault(); }} @click=${() => step(d)}>
173
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d=${d === 1 ? "M3 8h10M8 3v10" : "M3 8h10"}/></svg>
174
+ </button>`)}
175
+ </span>` : nothing}
176
+ </div>`;
177
+ }
178
+ }
179
+ define("aster-number-field", NumberField);
@@ -0,0 +1,4 @@
1
+ export declare function compareNumeric(a: string | number, b: string | number): number;
2
+ export declare function canonicalNumeric(value: string | number): string;
3
+ export declare function numericAligned(value: string | number, step: string | number, base?: string | number): boolean;
4
+ export declare function stepNumeric(value: string | number | null, direction: 1 | -1, step: string | number, min?: string | number, max?: string | number): string | undefined;
@@ -0,0 +1,57 @@
1
+ const LIMIT = 1000;
2
+ function decimal(value) {
3
+ const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(String(value));
4
+ if (!match || !(match[2] || match[3]))
5
+ throw new TypeError("Enter a number");
6
+ const exponent = Number(match[4] ?? 0);
7
+ if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > LIMIT || String(value).length > LIMIT)
8
+ throw new TypeError("Number is too large");
9
+ let scale = (match[3]?.length ?? 0) - exponent;
10
+ let coefficient = BigInt(`${match[1] === "-" ? "-" : ""}${match[2] || "0"}${match[3] ?? ""}`);
11
+ if (scale < 0) {
12
+ coefficient *= 10n ** BigInt(-scale);
13
+ scale = 0;
14
+ }
15
+ return { coefficient, scale };
16
+ }
17
+ function coefficient(d, scale) { return d.coefficient * 10n ** BigInt(scale - d.scale); }
18
+ function text(value, scale) {
19
+ const sign = value < 0n ? "-" : "";
20
+ const digits = (value < 0n ? -value : value).toString().padStart(scale + 1, "0");
21
+ return scale ? `${sign}${digits.slice(0, -scale)}.${digits.slice(-scale)}`.replace(/\.?0+$/, "") : `${sign}${digits}`;
22
+ }
23
+ export function compareNumeric(a, b) {
24
+ const x = decimal(a), y = decimal(b), scale = Math.max(x.scale, y.scale);
25
+ const delta = coefficient(x, scale) - coefficient(y, scale);
26
+ return delta < 0n ? -1 : delta > 0n ? 1 : 0;
27
+ }
28
+ export function canonicalNumeric(value) { const d = decimal(value); return text(d.coefficient, d.scale); }
29
+ export function numericAligned(value, step, base = 0) {
30
+ const v = decimal(value), s = decimal(step), b = decimal(base), scale = Math.max(v.scale, s.scale, b.scale);
31
+ const stride = coefficient(s, scale);
32
+ if (stride <= 0n)
33
+ throw new TypeError("Step must be positive");
34
+ return (coefficient(v, scale) - coefficient(b, scale)) % stride === 0n;
35
+ }
36
+ function floor(a, b) { const q = a / b; return a < 0n && a % b !== 0n ? q - 1n : q; }
37
+ export function stepNumeric(value, direction, step, min, max) {
38
+ const v = decimal(value ?? 0), s = decimal(step), b = decimal(min ?? 0);
39
+ const lower = min === undefined ? undefined : decimal(min), upper = max === undefined ? undefined : decimal(max);
40
+ const scale = Math.max(v.scale, s.scale, b.scale, lower?.scale ?? 0, upper?.scale ?? 0);
41
+ const stride = coefficient(s, scale), base = coefficient(b, scale), entered = coefficient(v, scale);
42
+ if (stride <= 0n)
43
+ throw new TypeError("Step must be positive");
44
+ const offset = entered - base, q = floor(offset, stride), aligned = offset % stride === 0n;
45
+ let next = base + (direction === 1 ? q + 1n : aligned ? q - 1n : q) * stride;
46
+ if (value === null)
47
+ next = base + (direction === 1 && !aligned ? q + 1n : q) * stride;
48
+ if (lower && next < coefficient(lower, scale))
49
+ next = base;
50
+ if (upper && next > coefficient(upper, scale))
51
+ next = base + floor(coefficient(upper, scale) - base, stride) * stride;
52
+ if (lower && next < coefficient(lower, scale) || upper && next > coefficient(upper, scale))
53
+ return undefined;
54
+ if (value !== null && (direction === 1 && next <= entered || direction === -1 && next >= entered))
55
+ return undefined;
56
+ return text(next, scale);
57
+ }
@@ -0,0 +1,14 @@
1
+ import type { Field, Value } from "@getaster/data";
2
+ /**
3
+ * What the user typed, as the field's value. Text that doesn't parse stays
4
+ * text, so validation reports it in words and nothing typed is lost. Empty
5
+ * is null, except on a non-nullable text field.
6
+ */
7
+ export declare function parseInput(field: Field | undefined, text: string): Value;
8
+ /** A value as the text a control shows; a Decimal shows its scale's places ("7.50"). */
9
+ export declare function formatValue(field: Field | undefined, value: Value | undefined): string;
10
+ /** The input type and mode that suit a field. */
11
+ export declare function inputKind(field: Field | undefined): {
12
+ type: string;
13
+ inputmode?: string;
14
+ };
@@ -0,0 +1,60 @@
1
+ const INTEGER = new Set(["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32"]);
2
+ /**
3
+ * What the user typed, as the field's value. Text that doesn't parse stays
4
+ * text, so validation reports it in words and nothing typed is lost. Empty
5
+ * is null, except on a non-nullable text field.
6
+ */
7
+ export function parseInput(field, text) {
8
+ if (!field)
9
+ return text;
10
+ const t = text.trim();
11
+ if (field.type === "Edm.String")
12
+ return text === "" && field.nullable ? null : text;
13
+ if (t === "")
14
+ return null;
15
+ if (INTEGER.has(field.type))
16
+ return /^-?\d+$/.test(t) ? Number(t) : text;
17
+ if (field.type === "Edm.Single" || field.type === "Edm.Double") {
18
+ const n = Number(t);
19
+ return Number.isFinite(n) ? n : text;
20
+ }
21
+ if (field.type === "Edm.Decimal" || field.type === "Edm.Int64")
22
+ return t.replaceAll(",", "");
23
+ return t;
24
+ }
25
+ /** A value as the text a control shows; a Decimal shows its scale's places ("7.50"). */
26
+ export function formatValue(field, value) {
27
+ if (value === null || value === undefined)
28
+ return "";
29
+ const allowed = field?.allowedValues?.find((a) => a.value === value);
30
+ if (allowed?.label)
31
+ return allowed.label;
32
+ if (field?.type === "Edm.Decimal" && field.scale !== undefined)
33
+ return padScale(String(value), field.scale);
34
+ return String(value);
35
+ }
36
+ /** Exact decimal text with at least `scale` places; other text is left as it is. */
37
+ function padScale(text, scale) {
38
+ const m = /^(-?\d+)(?:\.(\d*))?$/.exec(text);
39
+ if (!m || scale === 0)
40
+ return text;
41
+ const places = m[2] ?? "";
42
+ return places.length >= scale ? text : `${m[1]}.${places.padEnd(scale, "0")}`;
43
+ }
44
+ /** The input type and mode that suit a field. */
45
+ export function inputKind(field) {
46
+ switch (field?.type) {
47
+ case "Edm.Byte":
48
+ case "Edm.SByte":
49
+ case "Edm.Int16":
50
+ case "Edm.Int32":
51
+ case "Edm.Int64":
52
+ return { type: "text", inputmode: "numeric" };
53
+ case "Edm.Decimal":
54
+ case "Edm.Single":
55
+ case "Edm.Double":
56
+ return { type: "text", inputmode: "decimal" };
57
+ default:
58
+ return { type: "text" };
59
+ }
60
+ }
@@ -0,0 +1,18 @@
1
+ import { type FilterDraft } from "@getaster/data";
2
+ import { AsterElement, type View } from "../base/element.ts";
3
+ /** A standalone metadata-driven Criteria editor, with local drafts and cancellable apply/cancel events. */
4
+ export declare class FilterBuilder extends AsterElement {
5
+ #private;
6
+ /** A data-layer draft; no record list or database writes are required. */
7
+ get model(): FilterDraft | undefined;
8
+ set model(value: FilterDraft | undefined);
9
+ /** Accessible name of the editor and its root condition group. */
10
+ get label(): string;
11
+ set label(value: string);
12
+ /** A reason shown instead of editable controls. */
13
+ get readOnlyReason(): string | undefined;
14
+ set readOnlyReason(value: string | undefined);
15
+ focus(options?: FocusOptions): void;
16
+ protected render(): View;
17
+ protected updated(): void;
18
+ }
@@ -0,0 +1,305 @@
1
+ import { filterOperators, describeCriteria } from "@getaster/data";
2
+ import { html, nothing } from "lit-html";
3
+ import { live } from "lit-html/directives/live.js";
4
+ import { signal } from "@preact/signals-core";
5
+ import { AsterElement, define, each, uid } from "../base/element.js";
6
+ import { ChoiceState, choiceView } from "../select/choice.js";
7
+ import { filterOperatorLabel } from "../rule/operators.js";
8
+ import { manyView } from "../select/many.js";
9
+ import { inputView } from "../field/input.js";
10
+ import { inputKind } from "../field/values.js";
11
+ const plus = html `<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9"/></svg>`;
12
+ const RELATIVE = [
13
+ { label: "Today", value: '{"relative":"today"}' }, { label: "Yesterday", value: '{"relative":"today","days":-1}' },
14
+ { label: "Tomorrow", value: '{"relative":"today","days":1}' }, { label: "Start of this week", value: '{"relative":"today","startOf":"week"}' },
15
+ { label: "Start of this month", value: '{"relative":"today","startOf":"month"}' }, { label: "Now", value: '{"relative":"now"}' },
16
+ { label: "Custom relative date", value: "custom" },
17
+ ];
18
+ /** A standalone metadata-driven Criteria editor, with local drafts and cancellable apply/cancel events. */
19
+ export class FilterBuilder extends AsterElement {
20
+ #model = this.prop(undefined);
21
+ #label = this.prop("Advanced filter");
22
+ #readOnly = this.prop(undefined);
23
+ #choices = new Map();
24
+ #lists = new Map();
25
+ #customRelative = signal(new Set());
26
+ #id = uid("aster-filter");
27
+ #focus;
28
+ /** A data-layer draft; no record list or database writes are required. */
29
+ get model() { return this.#model.value; }
30
+ set model(value) {
31
+ if (value !== this.#model.peek()) {
32
+ this.#choices.clear();
33
+ this.#lists.clear();
34
+ this.#customRelative.value = new Set();
35
+ }
36
+ this.#model.value = value;
37
+ }
38
+ /** Accessible name of the editor and its root condition group. */
39
+ get label() { return this.#label.value; }
40
+ set label(value) { this.#label.value = value; }
41
+ /** A reason shown instead of editable controls. */
42
+ get readOnlyReason() { return this.#readOnly.value; }
43
+ set readOnlyReason(value) { this.#readOnly.value = value; }
44
+ focus(options) { this.querySelector("[role=combobox], input, button")?.focus(options); }
45
+ render() {
46
+ const model = this.#model.value;
47
+ if (!model)
48
+ return nothing;
49
+ const root = model.root.value;
50
+ const result = model.result.value;
51
+ const readOnly = this.#readOnly.value;
52
+ return html `<section class="aster-filter-builder" aria-labelledby=${`${this.#id}-title`}>
53
+ <h3 id=${`${this.#id}-title`}>${this.#label.value}</h3>
54
+ ${readOnly ? html `<p class="aster-hint" role="status">${readOnly}</p>` : nothing}
55
+ <fieldset class="aster-filter-controls" ?disabled=${readOnly !== undefined}>
56
+ <legend class="aster-sr-only">${this.#label.value} conditions</legend>
57
+ ${this.#node(root, this.#label.value, 0, true)}
58
+ </fieldset>
59
+ <p class="aster-filter-preview" aria-label="Expression preview">${result.valid ? describeCriteria(result.criteria, model.schema.set(model.set).type) : "Complete all conditions before applying."}</p>
60
+ ${result.problems.length ? html `<p class="aster-hint" role="status">${result.problems.length} ${result.problems.length === 1 ? "condition needs" : "conditions need"} attention.</p>` : nothing}
61
+ ${readOnly === undefined ? html `<div class="aster-filter-buttons">
62
+ <button type="button" class="aster-button" ?disabled=${!result.valid} @click=${() => this.#apply()}>Apply filter</button>
63
+ <button type="button" class="aster-button" @click=${() => { model.cancel(); this.emit("cancel"); }}>Cancel</button>
64
+ <button type="button" class="aster-grid-tool" @click=${() => { model.clear(); this.#focus = { id: model.root.peek().id, action: "add" }; }}>Clear expression</button>
65
+ </div>` : nothing}
66
+ </section>`;
67
+ }
68
+ #choice(key, label, value, options, choose, problem) {
69
+ let state = this.#choices.get(key);
70
+ if (!state) {
71
+ state = new ChoiceState();
72
+ this.#choices.set(key, state);
73
+ }
74
+ const known = options.some((o) => o.value === value);
75
+ const offered = !known && value !== undefined && value !== "" ? [{ value, label: `Unavailable: ${String(value)}` }, ...options] : options;
76
+ return choiceView(state, { id: `${this.#id}-${key}`, label, value, options: offered, placeholder: "Choose…", readOnly: this.#readOnly.value !== undefined,
77
+ invalid: problem !== undefined, ...(problem ? { describedBy: problem } : {}), choose: (v) => choose(String(v ?? "")) });
78
+ }
79
+ #node(node, name, depth, root = false) {
80
+ const model = this.#model.value;
81
+ const type = model.typeFor(node.id);
82
+ const incomplete = node.kind === "condition" && (!node.field || (node.mode === "value" && (node.text === "" || (node.op === "in" && node.text === "[]"))));
83
+ const problem = incomplete ? undefined : model.result.value.problems.find((p) => p.id === node.id);
84
+ const errorId = problem ? `${this.#id}-${node.id}-error` : undefined;
85
+ const error = problem ? html `<p class="aster-error" id=${errorId}>${problem.message}</p>` : nothing;
86
+ 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
+ if (node.kind === "condition") {
88
+ const field = this.#field(type, node.field);
89
+ const operators = filterOperators(field);
90
+ return html `<div class="aster-filter-condition" role="group" aria-label=${name} data-clause=${node.id}>
91
+ <div class="aster-filter-condition-fields">
92
+ ${this.#choice(`${node.id}-field`, `${name}: field`, node.field, this.#fields(type), (field) => model.change(node.id, { field }), errorId)}
93
+ ${node.field ? this.#choice(`${node.id}-op`, `${name}: test`, operators.includes(node.op) ? node.op : undefined, operators.map((op) => ({ value: op, label: filterOperatorLabel(op) })), (op) => model.change(node.id, { op }), errorId) : nothing}
94
+ ${node.field ? this.#value(node, field, type, name, errorId) : html `<span class="aster-filter-start aster-hint">Choose a field to begin.</span>`}${remove}
95
+ </div>${error}
96
+ </div>`;
97
+ }
98
+ const related = node.kind === "related";
99
+ const nav = related ? type?.navigation(node.field) : undefined;
100
+ const options = related ? nav?.collection === false ? ["via"] : ["any", "all"] : ["and", "or", "not"];
101
+ return html `<div class="aster-rule-group aster-filter-group" role="group" aria-label=${name} data-clause=${node.id} style=${`--depth:${depth}`}>
102
+ <div class="aster-rule-head">
103
+ ${related ? this.#choice(`${node.id}-navigation`, `${name}: relationship`, node.field, (type?.navigations ?? []).map((n) => ({ value: n.name, label: n.name })), (field) => {
104
+ const next = type?.navigation(field);
105
+ model.change(node.id, { field, op: next?.collection ? "any" : "via" });
106
+ }, errorId) : nothing}
107
+ ${this.#choice(`${node.id}-match`, `${name}: match`, node.op, options.map((op) => ({ value: op, label: filterOperatorLabel(op) })), (op) => model.change(node.id, { op }), errorId)}
108
+ <span>${related ? "matches" : "of these conditions"}</span>${remove}
109
+ </div>
110
+ <div class="aster-rule-body">${each(node.children, (c) => c.id, (c) => this.#node(c, `${name}, ${c.kind === "condition" ? "condition" : "group"} ${node.children.indexOf(c) + 1}`, depth + 1))}
111
+ ${node.children.length === 0 ? html `<p class="aster-hint">${root ? "No expression. Other query constraints still apply." : related && !nav ? "Choose a relationship before adding conditions." : "Add a condition to this group."}</p>` : nothing}
112
+ <div class="aster-rule-adds">
113
+ <button type="button" class="aster-list-edit-add" data-action="add" ?disabled=${related && !nav} aria-label=${`Add condition to ${name}`} @click=${() => this.#add(node.id, "condition")}>${plus}Add condition</button>
114
+ <button type="button" class="aster-list-edit-add" ?disabled=${related && !nav} aria-label=${`Add group to ${name}`} @click=${() => this.#add(node.id, "group")}>${plus}Add group</button>
115
+ ${(related ? nav ? model.schema.set(nav.target).type : undefined : type)?.navigations.length ? html `<button type="button" class="aster-list-edit-add" aria-label=${`Add related condition to ${name}`} @click=${() => this.#add(node.id, "related")}>${plus}Add related condition</button>` : nothing}
116
+ </div>${error}
117
+ </div>
118
+ </div>`;
119
+ }
120
+ #fields(type) { return (type?.fields ?? []).filter((f) => f.type !== "Edm.Binary").map((f) => ({ value: f.name, label: f.title ?? f.name })); }
121
+ #field(type, name) { return type?.field(name); }
122
+ #value(node, field, type, name, error) {
123
+ if (node.op === "isnull" || node.op === "notnull")
124
+ return html `<span></span>`;
125
+ const model = this.#model.value;
126
+ const comparison = ["eq", "ne", "gt", "ge", "lt", "le"].includes(node.op);
127
+ const modes = [{ value: "value", label: "Value" }, ...(comparison ? [{ value: "field", label: "Another field" }] : []),
128
+ ...(comparison && (field?.type === "Edm.Date" || field?.type === "Edm.DateTimeOffset") ? [{ value: "relative", label: "Relative date" }] : [])];
129
+ const change = (text) => model.change(node.id, { text });
130
+ let input;
131
+ switch (node.mode) {
132
+ case "field":
133
+ input = this.#choice(`${node.id}-other`, `${name}: comparison field`, node.other, this.#fields(type).filter((o) => this.#field(type, String(o.value))?.type === field?.type), (other) => model.change(node.id, { other }), error);
134
+ break;
135
+ case "relative": {
136
+ const preset = !this.#customRelative.value.has(node.id) && RELATIVE.some((o) => o.value === node.text);
137
+ input = html `${this.#choice(`${node.id}-relative`, `${name}: relative date`, preset ? node.text : "custom", RELATIVE, (v) => {
138
+ const custom = new Set(this.#customRelative.peek());
139
+ if (v === "custom")
140
+ custom.add(node.id);
141
+ else
142
+ custom.delete(node.id);
143
+ this.#customRelative.value = custom;
144
+ change(v === "custom" ? '{"relative":"today","days":0}' : v);
145
+ }, error)}
146
+ ${!preset ? this.#relative(node, name, error) : nothing}`;
147
+ break;
148
+ }
149
+ default: {
150
+ if (node.op === "in")
151
+ input = this.#options(field) ? this.#many(node, field, name, error) : this.#list(node, field, name, error);
152
+ else if (field?.allowedValues || field?.type === "Edm.Boolean") {
153
+ const options = field.allowedValues ? field.allowedValues.map((a) => ({ value: String(a.value), label: a.label ?? String(a.value) })) : [{ value: "true", label: "Yes" }, { value: "false", label: "No" }];
154
+ input = this.#choice(`${node.id}-value`, `${name}: value`, node.text, options, change, error);
155
+ }
156
+ else
157
+ input = this.#input(node, name, field?.type === "Edm.Date" && node.op !== "in" ? "date" : "text", error, field?.type === "Edm.DateTimeOffset" ? "Timestamp with offset" : "Value", inputKind(field).inputmode ?? "");
158
+ }
159
+ }
160
+ const modeChoice = this.#choice(`${node.id}-mode`, `${name}: compare with`, node.mode, modes, (mode) => {
161
+ model.change(node.id, { mode: mode });
162
+ this.#focus = { id: node.id, action: mode };
163
+ }, error);
164
+ return html `<div class="aster-filter-value" data-mode=${node.mode}>
165
+ ${node.mode !== "value" ? modeChoice : nothing}${input}
166
+ ${node.mode === "value" && modes.length > 1 ? html `<details class="aster-filter-comparison"><summary>Compare with…</summary>${modeChoice}</details>` : nothing}
167
+ ${["contains", "startswith", "endswith"].includes(node.op) ? html `<label><input type="checkbox" .checked=${node.caseInsensitive} @change=${(e) => model.change(node.id, { caseInsensitive: e.target.checked })}>Ignore case</label>` : nothing}
168
+ </div>`;
169
+ }
170
+ #input(node, name, type, error, placeholder, keyboard = "") {
171
+ return inputView({ id: `${this.#id}-${node.id}-value`, label: `${name}: value`, type, value: node.text, keyboard, placeholder,
172
+ invalid: error !== undefined, ...(error ? { describedBy: error } : {}), input: (text) => this.#model.value.change(node.id, { text }) });
173
+ }
174
+ #options(field) {
175
+ if (field?.allowedValues)
176
+ return field.allowedValues.map((o) => ({ value: o.value, label: o.label ?? String(o.value) }));
177
+ return field?.type === "Edm.Boolean" ? [{ value: true, label: "Yes" }, { value: false, label: "No" }] : undefined;
178
+ }
179
+ #many(node, field, name, error) {
180
+ let values = [];
181
+ try {
182
+ const parsed = JSON.parse(node.text);
183
+ if (Array.isArray(parsed))
184
+ values = parsed;
185
+ }
186
+ catch { /* The draft remains invalid until a choice is made. */ }
187
+ const options = this.#options(field) ?? [];
188
+ return html `<div class="aster-filter-choices">${manyView({ label: `${name}: values`, options: [...options, ...(field.nullable ? [{ value: null, label: "Empty (null)" }] : [])], values,
189
+ invalid: error !== undefined, ...(error ? { describedBy: error } : {}), readOnly: this.#readOnly.value !== undefined,
190
+ change: (values) => this.#model.value.change(node.id, { text: JSON.stringify(values) }) })}
191
+ ${values.length ? nothing : html `<span class="aster-hint">Choose one or more values.</span>`}</div>`;
192
+ }
193
+ #list(node, field, name, error) {
194
+ let values = [];
195
+ try {
196
+ const parsed = JSON.parse(node.text);
197
+ if (Array.isArray(parsed))
198
+ values = parsed;
199
+ }
200
+ catch { /* Validation retains the malformed draft. */ }
201
+ const cached = this.#lists.get(node.id);
202
+ const text = cached?.encoded === node.text ? cached.text : values.filter((v) => v !== null).join("\n");
203
+ const includeNull = values.includes(null);
204
+ const write = (text, nulls) => {
205
+ const values = text === "" ? [] : text.split("\n").map((line) => {
206
+ switch (field?.type) {
207
+ case "Edm.Boolean": return line === "true" ? true : line === "false" ? false : line;
208
+ case "Edm.Byte":
209
+ case "Edm.SByte":
210
+ case "Edm.Int16":
211
+ case "Edm.Int32":
212
+ case "Edm.Single":
213
+ case "Edm.Double":
214
+ return line.trim() && Number.isFinite(Number(line)) ? Number(line) : line;
215
+ default: return line;
216
+ }
217
+ });
218
+ if (nulls)
219
+ values.push(null);
220
+ const encoded = JSON.stringify(values);
221
+ this.#lists.set(node.id, { text, encoded });
222
+ this.#model.value.change(node.id, { text: encoded });
223
+ };
224
+ return html `<div class="aster-filter-list"><label class="aster-field aster-field-prose" data-ring-owner>
225
+ <span>Values, one per line${field?.type === "Edm.Boolean" ? " (true or false)" : ""}</span>
226
+ <textarea rows="3" aria-label=${`${name}: values, one per line`} .value=${live(text)} aria-invalid=${error ? "true" : nothing} aria-describedby=${error ?? nothing}
227
+ @input=${(e) => write(e.target.value, includeNull)}></textarea></label>
228
+ <label><input type="checkbox" .checked=${includeNull} @change=${(e) => write(text, e.target.checked)}>Include empty (null) values</label>
229
+ </div>`;
230
+ }
231
+ #relative(node, name, error) {
232
+ let value = {};
233
+ try {
234
+ const parsed = JSON.parse(node.text);
235
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
236
+ value = parsed;
237
+ }
238
+ catch { /* Validation retains the malformed draft. */ }
239
+ const write = (patch) => {
240
+ this.#customRelative.value = new Set([...this.#customRelative.peek(), node.id]);
241
+ this.#model.value.change(node.id, { text: JSON.stringify({ relative: "today", ...value, ...patch }) });
242
+ };
243
+ return html `<div class="aster-filter-relative">
244
+ ${this.#choice(`${node.id}-base`, `${name}: relative base`, String(value.relative ?? "today"), [{ value: "today", label: "Today" }, { value: "now", label: "Now" }], (relative) => write({ relative }), error)}
245
+ ${["days", "weeks", "months"].map((unit) => html `<label class="aster-field" data-ring-owner><span>${unit} (+/−)</span>
246
+ <input type="number" step="1" aria-label=${`${name}: offset ${unit}`} aria-invalid=${error ? "true" : nothing} aria-describedby=${error ?? nothing}
247
+ .value=${live(String(value[unit] ?? 0))} @input=${(e) => { const text = e.target.value; write({ [unit]: text.trim() ? Number(text) : "" }); }}></label>`)}
248
+ ${this.#choice(`${node.id}-align`, `${name}: align date`, String(value.startOf ?? "none"), [{ value: "none", label: "No alignment" }, { value: "week", label: "Start of week" }, { value: "month", label: "Start of month" }], (startOf) => write({ startOf: startOf === "none" ? undefined : startOf }), error)}
249
+ <span class="aster-hint">Negative offsets go back; positive offsets go forward. Dates use UTC.</span>
250
+ </div>`;
251
+ }
252
+ #add(parent, kind) {
253
+ const id = this.#model.value.add(parent, kind);
254
+ this.#focus = { id };
255
+ }
256
+ #remove(id) {
257
+ const root = this.#model.value.root.peek();
258
+ const parentOf = (n) => n.kind === "condition" ? undefined : n.children.some((c) => c.id === id) ? n.id : n.children.map(parentOf).find((p) => p !== undefined);
259
+ this.#focus = { id: parentOf(root) ?? root.id, action: "add" };
260
+ this.#model.value.remove(id);
261
+ const ids = new Set();
262
+ const collect = (n) => { ids.add(n.id); if (n.kind !== "condition")
263
+ n.children.forEach(collect); };
264
+ collect(this.#model.value.root.peek());
265
+ for (const key of this.#choices.keys())
266
+ if (!ids.has(key.split("-")[0]))
267
+ this.#choices.delete(key);
268
+ for (const key of this.#lists.keys())
269
+ if (!ids.has(key))
270
+ this.#lists.delete(key);
271
+ this.#customRelative.value = new Set([...this.#customRelative.peek()].filter((id) => ids.has(id)));
272
+ }
273
+ #apply() {
274
+ const model = this.#model.value;
275
+ const result = model?.result.peek();
276
+ if (!model || !result?.valid)
277
+ return;
278
+ if (this.emit("apply", result.criteria))
279
+ model.apply();
280
+ }
281
+ updated() {
282
+ const focus = this.#focus;
283
+ if (!focus)
284
+ return;
285
+ const node = this.querySelector(`[data-clause="${CSS.escape(focus.id)}"]`);
286
+ let target;
287
+ switch (focus.action) {
288
+ case "add":
289
+ target = node?.querySelector("[data-action=add]");
290
+ break;
291
+ case "field":
292
+ case "relative":
293
+ case "value": {
294
+ const suffix = focus.action === "field" ? "other" : focus.action;
295
+ target = node?.querySelector(`#${CSS.escape(`${this.#id}-${focus.id}-${suffix}`)}`)
296
+ ?? node?.querySelector(".aster-filter-value input, .aster-filter-value textarea, .aster-filter-value [role=combobox]");
297
+ break;
298
+ }
299
+ default: target = node?.querySelector("[role=combobox], input, button");
300
+ }
301
+ target?.focus();
302
+ this.#focus = undefined;
303
+ }
304
+ }
305
+ define("aster-filter-builder", FilterBuilder);