@getaster/elements 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +13 -4
  3. package/bin/aster-theme-check.js +35 -4
  4. package/dist/base/element.d.ts +8 -1
  5. package/dist/base/element.js +7 -2
  6. package/dist/button/button.d.ts +17 -2
  7. package/dist/button/button.js +42 -8
  8. package/dist/composition/builtins.d.ts +623 -1
  9. package/dist/composition/builtins.js +232 -4
  10. package/dist/date/calendar.d.ts +3 -0
  11. package/dist/date/calendar.js +8 -2
  12. package/dist/date/date-field.d.ts +23 -3
  13. package/dist/date/date-field.js +77 -9
  14. package/dist/date/dates.d.ts +10 -2
  15. package/dist/date/dates.js +23 -3
  16. package/dist/dialog/config.d.ts +4 -0
  17. package/dist/dialog/config.js +8 -0
  18. package/dist/dialog/dialog.d.ts +34 -6
  19. package/dist/dialog/dialog.js +151 -14
  20. package/dist/field/bound-field.d.ts +17 -1
  21. package/dist/field/bound-field.js +68 -7
  22. package/dist/field/check.js +1 -0
  23. package/dist/field/field.js +1 -1
  24. package/dist/field/input.d.ts +1 -0
  25. package/dist/field/input.js +2 -2
  26. package/dist/field/number-field.js +2 -2
  27. package/dist/filter/filter-builder.d.ts +7 -1
  28. package/dist/filter/filter-builder.js +8 -1
  29. package/dist/form/controls.d.ts +40 -8
  30. package/dist/form/controls.js +58 -15
  31. package/dist/form/custom-field.d.ts +1 -0
  32. package/dist/form/custom-field.js +23 -0
  33. package/dist/form/features.d.ts +27 -0
  34. package/dist/form/features.js +52 -0
  35. package/dist/form/form.d.ts +67 -7
  36. package/dist/form/form.js +267 -24
  37. package/dist/form/policy.d.ts +29 -0
  38. package/dist/form/policy.js +16 -0
  39. package/dist/grid/appearance.d.ts +365 -0
  40. package/dist/grid/appearance.js +814 -0
  41. package/dist/grid/features.d.ts +38 -0
  42. package/dist/grid/features.js +78 -0
  43. package/dist/grid/grid.d.ts +216 -4
  44. package/dist/grid/grid.js +1853 -301
  45. package/dist/grid/policy.d.ts +58 -0
  46. package/dist/grid/policy.js +17 -0
  47. package/dist/grid/view.d.ts +32 -2
  48. package/dist/grid/view.js +89 -4
  49. package/dist/index.d.ts +14 -11
  50. package/dist/index.js +2 -1
  51. package/dist/menu/config.d.ts +7 -0
  52. package/dist/menu/config.js +44 -0
  53. package/dist/menu/menu.d.ts +23 -0
  54. package/dist/menu/menu.js +69 -17
  55. package/dist/picker/picker.js +8 -3
  56. package/dist/section/accordion.d.ts +26 -0
  57. package/dist/section/accordion.js +86 -8
  58. package/dist/section/policy.d.ts +8 -0
  59. package/dist/section/policy.js +17 -0
  60. package/dist/section/section.d.ts +30 -0
  61. package/dist/section/section.js +106 -3
  62. package/dist/select/choice-element.d.ts +12 -0
  63. package/dist/select/choice-element.js +36 -0
  64. package/dist/select/choice.d.ts +12 -2
  65. package/dist/select/choice.js +93 -28
  66. package/dist/select/select.d.ts +12 -0
  67. package/dist/select/select.js +37 -1
  68. package/dist/styles/contract.d.ts +14 -0
  69. package/dist/styles/contract.js +15 -0
  70. package/dist/switch/switch.d.ts +16 -0
  71. package/dist/switch/switch.js +56 -6
  72. package/dist/tree/navigator.d.ts +3 -0
  73. package/dist/tree/navigator.js +26 -2
  74. package/dist/window/chrome.d.ts +5 -1
  75. package/dist/window/chrome.js +4 -3
  76. package/dist/window/config.d.ts +71 -0
  77. package/dist/window/config.js +40 -0
  78. package/dist/window/window.d.ts +51 -13
  79. package/dist/window/window.js +233 -38
  80. package/dist/yaml.d.ts +19 -0
  81. package/dist/yaml.js +57 -0
  82. package/package.json +10 -4
  83. package/styles/aster-ui.css +233 -10
  84. package/styles/base.css +36 -0
  85. package/styles/contract.ts +16 -0
  86. package/styles/elements.css +197 -10
  87. package/styles/fonts/OFL-bricolage-grotesque.txt +93 -0
  88. package/styles/fonts/OFL-fraunces.txt +93 -0
  89. package/styles/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
  90. package/styles/fonts/fraunces-latin-full-normal.woff2 +0 -0
  91. package/styles/fonts.css +17 -0
  92. package/styles/theme-harbour.css +5 -0
  93. package/styles/theme-lamplight.css +5 -0
