@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,20 @@
1
+ import type { Collection, EditModel } from "@getaster/data";
2
+ import type { PickerOptions } from "../picker/options.ts";
3
+ import { type TemplateResult } from "lit-html";
4
+ import "../date/date-field.ts";
5
+ import "../field/check.ts";
6
+ import "../field/field.ts";
7
+ import "../field/number-field.ts";
8
+ import "../picker/picker.ts";
9
+ import "../select/select.ts";
10
+ /**
11
+ * The control for one field, chosen from metadata: a foreign key with
12
+ * `options` gives a picker, a Boolean a checkbox, allowed values a select,
13
+ * a Date the date field, anything else a field.
14
+ */
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;
@@ -0,0 +1,34 @@
1
+ import { html } from "lit-html";
2
+ import { styleMap } from "lit-html/directives/style-map.js";
3
+ import "../date/date-field.js";
4
+ import "../field/check.js";
5
+ import "../field/field.js";
6
+ import "../field/number-field.js";
7
+ import "../picker/picker.js";
8
+ import "../select/select.js";
9
+ const NONE = new Map();
10
+ /**
11
+ * The control for one field, chosen from metadata: a foreign key with
12
+ * `options` gives a picker, a Boolean a checkbox, allowed values a select,
13
+ * a Date the date field, anything else a field.
14
+ */
15
+ export function controlFor(model, name, opts) {
16
+ const style = styleMap(opts.span ? { "--span": String(opts.span) } : {});
17
+ const shared = opts.sharedReasons ?? NONE;
18
+ if (opts.options) {
19
+ return html `<aster-picker style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared} .options=${opts.options}></aster-picker>`;
20
+ }
21
+ if (model.type.field(name)?.type === "Edm.Boolean") {
22
+ return html `<aster-check style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-check>`;
23
+ }
24
+ if (model.type.field(name)?.allowedValues) {
25
+ return html `<aster-select style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-select>`;
26
+ }
27
+ if (model.type.field(name)?.type === "Edm.Date") {
28
+ return html `<aster-date style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-date>`;
29
+ }
30
+ if (["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64", "Edm.Decimal", "Edm.Single", "Edm.Double"].includes(model.type.field(name)?.type ?? "")) {
31
+ return html `<aster-number-field style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-number-field>`;
32
+ }
33
+ return html `<aster-field style=${style} .model=${model} .field=${name} .labelled=${opts.labelled} .sharedReasons=${shared}></aster-field>`;
34
+ }
@@ -0,0 +1,46 @@
1
+ import type { Collection, EditModel } from "@getaster/data";
2
+ import type { ReadonlySignal } from "@preact/signals-core";
3
+ import type { PickerOptions } from "../picker/options.ts";
4
+ import { AsterElement, type View } from "../base/element.ts";
5
+ import "../field/field.ts";
6
+ import "../section/section.ts";
7
+ /** A field of the model, optionally spanning columns; `options` makes a foreign key a picker. */
8
+ export interface FormField {
9
+ readonly field: string;
10
+ readonly span?: number;
11
+ readonly options?: Collection | PickerOptions;
12
+ }
13
+ /** A value shown read-only, such as a figure derived from other fields. */
14
+ export interface FormValue {
15
+ readonly title: string;
16
+ readonly value: ReadonlySignal<string>;
17
+ readonly span?: number;
18
+ }
19
+ export type FormItem = string | FormField | FormValue;
20
+ export interface FormSection {
21
+ readonly title: string;
22
+ readonly columns?: number;
23
+ /** Starts collapsed when false. */
24
+ readonly open?: boolean;
25
+ readonly items: readonly FormItem[];
26
+ }
27
+ /**
28
+ * Fields laid out from metadata. The app gives only the arrangement
29
+ * (sections, columns, spans); each field's control, label, type, required
30
+ * marker, allowed values and state come from the model and its metadata:
31
+ * allowed values become a select, a foreign key with `options` a picker,
32
+ * anything else a field.
33
+ */
34
+ export declare class Form extends AsterElement {
35
+ #private;
36
+ get model(): EditModel | undefined;
37
+ set model(v: EditModel | undefined);
38
+ get sections(): readonly FormSection[];
39
+ set sections(v: readonly FormSection[]);
40
+ /** The sections' heading level in the page outline. */
41
+ get headingLevel(): 2 | 3 | 4;
42
+ set headingLevel(v: 2 | 3 | 4);
43
+ /** Focuses the first field with an error, or the first field. */
44
+ focusFirst(): void;
45
+ protected render(): View;
46
+ }
@@ -0,0 +1,85 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { styleMap } from "lit-html/directives/style-map.js";
3
+ import { AsterElement, define, uid } from "../base/element.js";
4
+ import "../field/field.js";
5
+ import "../section/section.js";
6
+ import { controlFor } from "./controls.js";
7
+ /**
8
+ * Fields laid out from metadata. The app gives only the arrangement
9
+ * (sections, columns, spans); each field's control, label, type, required
10
+ * marker, allowed values and state come from the model and its metadata:
11
+ * allowed values become a select, a foreign key with `options` a picker,
12
+ * anything else a field.
13
+ */
14
+ export class Form extends AsterElement {
15
+ #shared = new Map();
16
+ #model = this.prop(undefined);
17
+ #sections = this.prop([]);
18
+ #level = this.prop(2);
19
+ #id = uid("aster-form");
20
+ get model() {
21
+ return this.#model.value;
22
+ }
23
+ set model(v) {
24
+ this.#model.value = v;
25
+ }
26
+ get sections() {
27
+ return this.#sections.value;
28
+ }
29
+ set sections(v) {
30
+ this.#sections.value = v;
31
+ }
32
+ /** The sections' heading level in the page outline. */
33
+ get headingLevel() {
34
+ return this.#level.value;
35
+ }
36
+ set headingLevel(v) {
37
+ this.#level.value = v;
38
+ }
39
+ /** Focuses the first field with an error, or the first field. */
40
+ focusFirst() {
41
+ const target = this.querySelector("[aria-invalid='true']") ??
42
+ this.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])");
43
+ target?.focus();
44
+ }
45
+ render() {
46
+ const model = this.#model.value;
47
+ if (!model)
48
+ return nothing;
49
+ // A read-only reason that applies to several fields shows once, above them.
50
+ const counts = new Map();
51
+ for (const item of this.#sections.value.flatMap((s) => s.items).filter(isField)) {
52
+ const state = model.state(nameOf(item)).value;
53
+ const reason = state.visible && state.readOnly ? state.reasons.readOnly : undefined;
54
+ if (reason !== undefined)
55
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
56
+ }
57
+ const shared = new Map([...counts].filter(([, n]) => n > 1).map(([reason], i) => [reason, `${this.#id}-reason-${i}`]));
58
+ this.#shared = shared;
59
+ return html `${[...shared].map(([reason, id]) => html `<p class="aster-hint aster-form-reason" id=${id}>${reason}</p>`)}
60
+ ${this.#sections.value.map((section) => {
61
+ const changed = section.items.filter(isField).filter((i) => model.pending(nameOf(i)).value).length;
62
+ return html `<aster-section .heading=${section.title} .level=${this.#level.value} .open=${section.open ?? true}
63
+ .summary=${changed ? `${changed} ${changed === 1 ? "change" : "changes"}` : nothing}
64
+ .body=${html `<div class="aster-form-grid" style=${styleMap({ "--columns": String(section.columns ?? 2) })}>
65
+ ${section.items.map((item) => this.#item(model, item))}
66
+ </div>`}></aster-section>`;
67
+ })}`;
68
+ }
69
+ #item(model, item) {
70
+ const span = typeof item === "object" ? (item.span ?? 1) : 1;
71
+ if (typeof item === "object" && "value" in item) {
72
+ return html `<aster-field style=${styleMap({ "--span": String(span) })} .labelled=${true} .label=${item.title}
73
+ .value=${item.value.value} .readOnly=${true}></aster-field>`;
74
+ }
75
+ const options = typeof item === "object" ? item.options : undefined;
76
+ return controlFor(model, nameOf(item), { options, labelled: true, span, sharedReasons: this.#shared });
77
+ }
78
+ }
79
+ function isField(item) {
80
+ return typeof item === "string" || "field" in item;
81
+ }
82
+ function nameOf(item) {
83
+ return typeof item === "string" ? item : item.field;
84
+ }
85
+ define("aster-form", Form);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A short relative time: "Just now", "18 min ago", "1 h ago", "Yesterday",
3
+ * a weekday within the week, then a date. Always pair it with
4
+ * `absoluteTime` on hover.
5
+ */
6
+ export declare function relativeTime(iso: string, now: number, timeZone?: string): string;
7
+ /** "Wed 7 Oct 2026, 14:57". */
8
+ export declare function absoluteTime(iso: string, timeZone?: string): string;
9
+ /** "£41.2k", "£980", "£1.3m": compact, for figures. Never truncated. */
10
+ export declare function compactMoney(amount: number, currency?: string): string;
package/dist/format.js ADDED
@@ -0,0 +1,57 @@
1
+ const MINUTE = 60_000;
2
+ const HOUR = 60 * MINUTE;
3
+ /**
4
+ * A short relative time: "Just now", "18 min ago", "1 h ago", "Yesterday",
5
+ * a weekday within the week, then a date. Always pair it with
6
+ * `absoluteTime` on hover.
7
+ */
8
+ export function relativeTime(iso, now, timeZone) {
9
+ const t = Date.parse(iso);
10
+ const ago = now - t;
11
+ if (ago < MINUTE)
12
+ return "Just now";
13
+ if (ago < HOUR)
14
+ return `${Math.floor(ago / MINUTE)} min ago`;
15
+ const days = dayNumber(now, timeZone) - dayNumber(t, timeZone);
16
+ if (days === 0)
17
+ return `${Math.floor(ago / HOUR)} h ago`;
18
+ if (days === 1)
19
+ return "Yesterday";
20
+ const opts = timeZone ? { timeZone } : {};
21
+ if (days < 7)
22
+ return new Intl.DateTimeFormat("en-GB", { weekday: "short", ...opts }).format(t);
23
+ return new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...opts }).format(t);
24
+ }
25
+ /** "Wed 7 Oct 2026, 14:57". */
26
+ export function absoluteTime(iso, timeZone) {
27
+ const parts = new Intl.DateTimeFormat("en-GB", {
28
+ weekday: "short",
29
+ day: "numeric",
30
+ month: "short",
31
+ year: "numeric",
32
+ hour: "2-digit",
33
+ minute: "2-digit",
34
+ hourCycle: "h23",
35
+ ...(timeZone ? { timeZone } : {}),
36
+ }).formatToParts(Date.parse(iso));
37
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
38
+ return `${get("weekday")} ${get("day")} ${get("month")} ${get("year")}, ${get("hour")}:${get("minute")}`;
39
+ }
40
+ /** Whole days since the epoch in the time zone, for "yesterday" and weekday names. */
41
+ function dayNumber(t, timeZone) {
42
+ const p = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone ? { timeZone } : {}) }).format(t);
43
+ return Math.floor(Date.parse(`${p}T00:00:00Z`) / 86_400_000);
44
+ }
45
+ /** "£41.2k", "£980", "£1.3m": compact, for figures. Never truncated. */
46
+ export function compactMoney(amount, currency = "GBP") {
47
+ return new Intl.NumberFormat("en-GB", {
48
+ style: "currency",
49
+ currency,
50
+ notation: "compact",
51
+ maximumFractionDigits: 1,
52
+ })
53
+ .format(amount)
54
+ .replace(/K$/, "k")
55
+ .replace(/M$/, "m")
56
+ .replace(/^-/, "−");
57
+ }
@@ -0,0 +1,16 @@
1
+ import type { Criteria, Field } from "@getaster/data";
2
+ /**
3
+ * A filter typed into a number or date column: `5`, `>5`, `>=5`, `<5`,
4
+ * `<=5`, or a range `1..3`. Returns undefined for empty text, and an error
5
+ * message for text that isn't one of these.
6
+ */
7
+ export declare function parseRange(field: Field, text: string): {
8
+ criteria?: Criteria;
9
+ error?: string;
10
+ };
11
+ /** The text a range filter shows for its criteria, so a restored view shows what was typed. */
12
+ export declare function rangeText(criteria: Criteria | undefined): string;
13
+ /** Text contains, ignoring case. */
14
+ export declare function containsText(field: string, text: string): Criteria | undefined;
15
+ /** A filter's text with its date replaced by `date`, keeping an operator typed before it. */
16
+ export declare function withDate(text: string, date: string): string;
@@ -0,0 +1,66 @@
1
+ import { parseDate, today } from "../date/dates.js";
2
+ /**
3
+ * A filter typed into a number or date column: `5`, `>5`, `>=5`, `<5`,
4
+ * `<=5`, or a range `1..3`. Returns undefined for empty text, and an error
5
+ * message for text that isn't one of these.
6
+ */
7
+ export function parseRange(field, text) {
8
+ const t = text.trim();
9
+ if (t === "")
10
+ return {};
11
+ const literal = (s) => {
12
+ const v = s.trim();
13
+ if (field.type === "Edm.Date")
14
+ return parseDate(v, Number(today().slice(0, 4)));
15
+ if (!/^-?\d+(\.\d+)?$/.test(v))
16
+ return undefined;
17
+ return field.type === "Edm.Decimal" || field.type === "Edm.Int64" ? v : Number(v);
18
+ };
19
+ const hint = field.type === "Edm.Date" ? "a date like 1 Oct 2026" : "a number";
20
+ const range = /^(.+?)\.\.(.+)$/.exec(t);
21
+ if (range) {
22
+ const from = literal(range[1]);
23
+ const to = literal(range[2]);
24
+ if (from === undefined || to === undefined)
25
+ return { error: `Use ${hint} on both sides of ..` };
26
+ return {
27
+ criteria: {
28
+ op: "and",
29
+ args: [
30
+ { op: "ge", field: field.name, value: from },
31
+ { op: "le", field: field.name, value: to },
32
+ ],
33
+ },
34
+ };
35
+ }
36
+ const m = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(t);
37
+ const value = literal(m[2]);
38
+ if (value === undefined)
39
+ return { error: `Use ${hint}, optionally after >, >=, < or <=, or a range a..b` };
40
+ const op = { ">=": "ge", "<=": "le", ">": "gt", "<": "lt", "=": "eq" }[m[1]] ?? "eq";
41
+ return { criteria: { op, field: field.name, value } };
42
+ }
43
+ /** The text a range filter shows for its criteria, so a restored view shows what was typed. */
44
+ export function rangeText(criteria) {
45
+ if (!criteria)
46
+ return "";
47
+ if (criteria.op === "and" && criteria.args.length === 2) {
48
+ const [a, b] = criteria.args;
49
+ if (a && b && "value" in a && "value" in b)
50
+ return `${a.value}..${b.value}`;
51
+ }
52
+ if ("value" in criteria && criteria.op !== "contains") {
53
+ const sign = { eq: "", gt: ">", ge: ">=", lt: "<", le: "<=" }[criteria.op] ?? "";
54
+ return `${sign}${criteria.value}`;
55
+ }
56
+ return "";
57
+ }
58
+ /** Text contains, ignoring case. */
59
+ export function containsText(field, text) {
60
+ return text.trim() === "" ? undefined : { op: "contains", field, value: text.trim(), caseInsensitive: true };
61
+ }
62
+ /** A filter's text with its date replaced by `date`, keeping an operator typed before it. */
63
+ export function withDate(text, date) {
64
+ const op = /^\s*(>=|<=|>|<|=)/.exec(text)?.[1] ?? "";
65
+ return `${op}${date}`;
66
+ }
@@ -0,0 +1,209 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import { type ProgressPolicy, type CsvOptions, type Aggregate, Tree, type Collection, type Selection, type FilterMode, type FieldRules, type Criteria, type DataContext, type Entity, type Field, type Grouping, type KeyText, type Value } from "@getaster/data";
3
+ import { AsterElement, type View } from "../base/element.ts";
4
+ import type { MenuItem } from "../menu/menu.ts";
5
+ import "../menu/menu.ts";
6
+ import "../progress/status.ts";
7
+ import "../select/choice-element.ts";
8
+ import "../filter/filter-builder.ts";
9
+ import "../dialog/dialog.ts";
10
+ import { type GridView } from "./view.ts";
11
+ /** Renders a cell's content; `entity` is the row's record. */
12
+ export type CellRenderer = (entity: Entity, key: KeyText) => View;
13
+ /** Text presentation of a typed value. Queries and exports retain the underlying value. */
14
+ export type CellFormatter = (value: Value | undefined, entity: Entity, field: Field | undefined) => string;
15
+ /** How a column shows a foreign key: the related set and the field to show. */
16
+ export interface Lookup {
17
+ readonly set: string;
18
+ readonly display: string;
19
+ }
20
+ /** An exact total over the grid query; count needs no field. */
21
+ export type GridSummary = {
22
+ readonly method: "count";
23
+ readonly label?: string;
24
+ } | {
25
+ readonly method: Aggregate["method"];
26
+ readonly field: string;
27
+ readonly label?: string;
28
+ };
29
+ /** Export visible metadata columns, in view order. */
30
+ export interface GridCsvOptions extends Omit<CsvOptions, "keys"> {
31
+ /** All filtered results by default, or shared selection in pick order. */
32
+ readonly scope?: "query" | "selection";
33
+ }
34
+ /** A row as row controls and expansion content see it; read inside a render, it updates them. */
35
+ export interface RowState {
36
+ readonly key: KeyText;
37
+ readonly entity: Entity;
38
+ readonly expanded: boolean;
39
+ readonly editable: boolean;
40
+ }
41
+ /** A button of a row's floating controls. */
42
+ export interface RowControl {
43
+ readonly label: string;
44
+ readonly icon: View;
45
+ readonly run: () => void;
46
+ }
47
+ /**
48
+ * The content a row expands into. Its state outlives the DOM: scrolled out
49
+ * of view and back, it's the same editor. `dirty`, `save` and `discard` let
50
+ * the grid ask before closing over unsaved changes.
51
+ */
52
+ export interface ExpansionEditor {
53
+ readonly view: View;
54
+ readonly dirty?: ReadonlySignal<boolean>;
55
+ save?(): Promise<boolean>;
56
+ discard?(): void;
57
+ dispose?(): void;
58
+ }
59
+ export interface Expansion {
60
+ content(row: RowState): ExpansionEditor;
61
+ /** Whether a record may expand; all may by default. */
62
+ canExpand?(entity: Entity): boolean;
63
+ /** Expanding a row closes the one already open. */
64
+ single?: boolean;
65
+ /** At most this many open at once; another is refused with a message. */
66
+ limit?: number;
67
+ /** The record's name in messages ("task"). */
68
+ noun?: string;
69
+ }
70
+ /** Makes the grid a tree grid over a self-referencing set. */
71
+ export interface GridTree {
72
+ /** The field holding a record's parent key. */
73
+ readonly parentField: string;
74
+ /** The parent value of top-level records; null by default. */
75
+ readonly rootValue?: Value;
76
+ /** `keepAncestors` (the default) shows a filter's matches with the paths to them. */
77
+ readonly filterMode?: FilterMode;
78
+ /** Rows can be dragged onto another row to move under it, or onto the header to the top level; a touch or pen holds a row still to lift it. */
79
+ readonly draggable?: boolean;
80
+ }
81
+ /**
82
+ * A virtualized grid over an entity set, shaped by a view the person can
83
+ * change: sort, filter, hide, reorder, resize, freeze; their view is kept
84
+ * per browser with `persist`, and named views can be saved.
85
+ *
86
+ * Layout is CSS: the columns are one `grid-template-columns`, each row is a
87
+ * subgrid, the header and frozen columns are sticky. JS keeps only the rows
88
+ * near the viewport in the DOM (spacers stand for the rest), measures row
89
+ * heights with a ResizeObserver, and writes column tracks as a variable.
90
+ *
91
+ * The collection's query follows the view; rows load by page as they come
92
+ * into view, and ones not loaded yet show as placeholders. Focus is on the
93
+ * grid; its active cell is `aria-activedescendant`. Shared picks are separate
94
+ * from focus; an owned grid without shared selection chooses the active row.
95
+ */
96
+ export declare class Grid extends AsterElement {
97
+ #private;
98
+ get ctx(): DataContext | undefined;
99
+ set ctx(v: DataContext | undefined);
100
+ /** The entity set the grid shows. */
101
+ get set(): string;
102
+ set set(v: string);
103
+ /** Criteria every row meets, beneath the view's own filters. */
104
+ get base(): Criteria | undefined;
105
+ set base(v: Criteria | undefined);
106
+ /** The view in use. Set by the page, it's also what Reset goes back to. */
107
+ get view(): GridView | undefined;
108
+ set view(v: GridView | undefined);
109
+ get label(): string;
110
+ set label(v: string);
111
+ /** The page-scope key for the view and selection. */
112
+ get publish(): string;
113
+ set publish(v: string);
114
+ /** Keeps the person's views in this browser under this name. */
115
+ get persist(): string;
116
+ set persist(v: string);
117
+ /** Cell content by field, where the default formatting isn't enough. */
118
+ get renderers(): Readonly<Record<string, CellRenderer>>;
119
+ set renderers(v: Readonly<Record<string, CellRenderer>>);
120
+ /** Text-only display formatting by field; never changes server sorting or filtering. */
121
+ get formatters(): Readonly<Record<string, CellFormatter>>;
122
+ set formatters(value: Readonly<Record<string, CellFormatter>>);
123
+ /** Exact overall totals and, when grouped, totals on each group header. */
124
+ get summaries(): readonly GridSummary[];
125
+ set summaries(value: readonly GridSummary[]);
126
+ /** Fresh server values; custom DOM renderers and lookup labels are not exported. */
127
+ exportCsv(options?: GridCsvOptions): Promise<string>;
128
+ /** Foreign keys shown by a related record's field, by column. */
129
+ get lookups(): Readonly<Record<string, Lookup>>;
130
+ set lookups(v: Readonly<Record<string, Lookup>>);
131
+ /** The page's actions for a row, shown first in its menu (right-click, Shift+F10, the Menu key). */
132
+ get rowMenu(): (entity: Entity, key: KeyText) => readonly MenuItem[];
133
+ set rowMenu(v: (entity: Entity, key: KeyText) => readonly MenuItem[]);
134
+ /**
135
+ * A tree grid: the first column shows each record's level with an opener;
136
+ * levels load as they open; a filter keeps the path to each match. Set it
137
+ * before the grid connects.
138
+ */
139
+ get tree(): GridTree | undefined;
140
+ set tree(v: GridTree | undefined);
141
+ /** The lazy group hierarchy of an owned grouped grid. */
142
+ get groupingModel(): Grouping | undefined;
143
+ /** The tree model, in a tree grid: open state, reveal, move. */
144
+ get treeModel(): Tree | undefined;
145
+ /**
146
+ * Floating controls for each row, recomputed from its state. They show on
147
+ * hover, on the selected and the active row; Tab reaches them from the
148
+ * grid, ←/→ move between them, Escape goes back.
149
+ */
150
+ get rowControls(): ((row: RowState) => readonly RowControl[]) | undefined;
151
+ set rowControls(v: ((row: RowState) => readonly RowControl[]) | undefined);
152
+ /** Rows expand in place into content the page supplies (double-click, or `expand`). */
153
+ get expansion(): Expansion | undefined;
154
+ set expansion(v: Expansion | undefined);
155
+ /** Keys of the expanded rows, in the order they opened. */
156
+ get expanded(): ReadonlySignal<readonly KeyText[]>;
157
+ /**
158
+ * Expands a row and moves focus into its content. Refused (false) for a
159
+ * record that can't expand, or over the limit, which says so.
160
+ */
161
+ expand(key: KeyText): boolean;
162
+ /**
163
+ * Collapses a row. `ask` (the default) asks first when it has unsaved
164
+ * changes; `discard` drops them on purpose; `save` saves, and stays open
165
+ * if that fails.
166
+ */
167
+ collapse(key: KeyText, how?: "ask" | "discard" | "save"): Promise<boolean>;
168
+ /**
169
+ * In a tree grid, opens the path to a record, makes its row active and
170
+ * scrolls it to the top. Resolves to whether the record is in the tree.
171
+ */
172
+ reveal(key: KeyText): Promise<boolean>;
173
+ /** Rows can be edited in place: Enter, F2 or a double-click start, Enter saves, Escape cancels. */
174
+ get editable(): boolean;
175
+ set editable(v: boolean);
176
+ /**
177
+ * Page rules for the cells of a row being edited. Each row has a child
178
+ * scope where it's published as `row`, so a rule reads its own record.
179
+ */
180
+ get rules(): Readonly<Record<string, FieldRules>>;
181
+ set rules(v: Readonly<Record<string, FieldRules>>);
182
+ get progressPolicy(): ProgressPolicy;
183
+ set progressPolicy(value: ProgressPolicy);
184
+ /** Shown when the query matches nothing. */
185
+ get empty(): View;
186
+ set empty(v: View);
187
+ get collection(): Collection | undefined;
188
+ set collection(v: Collection | undefined);
189
+ /** Shared selection; without collection, its collection supplies the binding. */
190
+ get selection(): Selection | undefined;
191
+ /** Allows this view to change selection; borrowed selection state remains visible. */
192
+ get selectable(): boolean;
193
+ set selectable(value: boolean);
194
+ set selection(v: Selection | undefined);
195
+ /** Navigation key, independent of a shared selection. */
196
+ get activeKey(): KeyText | undefined;
197
+ /** Saves an inline edit before an owner changes or removes this view. False keeps invalid/failed edits open. */
198
+ finishEditing(): Promise<boolean>;
199
+ /** The selected record's key. */
200
+ get selected(): KeyText | undefined;
201
+ /** Loads and focuses a flat collection position without changing shared selection. */
202
+ focusIndex(index: number, column?: number): Promise<boolean>;
203
+ /** Reveals a key currently loaded in the flat collection. */
204
+ focusKey(key: KeyText, column?: number): Promise<boolean>;
205
+ connectedCallback(): void;
206
+ focus(options?: FocusOptions): void;
207
+ protected render(): View;
208
+ protected updated(): void;
209
+ }