@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,122 @@
1
+ import { signal } from "@preact/signals-core";
2
+ import { html, nothing } from "lit-html";
3
+ import { anchorStyle, openWhen, placeStyle } from "../overlay/anchor.js";
4
+ /** Whether a choice's list is open, and which option is active in it. */
5
+ export class ChoiceState {
6
+ open = signal(false);
7
+ active = signal(0);
8
+ }
9
+ /**
10
+ * A select-only combobox: the value in a frame, and a list drawn like the
11
+ * picker's rather than by the platform.
12
+ *
13
+ * Keyboard: ↓/↑, Enter or Space open; in the list ↓/↑, Home and End move,
14
+ * a letter jumps to the next label starting with it, Enter or Space choose,
15
+ * Escape and Tab close. The active option is `aria-activedescendant`.
16
+ */
17
+ export function choiceView(state, p) {
18
+ const choices = p.options;
19
+ const current = p.value ?? null;
20
+ const chosen = choices.findIndex((o) => o.value === current);
21
+ const shown = chosen >= 0 ? choices[chosen].label : current === null || current === "" ? p.placeholder : String(current);
22
+ const open = state.open.value && !p.readOnly;
23
+ const active = Math.min(state.active.value, choices.length - 1);
24
+ const listId = `${p.id}-options`;
25
+ const optionId = (i) => `${p.id}-option-${i}`;
26
+ const choose = (i) => {
27
+ const o = choices[i];
28
+ if (o)
29
+ p.choose(o.value);
30
+ state.open.value = false;
31
+ };
32
+ const show = () => {
33
+ if (p.readOnly)
34
+ return;
35
+ state.active.value = Math.max(0, chosen);
36
+ state.open.value = true;
37
+ };
38
+ return html `<div class="aster-picker">
39
+ <div class="aster-field aster-select" data-ring-owner style=${anchorStyle(p.id)}
40
+ @click=${(e) => {
41
+ e.currentTarget.querySelector("[role=combobox]")?.focus();
42
+ if (state.open.peek())
43
+ state.open.value = false;
44
+ else
45
+ show();
46
+ }}>
47
+ <div id=${p.id} class="aster-select-value" role="combobox" tabindex="0"
48
+ aria-label=${p.label ?? nothing}
49
+ aria-labelledby=${p.labelledBy ?? nothing}
50
+ aria-haspopup="listbox" aria-expanded=${open ? "true" : "false"} aria-controls=${listId}
51
+ aria-activedescendant=${open ? optionId(active) : nothing}
52
+ aria-readonly=${p.readOnly ? "true" : nothing}
53
+ aria-required=${p.required ? "true" : nothing}
54
+ aria-invalid=${p.invalid ? "true" : nothing}
55
+ aria-describedby=${p.describedBy ?? nothing}
56
+ ?data-placeholder=${chosen < 0}
57
+ @blur=${() => (state.open.value = false)}
58
+ @keydown=${(e) => {
59
+ if (p.readOnly)
60
+ return;
61
+ const n = choices.length;
62
+ if (!state.open.peek()) {
63
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
64
+ e.preventDefault();
65
+ show();
66
+ }
67
+ return;
68
+ }
69
+ const move = (i) => {
70
+ e.preventDefault();
71
+ state.active.value = i;
72
+ };
73
+ switch (e.key) {
74
+ case "ArrowDown":
75
+ return move(Math.min(n - 1, active + 1));
76
+ case "ArrowUp":
77
+ return move(Math.max(0, active - 1));
78
+ case "Home":
79
+ return move(0);
80
+ case "End":
81
+ return move(n - 1);
82
+ case "Enter":
83
+ case " ":
84
+ e.preventDefault();
85
+ return choose(active);
86
+ case "Escape":
87
+ e.preventDefault();
88
+ e.stopPropagation();
89
+ state.open.value = false;
90
+ return;
91
+ case "Tab":
92
+ state.open.value = false;
93
+ return;
94
+ default: {
95
+ if (e.key.length !== 1 || !/\S/.test(e.key))
96
+ return;
97
+ const k = e.key.toLowerCase();
98
+ for (let step = 1; step <= n; step++) {
99
+ const i = (active + step) % n;
100
+ if (choices[i].label.toLowerCase().startsWith(k))
101
+ return move(i);
102
+ }
103
+ }
104
+ }
105
+ }}>${shown}</div>
106
+ <svg class="aster-picker-chevron" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.8 5 6.8l3-3"/></svg>
107
+ </div>
108
+ <div class="aster-picker-list aster-popover" popover="manual" style=${placeStyle(p.id)} ${openWhen(open)}
109
+ id=${listId} role="listbox" aria-label=${p.label ?? nothing} aria-labelledby=${p.labelledBy ?? nothing}>
110
+ ${choices.map((o, i) => html `<div class="aster-option" role="option" id=${optionId(i)}
111
+ aria-selected=${i === chosen ? "true" : "false"} ?data-active=${i === active}
112
+ @pointerdown=${(ev) => ev.preventDefault()}
113
+ @pointerup=${(ev) => {
114
+ // WebKit suppresses touch clicks after pointerdown is cancelled to retain focus.
115
+ if (ev.pointerType === "touch" && state.open.peek())
116
+ choose(i);
117
+ }}
118
+ @click=${() => { if (state.open.peek())
119
+ choose(i); }}>${o.label}</div>`)}
120
+ </div>
121
+ </div>`;
122
+ }
@@ -0,0 +1,13 @@
1
+ import type { Value } from "@getaster/data";
2
+ import type { Option } from "./choice.ts";
3
+ /** The same finite multi-choice control for record rules and query drafts. */
4
+ export declare function manyView(p: {
5
+ readonly label: string;
6
+ readonly options: readonly Option[];
7
+ readonly values: readonly Value[];
8
+ readonly change: (values: readonly Value[]) => void;
9
+ readonly readOnly?: boolean;
10
+ readonly invalid?: boolean;
11
+ readonly describedBy?: string;
12
+ readonly pending?: boolean;
13
+ }): import("lit-html").TemplateResult<1>;
@@ -0,0 +1,20 @@
1
+ import { html, nothing } from "lit-html";
2
+ /** The same finite multi-choice control for record rules and query drafts. */
3
+ export function manyView(p) {
4
+ const missing = p.values.filter((v) => !p.options.some((o) => o.value === v));
5
+ const options = [...p.options, ...missing.map((value) => ({ value, label: `Unavailable: ${String(value)}` }))];
6
+ return html `<fieldset class="aster-rule-many" tabindex="-1" aria-invalid=${p.invalid ? "true" : nothing}
7
+ aria-describedby=${p.describedBy ?? nothing} ?disabled=${p.readOnly} ?data-pending=${p.pending}>
8
+ <legend class="aster-sr-only">${p.label}</legend>
9
+ ${options.map((o) => html `<label><input type="checkbox" .checked=${p.values.includes(o.value)}
10
+ @change=${(e) => {
11
+ const input = e.target;
12
+ const group = input.closest("fieldset");
13
+ p.change(input.checked ? [...p.values, o.value] : p.values.filter((v) => v !== o.value));
14
+ queueMicrotask(() => {
15
+ if (!input.isConnected && group?.isConnected)
16
+ (group.querySelector("input") ?? group).focus();
17
+ });
18
+ }}>${o.label}</label>`)}
19
+ </fieldset>`;
20
+ }
@@ -0,0 +1,16 @@
1
+ import { type View } from "../base/element.ts";
2
+ import { BoundField, type ControlContext } from "../field/bound-field.ts";
3
+ import { type Option } from "./choice.ts";
4
+ /**
5
+ * One of a field's allowed values (`Validation.AllowedValues`), labelled from
6
+ * metadata; a nullable field also offers "None". `choices` replaces them,
7
+ * such as the operators that suit another field's type. A select-only
8
+ * combobox (see `choiceView`), so its list matches the picker's.
9
+ */
10
+ export declare class Select extends BoundField {
11
+ #private;
12
+ get choices(): readonly Option[] | undefined;
13
+ set choices(v: readonly Option[] | undefined);
14
+ focus(options?: FocusOptions): void;
15
+ protected control(c: ControlContext): View;
16
+ }
@@ -0,0 +1,40 @@
1
+ import { define } from "../base/element.js";
2
+ import { BoundField } from "../field/bound-field.js";
3
+ import { ChoiceState, choiceView } from "./choice.js";
4
+ /**
5
+ * One of a field's allowed values (`Validation.AllowedValues`), labelled from
6
+ * metadata; a nullable field also offers "None". `choices` replaces them,
7
+ * such as the operators that suit another field's type. A select-only
8
+ * combobox (see `choiceView`), so its list matches the picker's.
9
+ */
10
+ export class Select extends BoundField {
11
+ #state = new ChoiceState();
12
+ #choices = this.prop(undefined);
13
+ get choices() {
14
+ return this.#choices.value;
15
+ }
16
+ set choices(v) {
17
+ this.#choices.value = v;
18
+ }
19
+ focus(options) {
20
+ this.querySelector("[role=combobox]")?.focus(options);
21
+ }
22
+ control(c) {
23
+ return choiceView(this.#state, {
24
+ id: c.id,
25
+ options: this.#choices.value ?? [
26
+ ...(c.meta?.nullable ? [{ value: null, label: "None" }] : []),
27
+ ...(c.meta?.allowedValues ?? []).map((a) => ({ value: a.value, label: a.label ?? String(a.value) })),
28
+ ],
29
+ value: c.value,
30
+ placeholder: "Choose…",
31
+ readOnly: c.state.readOnly,
32
+ ...(this.labelled ? { labelledBy: `${c.id}-label` } : { label: c.label }),
33
+ required: c.state.required,
34
+ invalid: c.invalid,
35
+ ...(c.describedBy ? { describedBy: c.describedBy } : {}),
36
+ choose: (v) => this.write(v),
37
+ });
38
+ }
39
+ }
40
+ define("aster-select", Select);
@@ -0,0 +1,47 @@
1
+ import { type View } from "../base/element.ts";
2
+ export interface SidebarItem {
3
+ readonly id: string;
4
+ readonly label: string;
5
+ readonly href?: string;
6
+ readonly badge?: string;
7
+ readonly icon?: string;
8
+ readonly hidden?: boolean;
9
+ readonly disabled?: boolean;
10
+ readonly reason?: string;
11
+ readonly children?: readonly SidebarItem[];
12
+ }
13
+ /** Navigation URLs are limited to web, relative and fragment destinations. */
14
+ export declare function sidebarHref(value: string): boolean;
15
+ export declare const sidebarItems: import("../index.ts").CompositeType<readonly SidebarItem[]>;
16
+ /** Controlled navigation: hosts own routing, permissions and selection policy. */
17
+ export declare const Sidebar: import("../index.ts").DeclaredElement<{
18
+ readonly label: {
19
+ readonly type: import("../index.ts").CompositeType<string>;
20
+ readonly initial: () => string;
21
+ readonly description: string;
22
+ };
23
+ readonly items: {
24
+ readonly type: import("../index.ts").CompositeType<readonly SidebarItem[]>;
25
+ readonly initial: () => readonly SidebarItem[];
26
+ readonly description: string;
27
+ };
28
+ readonly value: {
29
+ readonly type: Omit<import("../index.ts").CompositeType<string>, "validate"> & import("../index.ts").CompositeType<string | undefined> & {
30
+ readonly optional: true;
31
+ };
32
+ readonly initial: () => string | undefined;
33
+ readonly description: string;
34
+ };
35
+ readonly renderIcon: {
36
+ readonly type: Omit<import("../index.ts").CompositeType<(item: SidebarItem) => View>, "validate"> & import("../index.ts").CompositeType<((item: SidebarItem) => View) | undefined> & {
37
+ readonly optional: true;
38
+ };
39
+ readonly initial: () => ((item: SidebarItem) => View) | undefined;
40
+ readonly description: string;
41
+ };
42
+ readonly compact: {
43
+ readonly type: import("../index.ts").CompositeType<boolean>;
44
+ readonly initial: () => boolean;
45
+ readonly description: string;
46
+ };
47
+ }>;
@@ -0,0 +1,105 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { each } from "../base/element.js";
3
+ import { defineElement, elementProperty } from "../composition/element.js";
4
+ import { compositeTypes as t } from "../composition/contracts.js";
5
+ /** Navigation URLs are limited to web, relative and fragment destinations. */
6
+ export function sidebarHref(value) {
7
+ try {
8
+ return ["http:", "https:"].includes(new URL(value, "https://aster.invalid/").protocol);
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ function itemsValid(value) {
15
+ const ids = new Set();
16
+ const visit = (items, depth) => Array.isArray(items) && depth < 16 && items.every(item => {
17
+ if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id || ids.has(item.id) || typeof item.label !== "string" || !item.label.trim())
18
+ return false;
19
+ ids.add(item.id);
20
+ const keys = ["id", "label", "href", "badge", "icon", "hidden", "disabled", "reason", "children"];
21
+ return Object.keys(item).every(key => keys.includes(key))
22
+ && ["badge", "icon", "reason"].every(key => item[key] === undefined || typeof item[key] === "string")
23
+ && ["hidden", "disabled"].every(key => item[key] === undefined || typeof item[key] === "boolean")
24
+ && (item.href === undefined || typeof item.href === "string" && sidebarHref(item.href))
25
+ && (item.children === undefined || item.href === undefined && visit(item.children, depth + 1));
26
+ });
27
+ return visit(value, 0);
28
+ }
29
+ const itemSchema = {
30
+ type: "object", additionalProperties: false, required: ["id", "label"],
31
+ properties: {
32
+ id: { type: "string", minLength: 1 }, label: { type: "string", minLength: 1 },
33
+ href: { type: "string" }, badge: { type: "string" }, icon: { type: "string" },
34
+ hidden: { type: "boolean" }, disabled: { type: "boolean" }, reason: { type: "string" },
35
+ children: { type: "array", items: { type: "object" } },
36
+ },
37
+ };
38
+ export const sidebarItems = t.custom("navigation items with unique IDs and safe URLs", itemsValid, {
39
+ type: "array", items: itemSchema,
40
+ });
41
+ /** Controlled navigation: hosts own routing, permissions and selection policy. */
42
+ export const Sidebar = defineElement({
43
+ tag: "aster-sidebar",
44
+ properties: {
45
+ label: elementProperty(t.string, () => "Navigation", "Accessible navigation name"),
46
+ items: elementProperty(sidebarItems, () => [], "Navigation items; derive visibility and availability in the model"),
47
+ value: elementProperty(t.optional(t.string), () => undefined, "Current item ID"),
48
+ renderIcon: elementProperty(t.optional(t.custom("navigation icon renderer", (value) => typeof value === "function", { asterType: "function" })), () => undefined, "Trusted icon renderer; labels remain text"),
49
+ compact: elementProperty(t.boolean, () => false, "Use an icon rail with accessible labels"),
50
+ },
51
+ render: ({ label, items, value, compact, renderIcon }, element) => {
52
+ const choose = (event, item) => {
53
+ if (item.disabled) {
54
+ event.preventDefault();
55
+ return;
56
+ }
57
+ // Modified link clicks keep native open-in-new-tab behaviour.
58
+ if (item.href && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0))
59
+ return;
60
+ const allowed = element.dispatchEvent(new CustomEvent("navigate", { detail: item.id, bubbles: true, cancelable: true }));
61
+ if (!allowed)
62
+ event.preventDefault();
63
+ };
64
+ const list = (entries, unavailable = false) => html `<ul>
65
+ ${each(entries.filter(item => !item.hidden), item => item.id, item => {
66
+ const disabled = unavailable || !!item.disabled;
67
+ const effective = disabled ? { ...item, disabled: true } : item;
68
+ const content = html `<span class="aster-sidebar-icon" aria-hidden="true">${renderIcon ? renderIcon(item) : item.icon ?? item.label.slice(0, 1)}</span>
69
+ <span class="aster-sidebar-label">${item.label}</span>${item.badge ? html `<span class="aster-sidebar-badge">${item.badge}</span>` : nothing}`;
70
+ return html `<li>${item.children ? html `<details open><summary title=${compact ? item.label : nothing}>${content}</summary>${list(item.children, disabled)}</details>`
71
+ : item.href ? html `<a class="aster-sidebar-item" href=${disabled ? nothing : item.href}
72
+ aria-disabled=${disabled ? "true" : nothing} aria-current=${item.id === value ? "page" : nothing}
73
+ tabindex=${disabled ? "-1" : nothing} title=${item.reason ?? (compact ? item.label : nothing)}
74
+ @click=${(event) => choose(event, effective)}>${content}${item.reason ? html `<span class="aster-sr-only"> ${item.reason}</span>` : nothing}</a>`
75
+ : html `<button class="aster-sidebar-item" type="button" ?disabled=${disabled}
76
+ aria-current=${item.id === value ? "page" : nothing} title=${item.reason ?? (compact ? item.label : nothing)}
77
+ @click=${(event) => choose(event, effective)}>${content}${item.reason ? html `<span class="aster-sr-only"> ${item.reason}</span>` : nothing}</button>`}</li>`;
78
+ })}</ul>`;
79
+ return html `<nav class="aster-sidebar" aria-label=${label} ?data-compact=${compact}
80
+ @keydown=${(event) => {
81
+ if (!(event.target instanceof HTMLElement) || !event.target.matches("a, button, summary"))
82
+ return;
83
+ const controls = [...element.querySelectorAll("a[href],button:not(:disabled),summary")].filter(node => node.getClientRects().length > 0);
84
+ const index = controls.indexOf(event.target);
85
+ let next;
86
+ switch (event.key) {
87
+ case "ArrowDown":
88
+ next = Math.min(index + 1, controls.length - 1);
89
+ break;
90
+ case "ArrowUp":
91
+ next = Math.max(0, index - 1);
92
+ break;
93
+ case "Home":
94
+ next = 0;
95
+ break;
96
+ case "End":
97
+ next = controls.length - 1;
98
+ break;
99
+ default: return;
100
+ }
101
+ event.preventDefault();
102
+ controls[next]?.focus();
103
+ }}>${list(items)}</nav>`;
104
+ },
105
+ });
@@ -0,0 +1,26 @@
1
+ import type { SharedFilters } from "@getaster/data";
2
+ import { AsterElement, type View } from "../base/element.ts";
3
+ /**
4
+ * The page's shared filter as chips: one per contribution, said in words,
5
+ * each removing its own. It's a toolbar (WAI-ARIA APG): one tab stop; ←, →,
6
+ * Home and End move between chips; Enter, Space, Delete and Backspace remove
7
+ * the focused one; focus then moves to its neighbour, or to the "no
8
+ * filters" text when none is left. "Clear all" is the last item when there
9
+ * are two or more. Every removal is announced.
10
+ *
11
+ * A contribution whose contributor gave no way to withdraw it (`removable`
12
+ * false) shows as a chip that can't be pressed, and says so.
13
+ */
14
+ export declare class Slicer extends AsterElement {
15
+ #private;
16
+ get filters(): SharedFilters | undefined;
17
+ set filters(v: SharedFilters | undefined);
18
+ /** Names the toolbar, and shows before the chips. */
19
+ get label(): string;
20
+ set label(v: string);
21
+ /** Shown, and focused after the last removal, when there are no contributions. */
22
+ get empty(): string;
23
+ set empty(v: string);
24
+ protected render(): View;
25
+ protected updated(): void;
26
+ }
@@ -0,0 +1,140 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { AsterElement, define, each, uid } from "../base/element.js";
3
+ import { announce } from "../toast/toast.js";
4
+ const CROSS = html `<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 2l6 6M8 2l-6 6" /></svg>`;
5
+ /**
6
+ * The page's shared filter as chips: one per contribution, said in words,
7
+ * each removing its own. It's a toolbar (WAI-ARIA APG): one tab stop; ←, →,
8
+ * Home and End move between chips; Enter, Space, Delete and Backspace remove
9
+ * the focused one; focus then moves to its neighbour, or to the "no
10
+ * filters" text when none is left. "Clear all" is the last item when there
11
+ * are two or more. Every removal is announced.
12
+ *
13
+ * A contribution whose contributor gave no way to withdraw it (`removable`
14
+ * false) shows as a chip that can't be pressed, and says so.
15
+ */
16
+ export class Slicer extends AsterElement {
17
+ #filters = this.prop(undefined);
18
+ #label = this.prop("Filters");
19
+ #empty = this.prop("None. Showing everything.");
20
+ /** The roving tab stop, by position among the toolbar's items. */
21
+ #active = this.prop(0);
22
+ #id = uid("aster-slicer");
23
+ /** Where focus goes after the next commit: an item's position, or the empty text. */
24
+ #refocus;
25
+ get filters() {
26
+ return this.#filters.value;
27
+ }
28
+ set filters(v) {
29
+ this.#filters.value = v;
30
+ }
31
+ /** Names the toolbar, and shows before the chips. */
32
+ get label() {
33
+ return this.#label.value;
34
+ }
35
+ set label(v) {
36
+ this.#label.value = v;
37
+ }
38
+ /** Shown, and focused after the last removal, when there are no contributions. */
39
+ get empty() {
40
+ return this.#empty.value;
41
+ }
42
+ set empty(v) {
43
+ this.#empty.value = v;
44
+ }
45
+ render() {
46
+ const filters = this.#filters.value;
47
+ if (!filters)
48
+ return nothing;
49
+ const items = filters.contributions.value;
50
+ const label = html `<span class="aster-slicer-label" id=${`${this.#id}-label`}>${this.#label.value}</span>`;
51
+ if (items.length === 0) {
52
+ return html `<div class="aster-slicer">${label}<p class="aster-slicer-empty" tabindex="-1" aria-describedby=${`${this.#id}-label`}>${this.#empty.value}</p></div>`;
53
+ }
54
+ const count = items.length + (items.length > 1 ? 1 : 0);
55
+ const active = Math.min(this.#active.value, count - 1);
56
+ return html `<div class="aster-slicer">
57
+ ${label}
58
+ <div class="aster-slicer-chips" role="toolbar" aria-labelledby=${`${this.#id}-label`} @keydown=${(e) => this.#key(e, count)}>
59
+ ${each(items, (c) => c.by, (c) => {
60
+ const i = items.indexOf(c);
61
+ const removable = filters.removable(c.by);
62
+ return html `<button type="button" class="aster-slicer-chip" data-index=${i} tabindex=${i === active ? 0 : -1}
63
+ aria-label=${removable ? `Remove filter: ${c.label}` : `Filter: ${c.label}, set by its source`}
64
+ aria-disabled=${removable ? nothing : "true"}
65
+ @click=${() => this.#remove(c, i)} @focus=${() => (this.#active.value = i)}>
66
+ <span>${c.label}</span>${removable ? CROSS : nothing}
67
+ </button>`;
68
+ })}
69
+ ${items.length > 1
70
+ ? html `<button type="button" class="aster-slicer-clear" data-index=${items.length} tabindex=${active === items.length ? 0 : -1}
71
+ @click=${() => this.#clearAll()} @focus=${() => (this.#active.value = items.length)}>Clear all</button>`
72
+ : nothing}
73
+ </div>
74
+ </div>`;
75
+ }
76
+ updated() {
77
+ const to = this.#refocus;
78
+ if (to === undefined)
79
+ return;
80
+ this.#refocus = undefined;
81
+ const target = to === "empty" ? this.querySelector(".aster-slicer-empty") : this.querySelector(`[data-index="${to}"]`);
82
+ target?.focus();
83
+ }
84
+ #key(e, count) {
85
+ const at = Number(e.target.closest("[data-index]")?.getAttribute("data-index") ?? 0);
86
+ let to;
87
+ switch (e.key) {
88
+ case "ArrowRight":
89
+ case "ArrowDown":
90
+ to = (at + 1) % count;
91
+ break;
92
+ case "ArrowLeft":
93
+ case "ArrowUp":
94
+ to = (at - 1 + count) % count;
95
+ break;
96
+ case "Home":
97
+ to = 0;
98
+ break;
99
+ case "End":
100
+ to = count - 1;
101
+ break;
102
+ case "Delete":
103
+ case "Backspace":
104
+ e.preventDefault();
105
+ e.target.closest(".aster-slicer-chip")?.click();
106
+ return;
107
+ default:
108
+ return;
109
+ }
110
+ e.preventDefault();
111
+ this.#active.value = to;
112
+ this.querySelector(`[data-index="${to}"]`)?.focus();
113
+ }
114
+ #remove(c, at) {
115
+ const filters = this.#filters.peek();
116
+ if (!filters?.removable(c.by))
117
+ return;
118
+ filters.remove(c.by);
119
+ const left = filters.contributions.peek().length;
120
+ // The neighbour that slid into this place, or the one before it.
121
+ const next = left === 0 ? "empty" : Math.min(at, left - 1);
122
+ this.#active.value = next === "empty" ? 0 : next;
123
+ this.#refocus = next;
124
+ announce(`Removed filter: ${c.label}. ${left === 0 ? "No filters." : left === 1 ? "1 filter left." : `${left} filters left.`}`);
125
+ this.emit("slicer-change", { removed: [c.by] });
126
+ }
127
+ #clearAll() {
128
+ const filters = this.#filters.peek();
129
+ if (!filters)
130
+ return;
131
+ const before = filters.contributions.peek().map((c) => c.by);
132
+ filters.clear();
133
+ const left = filters.contributions.peek().length;
134
+ this.#active.value = 0;
135
+ this.#refocus = left === 0 ? "empty" : 0;
136
+ announce(left === 0 ? "Cleared all filters." : `Cleared the filters that can be removed. ${left === 1 ? "1 filter left." : `${left} filters left.`}`);
137
+ this.emit("slicer-change", { removed: before.filter((b) => !filters.contributions.peek().some((c) => c.by === b)) });
138
+ }
139
+ }
140
+ define("aster-slicer", Slicer);
@@ -0,0 +1,41 @@
1
+ import { AsterElement, type View } from "../base/element.ts";
2
+ /**
3
+ * Two panes side by side with a resize bar between them. The bar is an ARIA
4
+ * `separator`: drag it, or focus it and use ←/→ (Home and End for the
5
+ * limits). The width is clamped to `min` and `max` and written as
6
+ * `--split-start`; CSS lays out the panes and stacks horizontal splits in narrow containers. ← and → follow the text direction.
7
+ * With `persist`, the width is kept per viewer under that name. The width
8
+ * asked for is kept apart from the width shown, which is always inside
9
+ * `min` and `max`, so changing any of them in any order re-clamps it.
10
+ */
11
+ export declare class Split extends AsterElement {
12
+ #private;
13
+ get size(): number;
14
+ set size(v: number);
15
+ get orientation(): "horizontal" | "vertical";
16
+ set orientation(v: "horizontal" | "vertical");
17
+ get collapsible(): boolean;
18
+ set collapsible(v: boolean);
19
+ get collapsed(): boolean;
20
+ set collapsed(v: boolean);
21
+ get responsive(): boolean;
22
+ set responsive(v: boolean);
23
+ disconnectedCallback(): void;
24
+ protected updated(): void;
25
+ get start(): View;
26
+ set start(v: View);
27
+ get end(): View;
28
+ set end(v: View);
29
+ get width(): number;
30
+ set width(v: number);
31
+ get min(): number;
32
+ set min(v: number);
33
+ get max(): number;
34
+ set max(v: number);
35
+ /** The separator's accessible name. */
36
+ get label(): string;
37
+ set label(v: string);
38
+ get persist(): string;
39
+ set persist(v: string);
40
+ protected render(): View;
41
+ }