@@ -1,4 +1,5 @@
1
1
  import { AsterElement, type View } from "../base/element.ts";
2
+ import { type VisibilityMode } from "./policy.ts";
2
3
  import "./section.ts";
3
4
  export interface AccordionItem {
4
5
  readonly id: string;
@@ -9,16 +10,41 @@ export interface AccordionItem {
9
10
  readonly body?: View;
10
11
  readonly create?: () => View;
11
12
  }
13
+ /** A request to move an item; the host applies `order` to `items`. */
14
+ export interface AccordionReorderDetail {
15
+ readonly id: string;
16
+ readonly from: number;
17
+ readonly to: number;
18
+ readonly order: readonly string[];
19
+ }
20
+ /**
21
+ * Coordinates retained [[class:Section]] panels. Single mode constrains user
22
+ * toggles; errors may still reveal a panel. Close and reorder are requests:
23
+ * the accordion never removes or reorders its host-owned items itself.
24
+ */
12
25
  export declare class Accordion extends AsterElement {
13
26
  #private;
14
27
  get items(): readonly AccordionItem[];
15
28
  set items(v: readonly AccordionItem[]);
16
29
  get expanded(): readonly string[];
17
30
  set expanded(v: readonly string[]);
31
+ /** Sugar for `visibilityMode`: `true` is `"multiple"`, `false` is `"mutex"`. Default `false`. */
18
32
  get multiple(): boolean;
19
33
  set multiple(v: boolean);
34
+ /** How many sections may be open at once: `"mutex"` (default) or `"multiple"`. */
35
+ get visibilityMode(): VisibilityMode;
36
+ set visibilityMode(v: VisibilityMode);
20
37
  get headingLevel(): 2 | 3 | 4;
21
38
  set headingLevel(v: 2 | 3 | 4);
39
+ /** Whether items get a close control. Default `false`; a close is a cancelable request. */
40
+ get canClose(): boolean;
41
+ set canClose(v: boolean);
42
+ /** Whether items get Move up and Move down controls. Default `false`. */
43
+ get canReorder(): boolean;
44
+ set canReorder(v: boolean);
45
+ /** Whether a Collapse all control is shown. Default `false`. */
46
+ get canCollapseAll(): boolean;
47
+ set canCollapseAll(v: boolean);
22
48
  protected render(): View;
23
49
  protected updated(): void;
24
50
  }
@@ -1,11 +1,20 @@
1
1
  import { html, nothing } from "lit-html";
2
2
  import { AsterElement, define, each } from "../base/element.js";
3
+ import { reorderIds, visibilityModeFromMultiple } from "./policy.js";
3
4
  import "./section.js";
5
+ /**
6
+ * Coordinates retained [[class:Section]] panels. Single mode constrains user
7
+ * toggles; errors may still reveal a panel. Close and reorder are requests:
8
+ * the accordion never removes or reorders its host-owned items itself.
9
+ */
4
10
  export class Accordion extends AsterElement {
5
11
  #items = this.prop([]);
6
12
  #expanded = this.prop([]);
7
- #multiple = this.prop(false);
13
+ #visibilityMode = this.prop("mutex");
8
14
  #level = this.prop(2);
15
+ #canClose = this.prop(false);
16
+ #canReorder = this.prop(false);
17
+ #canCollapseAll = this.prop(false);
9
18
  #created = new Map();
10
19
  #restoreFocus = false;
11
20
  get items() { return this.#items.value; }
@@ -27,26 +36,74 @@ export class Accordion extends AsterElement {
27
36
  throw new TypeError("Expanded IDs must be unique");
28
37
  this.#expanded.value = [...v];
29
38
  }
30
- get multiple() { return this.#multiple.value; }
31
- set multiple(v) { this.#multiple.value = v; }
39
+ /** Sugar for `visibilityMode`: `true` is `"multiple"`, `false` is `"mutex"`. Default `false`. */
40
+ get multiple() { return this.#visibilityMode.value === "multiple"; }
41
+ set multiple(v) {
42
+ if (typeof v !== "boolean")
43
+ throw new TypeError("multiple must be a boolean");
44
+ this.#visibilityMode.value = visibilityModeFromMultiple(v);
45
+ }
46
+ /** How many sections may be open at once: `"mutex"` (default) or `"multiple"`. */
47
+ get visibilityMode() { return this.#visibilityMode.value; }
48
+ set visibilityMode(v) {
49
+ if (v !== "multiple" && v !== "mutex")
50
+ throw new TypeError('visibilityMode must be "multiple" or "mutex"');
51
+ this.#visibilityMode.value = v;
52
+ }
32
53
  get headingLevel() { return this.#level.value; }
33
54
  set headingLevel(v) { this.#level.value = v; }
55
+ /** Whether items get a close control. Default `false`; a close is a cancelable request. */
56
+ get canClose() { return this.#canClose.value; }
57
+ set canClose(v) {
58
+ if (typeof v !== "boolean")
59
+ throw new TypeError("canClose must be a boolean");
60
+ this.#canClose.value = v;
61
+ }
62
+ /** Whether items get Move up and Move down controls. Default `false`. */
63
+ get canReorder() { return this.#canReorder.value; }
64
+ set canReorder(v) {
65
+ if (typeof v !== "boolean")
66
+ throw new TypeError("canReorder must be a boolean");
67
+ this.#canReorder.value = v;
68
+ }
69
+ /** Whether a Collapse all control is shown. Default `false`. */
70
+ get canCollapseAll() { return this.#canCollapseAll.value; }
71
+ set canCollapseAll(v) {
72
+ if (typeof v !== "boolean")
73
+ throw new TypeError("canCollapseAll must be a boolean");
74
+ this.#canCollapseAll.value = v;
75
+ }
34
76
  render() {
35
- const expanded = this.#expanded.value, multiple = this.#multiple.value;
77
+ const expanded = this.#expanded.value;
78
+ const mode = this.#visibilityMode.value;
79
+ const items = this.#items.value;
36
80
  return html `<div class="aster-accordion" @keydown=${(e) => this.#key(e)}>
37
- ${each(this.#items.value, i => i.id, item => {
81
+ ${this.#canCollapseAll.value ? html `<div class="aster-accordion-tools">
82
+ <button type="button" class="aster-accordion-collapse-all" @click=${() => this.#collapseAll()}>Collapse all</button>
83
+ </div>` : nothing}
84
+ ${each(items, i => i.id, item => {
38
85
  const open = expanded.includes(item.id);
86
+ const index = items.findIndex(i => i.id === item.id);
39
87
  if (open && item.create && !this.#created.has(item.id))
40
88
  this.#created.set(item.id, item.create());
89
+ const reorder = this.#canReorder.value && !item.disabled ? this.#reorderControls(item, index, items.length) : nothing;
90
+ const actions = reorder === nothing && item.actions === undefined ? nothing : html `${reorder}${item.actions ?? nothing}`;
41
91
  return html `<div class="aster-accordion-item" data-accordion-id=${item.id}>
42
92
  <aster-section .heading=${item.heading} .level=${this.#level.value} .open=${open}
43
- .summary=${item.summary ?? nothing} .actions=${item.actions ?? nothing} .disabled=${!!item.disabled}
93
+ .summary=${item.summary ?? nothing} .actions=${actions} .disabled=${!!item.disabled}
94
+ .canClose=${this.#canClose.value} .chrome=${this.#canClose.value ? { close: true } : undefined}
44
95
  .body=${item.body ?? this.#created.get(item.id) ?? nothing}
96
+ @close=${(e) => {
97
+ if (e.target.parentElement?.dataset.accordionId !== item.id)
98
+ return;
99
+ e.stopPropagation();
100
+ this.emit("close", item.id);
101
+ }}
45
102
  @section-toggle=${(e) => {
46
103
  if (e.target.parentElement?.dataset.accordionId !== item.id)
47
104
  return;
48
105
  e.stopPropagation();
49
- this.#expanded.value = e.detail.open ? [...(multiple || e.detail.reason === "error" ? this.#expanded.peek().filter(id => id !== item.id) : []), item.id] : this.#expanded.peek().filter(id => id !== item.id);
106
+ this.#expanded.value = e.detail.open ? [...(mode === "multiple" || e.detail.reason === "error" ? this.#expanded.peek().filter(id => id !== item.id) : []), item.id] : this.#expanded.peek().filter(id => id !== item.id);
50
107
  this.emit("expanded-change", { expanded: this.#expanded.peek(), reason: e.detail.reason });
51
108
  }}></aster-section>
52
109
  </div>`;
@@ -59,8 +116,29 @@ export class Accordion extends AsterElement {
59
116
  this.#headers()[0]?.focus();
60
117
  }
61
118
  }
119
+ #reorderControls(item, index, count) {
120
+ return html `<span class="aster-accordion-reorder">
121
+ <button type="button" aria-label=${`Move ${item.heading} up`} ?disabled=${index <= 0}
122
+ @click=${(e) => { e.stopPropagation(); this.#requestReorder(index, index - 1); }}>↑</button>
123
+ <button type="button" aria-label=${`Move ${item.heading} down`} ?disabled=${index >= count - 1}
124
+ @click=${(e) => { e.stopPropagation(); this.#requestReorder(index, index + 1); }}>↓</button>
125
+ </span>`;
126
+ }
127
+ /** A cancelable request; the host sets `items` to `order` to apply it. */
128
+ #requestReorder(from, to) {
129
+ const items = this.#items.peek();
130
+ if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length)
131
+ return;
132
+ this.emit("reorder", { id: items[from].id, from, to, order: reorderIds(items.map(i => i.id), from, to) });
133
+ }
134
+ #collapseAll() {
135
+ if (!this.#expanded.peek().length)
136
+ return;
137
+ this.#expanded.value = [];
138
+ this.emit("expanded-change", { expanded: [], reason: "user" });
139
+ }
62
140
  #headers() {
63
- return [...this.querySelectorAll(":scope > .aster-accordion > .aster-accordion-item > aster-section > .aster-section-head > button:not(:disabled)")].filter(el => el.closest("aster-accordion") === this);
141
+ return [...this.querySelectorAll(":scope > .aster-accordion > .aster-accordion-item > aster-section > .aster-section-head > button:not(:disabled):not(.aster-section-close)")].filter(el => el.closest("aster-accordion") === this);
64
142
  }
65
143
  #key(e) {
66
144
  const headers = this.#headers(), index = headers.indexOf(e.target);
@@ -0,0 +1,8 @@
1
+ /** How many sections may be open: one at a time (`"mutex"`, the default) or any number. */
2
+ export type VisibilityMode = "multiple" | "mutex";
3
+ /** The structured form of the existing `multiple` shorthand. */
4
+ export declare function visibilityModeFromMultiple(multiple: boolean): VisibilityMode;
5
+ /** A close control exists only when the section permits closing and chrome asks for it. */
6
+ export declare function closeShown(canClose: boolean, chromeClose: boolean | undefined): boolean;
7
+ /** The item order after moving `from` to `to`, for the `reorder` request. */
8
+ export declare function reorderIds(ids: readonly string[], from: number, to: number): string[];
@@ -0,0 +1,17 @@
1
+ /** The structured form of the existing `multiple` shorthand. */
2
+ export function visibilityModeFromMultiple(multiple) {
3
+ return multiple ? "multiple" : "mutex";
4
+ }
5
+ /** A close control exists only when the section permits closing and chrome asks for it. */
6
+ export function closeShown(canClose, chromeClose) {
7
+ return canClose && chromeClose === true;
8
+ }
9
+ /** The item order after moving `from` to `to`, for the `reorder` request. */
10
+ export function reorderIds(ids, from, to) {
11
+ if (from < 0 || to < 0 || from >= ids.length || to >= ids.length)
12
+ throw new RangeError("reorder indexes are out of range");
13
+ const order = [...ids];
14
+ const [id] = order.splice(from, 1);
15
+ order.splice(to, 0, id);
16
+ return order;
17
+ }
@@ -1,10 +1,28 @@
1
1
  import { AsterElement, type View } from "../base/element.ts";
2
+ /** Presentational chrome for a section, independent of its contents. */
3
+ export interface SectionChrome {
4
+ /** Window-bar look, via the shared title-bar template (window/chrome.ts). */
5
+ titleBar?: boolean;
6
+ /** Separate fold chevron in the title bar. Defaults to true with `titleBar`; ignored without it. */
7
+ fold?: boolean;
8
+ /** Close control; activating it emits a cancelable `close` action event. */
9
+ close?: boolean;
10
+ /** Footer region at the bottom of the body area. */
11
+ footer?: boolean;
12
+ /** Where `summary` shows: only while collapsed (default), or always. */
13
+ status?: "collapsed" | "always";
14
+ }
2
15
  /**
3
16
  * A titled section that collapses. The heading is a button with
4
17
  * `aria-expanded`; the body stays in the DOM, inert while collapsed, and CSS
5
18
  * animates its height (a grid row from 0fr to 1fr), so no layout is
6
19
  * measured in JS. A collapsed section opens when an error newly appears in
7
20
  * it; collapsing it again holds while that error stays.
21
+ *
22
+ * Optional chrome turns it into an in-flow panel: a shared window title bar
23
+ * with a separate fold control, a close control, a footer and a status that
24
+ * can stay visible. `collapsible: false` renders a plain heading and keeps
25
+ * the body open. The panel never closes itself; the host handles `close`.
8
26
  */
9
27
  export declare class Section extends AsterElement {
10
28
  #private;
@@ -24,6 +42,18 @@ export declare class Section extends AsterElement {
24
42
  /** Shown beside the heading while collapsed, such as "2 changes". */
25
43
  get summary(): View;
26
44
  set summary(v: View);
45
+ /** Presentational chrome. Default `{}`; omitted fields keep their defaults. */
46
+ get chrome(): SectionChrome;
47
+ set chrome(v: SectionChrome | undefined);
48
+ /** Whether the heading toggles the body. Default `true`. */
49
+ get collapsible(): boolean;
50
+ set collapsible(v: boolean);
51
+ /** Whether the section permits a close control. Default `true`; `chrome.close` still chooses whether to show it as [[object:SectionChrome]]. */
52
+ get canClose(): boolean;
53
+ set canClose(v: boolean);
54
+ /** Footer content; setting it also sets `chrome.footer`. Default `nothing`. */
55
+ get footer(): View;
56
+ set footer(v: View);
27
57
  protected render(): View;
28
58
  protected updated(): void;
29
59
  disconnectedCallback(): void;
@@ -1,11 +1,18 @@
1
1
  import { html, nothing } from "lit-html";
2
2
  import { AsterElement, define, uid } from "../base/element.js";
3
+ import { windowBar } from "../window/chrome.js";
4
+ import { closeShown } from "./policy.js";
3
5
  /**
4
6
  * A titled section that collapses. The heading is a button with
5
7
  * `aria-expanded`; the body stays in the DOM, inert while collapsed, and CSS
6
8
  * animates its height (a grid row from 0fr to 1fr), so no layout is
7
9
  * measured in JS. A collapsed section opens when an error newly appears in
8
10
  * it; collapsing it again holds while that error stays.
11
+ *
12
+ * Optional chrome turns it into an in-flow panel: a shared window title bar
13
+ * with a separate fold control, a close control, a footer and a status that
14
+ * can stay visible. `collapsible: false` renders a plain heading and keeps
15
+ * the body open. The panel never closes itself; the host handles `close`.
9
16
  */
10
17
  export class Section extends AsterElement {
11
18
  #heading = this.prop("");
@@ -15,6 +22,10 @@ export class Section extends AsterElement {
15
22
  #actions = this.prop(nothing);
16
23
  #level = this.prop(2);
17
24
  #disabled = this.prop(false);
25
+ #chrome = this.prop({});
26
+ #collapsible = this.prop(true);
27
+ #canClose = this.prop(true);
28
+ #footer = this.prop(nothing);
18
29
  #id = uid("aster-section");
19
30
  /** Whether the body held an error after the last commit, so only a new one opens the section. */
20
31
  #errored = false;
@@ -33,8 +44,8 @@ export class Section extends AsterElement {
33
44
  return this.#open.value;
34
45
  }
35
46
  set open(v) {
36
- if (!v && this.querySelector(".aster-section-body")?.contains(document.activeElement))
37
- this.querySelector(".aster-section-head button")?.focus();
47
+ if (!v && this.#collapsible.peek() && this.querySelector(".aster-section-body")?.contains(document.activeElement))
48
+ this.#toggleButton()?.focus();
38
49
  this.#open.value = v;
39
50
  }
40
51
  get body() {
@@ -57,9 +68,38 @@ export class Section extends AsterElement {
57
68
  set summary(v) {
58
69
  this.#summary.value = v;
59
70
  }
71
+ /** Presentational chrome. Default `{}`; omitted fields keep their defaults. */
72
+ get chrome() {
73
+ return this.#chrome.value;
74
+ }
75
+ set chrome(v) {
76
+ this.#chrome.value = Object.freeze({ ...(v ?? {}) });
77
+ }
78
+ /** Whether the heading toggles the body. Default `true`. */
79
+ get collapsible() { return this.#collapsible.value; }
80
+ set collapsible(v) { this.#collapsible.value = v; }
81
+ /** Whether the section permits a close control. Default `true`; `chrome.close` still chooses whether to show it as [[object:SectionChrome]]. */
82
+ get canClose() { return this.#canClose.value; }
83
+ set canClose(v) { this.#canClose.value = v; }
84
+ /** Footer content; setting it also sets `chrome.footer`. Default `nothing`. */
85
+ get footer() { return this.#footer.value; }
86
+ set footer(v) {
87
+ this.#footer.value = v;
88
+ this.#chrome.value = Object.freeze({ ...this.#chrome.peek(), footer: v !== nothing });
89
+ }
60
90
  render() {
61
91
  const open = this.#open.value;
62
- this.toggleAttribute("data-open", open);
92
+ const collapsible = this.#collapsible.value;
93
+ const chrome = this.#chrome.value;
94
+ const expanded = open || !collapsible;
95
+ this.toggleAttribute("data-open", expanded);
96
+ return this.#defaultChrome(collapsible, chrome) ? this.#defaultView(open) : this.#panelView(open, collapsible, expanded, chrome);
97
+ }
98
+ /** No option set: keep the original template verbatim, including its markers. */
99
+ #defaultChrome(collapsible, chrome) {
100
+ return collapsible && chrome.titleBar !== true && chrome.close !== true && chrome.footer !== true && chrome.status !== "always";
101
+ }
102
+ #defaultView(open) {
63
103
  return html `<div class="aster-section-head" role="heading" aria-level=${this.#level.value}>
64
104
  <button type="button" ?disabled=${this.#disabled.value} aria-expanded=${open ? "true" : "false"} aria-controls=${this.#id}
65
105
  @click=${() => this.#toggle(!this.#open.peek(), "user")}>
@@ -73,6 +113,24 @@ export class Section extends AsterElement {
73
113
  <div class="aster-section-inner">${this.#body.value}</div>
74
114
  </div>`;
75
115
  }
116
+ #panelView(open, collapsible, expanded, chrome) {
117
+ if (chrome.titleBar === true)
118
+ return html `${this.#titleBar(expanded, collapsible, chrome)}
119
+ ${this.#bodyRegion(collapsible, chrome)}`;
120
+ return html `<div class="aster-section-head" role="heading" aria-level=${this.#level.value}>
121
+ ${collapsible
122
+ ? html `<button type="button" ?disabled=${this.#disabled.value} aria-expanded=${open ? "true" : "false"} aria-controls=${this.#id}
123
+ @click=${() => this.#toggle(!this.#open.peek(), "user")}>
124
+ <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M3.5 2 6.5 5l-3 3"/></svg>
125
+ <span>${this.#heading.value}</span>
126
+ </button>`
127
+ : html `<span class="aster-section-title">${this.#heading.value}</span>`}
128
+ ${this.#showsSummary(expanded, chrome) ? html `<span class="aster-section-summary">${this.#summary.value}</span>` : nothing}
129
+ ${this.#actions.value === nothing ? nothing : html `<div class="aster-section-actions">${this.#actions.value}</div>`}
130
+ ${closeShown(this.#canClose.value, chrome.close) ? this.#close() : nothing}
131
+ </div>
132
+ ${this.#bodyRegion(collapsible, chrome)}`;
133
+ }
76
134
  updated() {
77
135
  if (!this.#errors) {
78
136
  this.#errors = new MutationObserver(() => this.#checkErrors());
@@ -85,6 +143,49 @@ export class Section extends AsterElement {
85
143
  this.#errors = undefined;
86
144
  super.disconnectedCallback();
87
145
  }
146
+ /** The retained body, inert while collapsed; the footer moves with it. */
147
+ #bodyRegion(collapsible, chrome) {
148
+ return html `<div class="aster-section-body" id=${this.#id} role="region" aria-label=${this.#heading.value} ?inert=${collapsible && !this.#open.value}>
149
+ <div class="aster-section-inner">${this.#body.value}${chrome.footer === true ? html `<div class="aster-section-footer">${this.#footer.value}</div>` : nothing}</div>
150
+ </div>`;
151
+ }
152
+ /** The window-style bar: shared title template, status, and bar controls. */
153
+ #titleBar(expanded, collapsible, chrome) {
154
+ const heading = html `<span class="aster-section-title">${this.#heading.value}</span>${this.#showsSummary(expanded, chrome)
155
+ ? html `<span class="aster-section-summary">${this.#summary.value}</span>`
156
+ : nothing}`;
157
+ const actions = html `${this.#actions.value === nothing ? nothing : html `<div class="aster-section-actions">${this.#actions.value}</div>`}${collapsible && chrome.fold !== false ? this.#fold() : nothing}${closeShown(this.#canClose.value, chrome.close) ? this.#close() : nothing}`;
158
+ return windowBar(`${this.#id}-heading`, heading, actions, undefined, { level: this.#level.value });
159
+ }
160
+ #showsSummary(expanded, chrome) {
161
+ return !expanded || chrome.status === "always";
162
+ }
163
+ #fold() {
164
+ const open = this.#open.value;
165
+ return html `<button type="button" class="aster-section-fold" ?disabled=${this.#disabled.value}
166
+ aria-expanded=${open ? "true" : "false"} aria-controls=${this.#id}
167
+ aria-label=${open ? `Collapse ${this.#heading.value}` : `Expand ${this.#heading.value}`}
168
+ @click=${(event) => {
169
+ event.stopPropagation();
170
+ this.#toggle(!this.#open.peek(), "user");
171
+ }}>
172
+ <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 5 6.5l3-3"/></svg>
173
+ </button>`;
174
+ }
175
+ #close() {
176
+ return html `<button type="button" class="aster-section-close" aria-label=${`Close ${this.#heading.value}`}
177
+ @click=${(event) => {
178
+ event.stopPropagation();
179
+ this.emit("close");
180
+ }}>
181
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
182
+ </button>`;
183
+ }
184
+ #toggleButton() {
185
+ return this.querySelector(":scope > .aster-window-bar > .aster-section-fold")
186
+ ?? this.querySelector(":scope > .aster-section-head > button:not(.aster-section-close)")
187
+ ?? undefined;
188
+ }
88
189
  #checkErrors() {
89
190
  const errored = this.querySelector(".aster-section-body [aria-invalid='true']") !== null;
90
191
  if (errored && !this.#errored && !this.#open.peek())
@@ -92,6 +193,8 @@ export class Section extends AsterElement {
92
193
  this.#errored = errored;
93
194
  }
94
195
  #toggle(open, reason) {
196
+ if (!this.#collapsible.peek())
197
+ return;
95
198
  this.#open.value = open;
96
199
  this.emit("section-toggle", { open, reason });
97
200
  }
@@ -15,6 +15,18 @@ export declare class Choice extends AsterElement {
15
15
  set label(v: string);
16
16
  get placeholder(): string;
17
17
  set placeholder(v: string);
18
+ /** Filters the list as the person types. Default `false`. */
19
+ get filter(): boolean;
20
+ set filter(v: boolean);
21
+ /** Shows a clear button while a value is chosen. Default `false`. */
22
+ get clearable(): boolean;
23
+ set clearable(v: boolean);
24
+ /** What the filtered list says when nothing matches. Default "No matches". */
25
+ get noResults(): string;
26
+ set noResults(v: string);
27
+ /** Removes the choice from the tab order and stops it opening. Default `false`. */
28
+ get disabled(): boolean;
29
+ set disabled(v: boolean);
18
30
  focus(options?: FocusOptions): void;
19
31
  protected render(): View;
20
32
  }
@@ -9,6 +9,10 @@ export class Choice extends AsterElement {
9
9
  #value = this.prop(undefined);
10
10
  #label = this.prop("");
11
11
  #placeholder = this.prop("Choose…");
12
+ #filter = this.prop(false);
13
+ #clearable = this.prop(false);
14
+ #noResults = this.prop("No matches");
15
+ #disabled = this.prop(false);
12
16
  #state = new ChoiceState();
13
17
  #id = uid("aster-choice");
14
18
  get options() {
@@ -35,6 +39,34 @@ export class Choice extends AsterElement {
35
39
  set placeholder(v) {
36
40
  this.#placeholder.value = v;
37
41
  }
42
+ /** Filters the list as the person types. Default `false`. */
43
+ get filter() {
44
+ return this.#filter.value;
45
+ }
46
+ set filter(v) {
47
+ this.#filter.value = v;
48
+ }
49
+ /** Shows a clear button while a value is chosen. Default `false`. */
50
+ get clearable() {
51
+ return this.#clearable.value;
52
+ }
53
+ set clearable(v) {
54
+ this.#clearable.value = v;
55
+ }
56
+ /** What the filtered list says when nothing matches. Default "No matches". */
57
+ get noResults() {
58
+ return this.#noResults.value;
59
+ }
60
+ set noResults(v) {
61
+ this.#noResults.value = v;
62
+ }
63
+ /** Removes the choice from the tab order and stops it opening. Default `false`. */
64
+ get disabled() {
65
+ return this.#disabled.value;
66
+ }
67
+ set disabled(v) {
68
+ this.#disabled.value = v;
69
+ }
38
70
  focus(options) {
39
71
  this.querySelector("[role=combobox]")?.focus(options);
40
72
  }
@@ -45,6 +77,10 @@ export class Choice extends AsterElement {
45
77
  value: this.#value.value,
46
78
  placeholder: this.#placeholder.value,
47
79
  label: this.#label.value,
80
+ filter: this.#filter.value,
81
+ clearable: this.#clearable.value,
82
+ noResults: this.#noResults.value,
83
+ disabled: this.#disabled.value,
48
84
  choose: (v) => {
49
85
  this.#value.value = v;
50
86
  this.emit("change", v);
@@ -4,10 +4,11 @@ export interface Option {
4
4
  readonly value: Value;
5
5
  readonly label: string;
6
6
  }
7
- /** Whether a choice's list is open, and which option is active in it. */
7
+ /** Whether a choice's list is open, which option is active in it, and the filter text. */
8
8
  export declare class ChoiceState {
9
9
  readonly open: import("@preact/signals-core").Signal<boolean>;
10
10
  readonly active: import("@preact/signals-core").Signal<number>;
11
+ readonly query: import("@preact/signals-core").Signal<string>;
11
12
  }
12
13
  export interface ChoiceProps {
13
14
  readonly id: string;
@@ -16,11 +17,18 @@ export interface ChoiceProps {
16
17
  /** Shown when the value isn't one of the options. */
17
18
  readonly placeholder: string;
18
19
  readonly readOnly?: boolean;
20
+ readonly disabled?: boolean;
19
21
  readonly label?: string;
20
22
  readonly labelledBy?: string;
21
23
  readonly required?: boolean;
22
24
  readonly invalid?: boolean;
23
25
  readonly describedBy?: string;
26
+ /** Filters the options as the person types, and shows `noResults` when nothing matches. */
27
+ readonly filter?: boolean;
28
+ /** Shows a clear button while a value is chosen. */
29
+ readonly clearable?: boolean;
30
+ /** What the open list says when the filter matches nothing. Default "No matches". */
31
+ readonly noResults?: string;
24
32
  readonly choose: (value: Value) => void;
25
33
  }
26
34
  /**
@@ -29,6 +37,8 @@ export interface ChoiceProps {
29
37
  *
30
38
  * Keyboard: ↓/↑, Enter or Space open; in the list ↓/↑, Home and End move,
31
39
  * a letter jumps to the next label starting with it, Enter or Space choose,
32
- * Escape and Tab close. The active option is `aria-activedescendant`.
40
+ * Escape and Tab close. With `filter`, typed text narrows the list and
41
+ * Backspace edits it; Enter chooses the active option. The active option is
42
+ * `aria-activedescendant`.
33
43
  */
34
44
  export declare function choiceView(state: ChoiceState, p: ChoiceProps): TemplateResult;