@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
@@ -0,0 +1,40 @@
1
+ /** Minimize and maximize stay managed-window chrome unless the host overrides them. */
2
+ export function resolveWindowChrome(chrome, managed) {
3
+ return {
4
+ header: chrome.header ?? true,
5
+ title: chrome.title ?? true,
6
+ close: chrome.close ?? true,
7
+ minimize: chrome.minimize ?? managed,
8
+ maximize: chrome.maximize ?? managed,
9
+ resizer: chrome.resizer ?? true,
10
+ footer: chrome.footer ?? false,
11
+ statusBar: chrome.statusBar ?? false,
12
+ };
13
+ }
14
+ /** A placement is rejected atomically; bounds are finite and positive. */
15
+ export function parseWindowPlacement(value) {
16
+ const placement = value;
17
+ if (!placement || !["normal", "maximized", "minimized"].includes(placement.state))
18
+ throw new TypeError("Invalid window state");
19
+ const b = placement.bounds;
20
+ if (b && (![b.x, b.y, b.width, b.height].every(Number.isFinite) || b.width <= 0 || b.height <= 0))
21
+ throw new TypeError("Invalid window bounds");
22
+ return {
23
+ state: placement.state,
24
+ ...(b ? { bounds: { x: b.x, y: b.y, width: b.width, height: b.height } } : {}),
25
+ restoreMaximized: placement.restoreMaximized === true,
26
+ };
27
+ }
28
+ /** Keeps a window's top-left inside `limits`, never computing layout of its own. */
29
+ export function clampWindow(at, size, limits) {
30
+ return {
31
+ x: Math.max(limits.left, Math.min(at.x, limits.right - size.width)),
32
+ y: Math.max(limits.top, Math.min(at.y, limits.bottom - size.height)),
33
+ };
34
+ }
35
+ export function centerWindow(size, limits) {
36
+ return {
37
+ x: limits.left + (limits.right - limits.left - size.width) / 2,
38
+ y: limits.top + (limits.bottom - limits.top - size.height) / 2,
39
+ };
40
+ }
@@ -1,17 +1,7 @@
1
1
  import { AsterElement, type View } from "../base/element.ts";
2
- export type WindowState = "normal" | "maximized" | "minimized";
3
- export interface WindowBounds {
4
- x: number;
5
- y: number;
6
- width: number;
7
- height: number;
8
- }
9
- export interface WindowPlacement {
10
- state: WindowState;
11
- bounds?: WindowBounds;
12
- restoreMaximized?: boolean;
13
- }
14
- /** Non-modal chrome; content ownership stays with the host page. */
2
+ import { type WindowChrome, type WindowPlacement, type WindowState } from "./config.ts";
3
+ export type { WindowBounds, WindowChrome, WindowPlacement, WindowState } from "./config.ts";
4
+ /** Window chrome and content; content ownership stays with the host page. */
15
5
  export declare class Window extends AsterElement {
16
6
  #private;
17
7
  get heading(): string;
@@ -28,6 +18,54 @@ export declare class Window extends AsterElement {
28
18
  get closing(): boolean;
29
19
  get beforeClose(): (() => boolean | Promise<boolean>) | undefined;
30
20
  set beforeClose(v: (() => boolean | Promise<boolean>) | undefined);
21
+ /** Which parts of the window chrome it draws, as [[object:WindowChrome]]. Default `{}`. */
22
+ get chrome(): WindowChrome;
23
+ set chrome(v: WindowChrome);
24
+ /** Sugar for `chrome.header`. Default `true`. */
25
+ get showHeader(): boolean;
26
+ set showHeader(v: boolean);
27
+ /** Sugar for `chrome.title`. Default `true`; a hidden title still names the window. */
28
+ get showTitle(): boolean;
29
+ set showTitle(v: boolean);
30
+ /** Sugar for `chrome.close`. Default `true`. */
31
+ get showCloseButton(): boolean;
32
+ set showCloseButton(v: boolean);
33
+ /** Sugar for `chrome.minimize`. Default: shown for managed windows. */
34
+ get showMinimizeButton(): boolean;
35
+ set showMinimizeButton(v: boolean);
36
+ /** Sugar for `chrome.maximize`. Default: shown for managed windows. */
37
+ get showMaximizeButton(): boolean;
38
+ set showMaximizeButton(v: boolean);
39
+ /** Sugar for `chrome.resizer`. Default `true`. */
40
+ get showResizer(): boolean;
41
+ set showResizer(v: boolean);
42
+ /** Sugar for `chrome.footer`. Default `false`. */
43
+ get showFooter(): boolean;
44
+ set showFooter(v: boolean);
45
+ /** Sugar for `chrome.statusBar`. Default `false`. */
46
+ get showStatusBar(): boolean;
47
+ set showStatusBar(v: boolean);
48
+ /** The footer's contents. Setting it also sets `chrome.footer`; `nothing` clears the flag. */
49
+ get footer(): View;
50
+ set footer(v: View);
51
+ /** The status bar's contents. Setting it also sets `chrome.statusBar`; `nothing` clears the flag. */
52
+ get status(): View;
53
+ set status(v: View);
54
+ /** Geometry used the first time the window opens, before any placement is captured. */
55
+ get initialPlacement(): WindowPlacement | undefined;
56
+ set initialPlacement(v: WindowPlacement | undefined);
57
+ /** Whether the window makes the rest of the page inert while open. Default `false`. */
58
+ get modal(): boolean;
59
+ set modal(v: boolean);
60
+ /** Whether Escape inside the window closes it. Default `true`. */
61
+ get dismissOnEscape(): boolean;
62
+ set dismissOnEscape(v: boolean);
63
+ /** Whether dragging keeps the window inside its parent element. Default `false`. */
64
+ get keepInParentRect(): boolean;
65
+ set keepInParentRect(v: boolean);
66
+ /** Whether the window centres itself the first time it opens. Default `false`. */
67
+ get autoCenter(): boolean;
68
+ set autoCenter(v: boolean);
31
69
  connectedCallback(): void;
32
70
  show(opener?: HTMLElement | null): void;
33
71
  /** Activates chrome without reparenting the content or recreating its models. */
@@ -2,10 +2,11 @@ import { html, nothing } from "lit-html";
2
2
  import { AsterElement, define, uid } from "../base/element.js";
3
3
  import { openWhen } from "../overlay/anchor.js";
4
4
  import { windowBar } from "./chrome.js";
5
+ import { centerWindow, clampWindow, parseWindowPlacement, resolveWindowChrome, } from "./config.js";
5
6
  const STEP = 16;
6
7
  const BIG_STEP = 64;
7
8
  const MARGIN = 8;
8
- /** Non-modal chrome; content ownership stays with the host page. */
9
+ /** Window chrome and content; content ownership stays with the host page. */
9
10
  export class Window extends AsterElement {
10
11
  #content = this.prop(nothing);
11
12
  #heading = this.prop("");
@@ -15,6 +16,14 @@ export class Window extends AsterElement {
15
16
  #active = this.prop(false);
16
17
  #preserve = this.prop(false);
17
18
  #closing = this.prop(false);
19
+ #chrome = this.prop({});
20
+ #footer = this.prop(nothing);
21
+ #status = this.prop(nothing);
22
+ #initial = this.prop(undefined);
23
+ #modal = this.prop(false);
24
+ #dismissOnEscape = this.prop(true);
25
+ #keepInParentRect = this.prop(false);
26
+ #autoCenter = this.prop(false);
18
27
  #id = uid("aster-window");
19
28
  #opener = null;
20
29
  #at;
@@ -25,6 +34,10 @@ export class Window extends AsterElement {
25
34
  #observed = null;
26
35
  #focus = null;
27
36
  #closeTask;
37
+ #modalQueued = false;
38
+ #hostCloses = 0;
39
+ /** Modality as captured when the window opened; changing `modal` applies on the next open. */
40
+ #shownModal = false;
28
41
  #generation = 0;
29
42
  #beforeClose;
30
43
  #dragEnd;
@@ -42,6 +55,62 @@ export class Window extends AsterElement {
42
55
  get closing() { return this.#closing.value; }
43
56
  get beforeClose() { return this.#beforeClose; }
44
57
  set beforeClose(v) { this.#beforeClose = v; }
58
+ /** Which parts of the window chrome it draws, as [[object:WindowChrome]]. Default `{}`. */
59
+ get chrome() { return this.#chrome.value; }
60
+ set chrome(v) { this.#chrome.value = { ...(v ?? {}) }; }
61
+ /** Sugar for `chrome.header`. Default `true`. */
62
+ get showHeader() { return this.#resolved().header; }
63
+ set showHeader(v) { this.#setChrome("header", v); }
64
+ /** Sugar for `chrome.title`. Default `true`; a hidden title still names the window. */
65
+ get showTitle() { return this.#resolved().title; }
66
+ set showTitle(v) { this.#setChrome("title", v); }
67
+ /** Sugar for `chrome.close`. Default `true`. */
68
+ get showCloseButton() { return this.#resolved().close; }
69
+ set showCloseButton(v) { this.#setChrome("close", v); }
70
+ /** Sugar for `chrome.minimize`. Default: shown for managed windows. */
71
+ get showMinimizeButton() { return this.#resolved().minimize; }
72
+ set showMinimizeButton(v) { this.#setChrome("minimize", v); }
73
+ /** Sugar for `chrome.maximize`. Default: shown for managed windows. */
74
+ get showMaximizeButton() { return this.#resolved().maximize; }
75
+ set showMaximizeButton(v) { this.#setChrome("maximize", v); }
76
+ /** Sugar for `chrome.resizer`. Default `true`. */
77
+ get showResizer() { return this.#resolved().resizer; }
78
+ set showResizer(v) { this.#setChrome("resizer", v); }
79
+ /** Sugar for `chrome.footer`. Default `false`. */
80
+ get showFooter() { return this.#resolved().footer; }
81
+ set showFooter(v) { this.#setChrome("footer", v); }
82
+ /** Sugar for `chrome.statusBar`. Default `false`. */
83
+ get showStatusBar() { return this.#resolved().statusBar; }
84
+ set showStatusBar(v) { this.#setChrome("statusBar", v); }
85
+ /** The footer's contents. Setting it also sets `chrome.footer`; `nothing` clears the flag. */
86
+ get footer() { return this.#footer.value; }
87
+ set footer(v) {
88
+ this.#footer.value = v;
89
+ this.#setChrome("footer", v !== nothing);
90
+ }
91
+ /** The status bar's contents. Setting it also sets `chrome.statusBar`; `nothing` clears the flag. */
92
+ get status() { return this.#status.value; }
93
+ set status(v) {
94
+ this.#status.value = v;
95
+ this.#setChrome("statusBar", v !== nothing);
96
+ }
97
+ /** Geometry used the first time the window opens, before any placement is captured. */
98
+ get initialPlacement() { return this.#initial.value; }
99
+ set initialPlacement(v) {
100
+ this.#initial.value = v === undefined ? undefined : parseWindowPlacement(v);
101
+ }
102
+ /** Whether the window makes the rest of the page inert while open. Default `false`. */
103
+ get modal() { return this.#modal.value; }
104
+ set modal(v) { this.#modal.value = v === true; }
105
+ /** Whether Escape inside the window closes it. Default `true`. */
106
+ get dismissOnEscape() { return this.#dismissOnEscape.value; }
107
+ set dismissOnEscape(v) { this.#dismissOnEscape.value = v !== false; }
108
+ /** Whether dragging keeps the window inside its parent element. Default `false`. */
109
+ get keepInParentRect() { return this.#keepInParentRect.value; }
110
+ set keepInParentRect(v) { this.#keepInParentRect.value = v === true; }
111
+ /** Whether the window centres itself the first time it opens. Default `false`. */
112
+ get autoCenter() { return this.#autoCenter.value; }
113
+ set autoCenter(v) { this.#autoCenter.value = v === true; }
45
114
  connectedCallback() {
46
115
  super.connectedCallback();
47
116
  this.#resize = new ResizeObserver(() => {
@@ -64,8 +133,11 @@ export class Window extends AsterElement {
64
133
  }
65
134
  show(opener = document.activeElement) {
66
135
  if (!this.#open.peek()) {
136
+ if (!this.#mounted)
137
+ this.#adoptInitial();
67
138
  this.#opener = opener;
68
139
  this.#mounted = true;
140
+ this.#shownModal = this.#modal.peek();
69
141
  this.#open.value = true;
70
142
  }
71
143
  if (this.#state.peek() === "minimized")
@@ -76,8 +148,9 @@ export class Window extends AsterElement {
76
148
  const panel = this.#panel();
77
149
  if (!panel)
78
150
  return;
79
- if (this.#state.peek() === "normal")
80
- this.#place(this.#at ?? beside(opener, panel));
151
+ if (this.#state.peek() === "normal") {
152
+ this.#place(this.#at ?? (this.#autoCenter.peek() ? centerWindow(sizeOf(panel), this.#limits()) : beside(opener, panel)));
153
+ }
81
154
  this.#applySize();
82
155
  this.activate();
83
156
  this.emit("statechange", this.#state.peek());
@@ -86,15 +159,19 @@ export class Window extends AsterElement {
86
159
  /** Activates chrome without reparenting the content or recreating its models. */
87
160
  activate(focus = true) {
88
161
  const panel = this.#panel();
89
- if (!panel || !this.#open.peek() || this.#state.peek() === "minimized" || document.querySelector("dialog:modal"))
162
+ const modalHost = this.#modalHost();
163
+ const blocked = document.querySelector("dialog:modal");
164
+ if (!panel || !this.#open.peek() || this.#state.peek() === "minimized" || (blocked && blocked !== modalHost))
90
165
  return;
91
166
  if (!this.#active.peek()) {
92
167
  const previous = document.activeElement;
93
168
  this.#active.value = true;
94
- // Top-layer order is insertion order; z-index cannot raise a popover.
95
- if (panel.matches(":popover-open"))
96
- panel.hidePopover();
97
- panel.showPopover();
169
+ if (!this.#shownModal) {
170
+ // Top-layer order is insertion order; z-index cannot raise a popover.
171
+ if (panel.matches(":popover-open"))
172
+ panel.hidePopover();
173
+ panel.showPopover();
174
+ }
98
175
  if (!focus && previous instanceof HTMLElement && previous.isConnected)
99
176
  previous.focus({ preventScroll: true });
100
177
  this.emit("activate");
@@ -147,7 +224,7 @@ export class Window extends AsterElement {
147
224
  const returnFocus = this.#active.peek() || this.#panel()?.contains(document.activeElement);
148
225
  this.#state.value = "minimized";
149
226
  this.#active.value = false;
150
- this.#panel()?.hidePopover();
227
+ this.#closeHost();
151
228
  if (returnFocus)
152
229
  this.#returnFocus();
153
230
  this.#changed();
@@ -173,19 +250,12 @@ export class Window extends AsterElement {
173
250
  }
174
251
  /** Invalid persisted data is rejected atomically. CSS limits size to the viewport. */
175
252
  setPlacement(value) {
176
- if (!["normal", "maximized", "minimized"].includes(value?.state))
177
- throw new TypeError("Invalid window state");
178
- const b = value.bounds;
179
- if (b && (![b.x, b.y, b.width, b.height].every(Number.isFinite) || b.width <= 0 || b.height <= 0))
180
- throw new TypeError("Invalid window bounds");
181
- this.#bounds = b ? { ...b } : undefined;
182
- this.#at = b ? { x: b.x, y: b.y } : undefined;
183
- this.#restoreMaximized = value.restoreMaximized === true;
184
- this.#state.value = value.state;
253
+ this.#adopt(parseWindowPlacement(value));
185
254
  requestAnimationFrame(() => this.#applySize());
186
255
  }
187
256
  updated() {
188
257
  this.#observe();
258
+ this.#syncModal();
189
259
  if (this.#open.peek() && this.#state.peek() !== "minimized")
190
260
  this.#applySize();
191
261
  }
@@ -196,39 +266,60 @@ export class Window extends AsterElement {
196
266
  if (!open && !(preserve && this.#mounted))
197
267
  return nothing;
198
268
  const heading = `${this.#id}-heading`;
199
- const managed = this.#managed.value;
200
- return html `<div class="aster-window" popover="manual" role="dialog" aria-modal="false" aria-labelledby=${heading}
269
+ const title = this.#heading.value;
270
+ const chrome = this.#resolved();
271
+ const modal = this.#shownModal;
272
+ const titleView = chrome.title ? title : html `<span class="aster-sr-only">${title}</span>`;
273
+ const panel = html `<div class="aster-window" popover=${modal ? nothing : "manual"} role="dialog" aria-modal=${modal ? "true" : "false"}
274
+ aria-labelledby=${chrome.header ? heading : nothing} aria-label=${chrome.header ? nothing : title || nothing}
201
275
  data-state=${state} data-active=${String(this.#active.value)} aria-busy=${String(this.#closing.value)}
202
- tabindex="-1" ${openWhen(open && state !== "minimized")}
276
+ data-resizer=${chrome.resizer ? nothing : "false"}
277
+ tabindex="-1" ${modal ? nothing : openWhen(open && state !== "minimized")}
203
278
  @pointerdown=${() => this.activate(false)} @focusin=${() => { this.#rememberFocus(); this.activate(false); }}
204
279
  @keydown=${(e) => {
205
280
  if (e.key !== "Escape" || e.defaultPrevented)
206
281
  return;
207
282
  e.preventDefault();
208
283
  e.stopPropagation();
209
- void this.close();
284
+ if (this.#dismissOnEscape.value)
285
+ void this.close();
210
286
  }}>
211
- ${windowBar(heading, this.#heading.value, html `<button type="button" class="aster-window-move" aria-label=${`Move ${this.#heading.value}`}
287
+ ${chrome.header ? windowBar(heading, titleView, html `<button type="button" class="aster-window-move" aria-label=${`Move ${title}`}
212
288
  ?disabled=${state !== "normal"} aria-describedby=${`${this.#id}-move-help`}
213
289
  @keydown=${(e) => this.#geometryKey(e, false)}>
214
290
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5v13M1.5 8h13M8 1.5 6 3.5M8 1.5l2 2M8 14.5l-2-2M8 14.5l2-2M1.5 8l2-2M1.5 8l2 2M14.5 8l-2-2M14.5 8l-2 2"/></svg>
215
291
  </button>
216
292
  <span id=${`${this.#id}-move-help`} hidden>Arrow keys move the window; with Shift, further.</span>
217
- <button type="button" aria-label=${`Resize ${this.#heading.value}`} ?disabled=${state !== "normal"}
293
+ ${chrome.resizer ? html `<button type="button" aria-label=${`Resize ${title}`} ?disabled=${state !== "normal"}
218
294
  aria-describedby=${`${this.#id}-resize-help`} @keydown=${(e) => this.#geometryKey(e, true)}>
219
295
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13 13 3M7 3h6v6M3 7v6h6"/></svg>
220
296
  </button>
221
- <span id=${`${this.#id}-resize-help`} hidden>Arrow keys resize the window; with Shift, further.</span>
222
- ${managed ? html `<button type="button" aria-label=${`Minimize ${this.#heading.value}`} @click=${() => this.minimize()}>
223
- <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 12h10"/></svg></button>
224
- <button type="button" aria-label=${`${state === "maximized" ? "Restore" : "Maximize"} ${this.#heading.value}`}
297
+ <span id=${`${this.#id}-resize-help`} hidden>Arrow keys resize the window; with Shift, further.</span>` : nothing}
298
+ ${chrome.minimize ? html `<button type="button" aria-label=${`Minimize ${title}`} @click=${() => this.minimize()}>
299
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 12h10"/></svg></button>` : nothing}
300
+ ${chrome.maximize ? html `<button type="button" aria-label=${`${state === "maximized" ? "Restore" : "Maximize"} ${title}`}
225
301
  @click=${() => state === "maximized" ? this.restore() : this.maximize()}>
226
302
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 3h10v10H3zM3 6h10"/></svg></button>` : nothing}
227
- <button type="button" class="aster-window-close" aria-label=${`Close ${this.#heading.value}`} ?disabled=${this.#closing.value} @click=${() => void this.close()}>
303
+ ${chrome.close ? html `<button type="button" class="aster-window-close" aria-label=${`Close ${title}`} ?disabled=${this.#closing.value} @click=${() => void this.close()}>
228
304
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
229
- </button>`, (event) => this.#drag(event))}
305
+ </button>` : nothing}`, (event) => this.#drag(event)) : nothing}
230
306
  <div class="aster-window-body">${this.#content.value}</div>
307
+ ${chrome.footer ? html `<div class="aster-window-footer">${this.#footer.value}</div>` : nothing}
308
+ ${chrome.statusBar ? html `<div class="aster-window-status" role="status">${this.#status.value}</div>` : nothing}
231
309
  </div>`;
310
+ // The wrapper is always rendered so a modality change between openings can
311
+ // move the live panel instead of recreating its content and drafts.
312
+ return html `<dialog class="aster-window-modal"
313
+ @cancel=${(e) => { e.preventDefault(); if (this.#dismissOnEscape.peek())
314
+ void this.close(); }}
315
+ @close=${() => {
316
+ if (this.#hostCloses > 0) {
317
+ this.#hostCloses--;
318
+ return;
319
+ }
320
+ if (this.#open.peek() && this.#state.peek() !== "minimized")
321
+ this.#finishClose();
322
+ }}></dialog>${panel}`;
232
323
  }
233
324
  #finishClose() {
234
325
  const returnFocus = this.#active.peek() || this.#panel()?.contains(document.activeElement);
@@ -236,11 +327,26 @@ export class Window extends AsterElement {
236
327
  this.#capture();
237
328
  this.#open.value = false;
238
329
  this.#active.value = false;
239
- this.#panel()?.hidePopover();
330
+ this.#closeHost();
240
331
  if (returnFocus)
241
332
  this.#returnFocus();
242
333
  this.emit("close");
243
334
  }
335
+ /** Closes the wrapper without treating its queued `close` event as external. */
336
+ #closeHost() {
337
+ // Only a modal window has a wrapper that closes it; the always-present
338
+ // dialog stays empty and closed for a non-modal window.
339
+ const host = this.#shownModal ? this.#modalHost() : null;
340
+ if (host) {
341
+ // A `<form method="dialog">` close leaves the host closed; its panel is already hidden with it.
342
+ if (host.open) {
343
+ this.#hostCloses++;
344
+ host.close();
345
+ }
346
+ return;
347
+ }
348
+ this.#panel()?.hidePopover();
349
+ }
244
350
  #rememberFocus() {
245
351
  if (document.activeElement instanceof HTMLElement && this.#panel()?.contains(document.activeElement))
246
352
  this.#focus = document.activeElement;
@@ -297,7 +403,7 @@ export class Window extends AsterElement {
297
403
  }
298
404
  #capture() {
299
405
  const panel = this.#panel();
300
- if (!panel?.matches(":popover-open") || this.#state.peek() !== "normal" || panel.dataset.state !== "normal")
406
+ if (!panel || !this.#shown(panel) || this.#state.peek() !== "normal" || panel.dataset.state !== "normal")
301
407
  return;
302
408
  const r = panel.getBoundingClientRect();
303
409
  this.#bounds = { x: r.x, y: r.y, width: r.width, height: r.height };
@@ -306,6 +412,16 @@ export class Window extends AsterElement {
306
412
  const panel = this.#panel();
307
413
  if (!panel)
308
414
  return;
415
+ const limits = this.#limits();
416
+ // CSS owns the window's min/max geometry; the panel reads these intent variables.
417
+ if (this.#keepInParentRect.peek()) {
418
+ panel.style.setProperty("--window-cap-width", `${limits.right - limits.left}px`);
419
+ panel.style.setProperty("--window-cap-height", `${limits.bottom - limits.top}px`);
420
+ }
421
+ else {
422
+ panel.style.removeProperty("--window-cap-width");
423
+ panel.style.removeProperty("--window-cap-height");
424
+ }
309
425
  if (this.#bounds) {
310
426
  panel.style.width = `${this.#bounds.width}px`;
311
427
  panel.style.height = `${this.#bounds.height}px`;
@@ -313,7 +429,7 @@ export class Window extends AsterElement {
313
429
  this.#recover();
314
430
  }
315
431
  #recover() {
316
- if (this.#state.peek() === "normal" && this.#at && this.#panel()?.matches(":popover-open"))
432
+ if (this.#state.peek() === "normal" && this.#at && this.#shown(this.#panel()))
317
433
  this.#place(this.#at);
318
434
  }
319
435
  #observe() {
@@ -329,14 +445,93 @@ export class Window extends AsterElement {
329
445
  const panel = this.#panel();
330
446
  if (!panel)
331
447
  return;
332
- const x = Math.max(MARGIN, Math.min(to.x, innerWidth - panel.offsetWidth - MARGIN));
333
- const y = Math.max(MARGIN, Math.min(to.y, innerHeight - panel.offsetHeight - MARGIN));
334
- this.#at = { x, y };
335
- panel.style.setProperty("--window-x", `${x}px`);
336
- panel.style.setProperty("--window-y", `${y}px`);
448
+ const at = clampWindow(to, sizeOf(panel), this.#limits());
449
+ this.#at = at;
450
+ panel.style.setProperty("--window-x", `${at.x}px`);
451
+ panel.style.setProperty("--window-y", `${at.y}px`);
452
+ }
453
+ /** The nearest viewport edge inside the parent, when `keepInParentRect` is on. */
454
+ #limits() {
455
+ const viewport = { left: MARGIN, top: MARGIN, right: innerWidth - MARGIN, bottom: innerHeight - MARGIN };
456
+ if (!this.#keepInParentRect.peek())
457
+ return viewport;
458
+ const r = this.parentElement?.getBoundingClientRect();
459
+ if (!r || r.width <= 0 || r.height <= 0)
460
+ return viewport;
461
+ return { left: Math.max(viewport.left, r.left), top: Math.max(viewport.top, r.top), right: Math.min(viewport.right, r.right), bottom: Math.min(viewport.bottom, r.bottom) };
462
+ }
463
+ #shown(panel) {
464
+ if (!panel)
465
+ return false;
466
+ if (this.#shownModal)
467
+ return this.#modalHost()?.open === true;
468
+ return panel.matches(":popover-open");
469
+ }
470
+ #resolved() {
471
+ return resolveWindowChrome(this.#chrome.value, this.#managed.value);
472
+ }
473
+ #setChrome(key, value) {
474
+ if (typeof value !== "boolean")
475
+ throw new TypeError(`${key} must be a boolean`);
476
+ this.#chrome.value = { ...this.#chrome.peek(), [key]: value };
477
+ }
478
+ #adopt(placement) {
479
+ this.#bounds = placement.bounds ? { ...placement.bounds } : undefined;
480
+ this.#at = placement.bounds ? { x: placement.bounds.x, y: placement.bounds.y } : undefined;
481
+ this.#restoreMaximized = placement.restoreMaximized === true;
482
+ this.#state.value = placement.state;
483
+ }
484
+ /** A declarative first placement applies only until any placement has been captured. */
485
+ #adoptInitial() {
486
+ const initial = this.#initial.peek();
487
+ if (initial && this.#at === undefined && this.#bounds === undefined)
488
+ this.#adopt(initial);
489
+ }
490
+ #modalHost() {
491
+ return this.querySelector("dialog.aster-window-modal");
492
+ }
493
+ /** The wrapper follows open, minimized and modal; the panel itself never owns modality. */
494
+ #syncModal() {
495
+ const host = this.#modalHost();
496
+ const panel = this.#panel();
497
+ // Move the live panel to the wrapper the captured modality needs. Keeping
498
+ // the same element preserves its DOM, focus memory and native input values.
499
+ if (host && panel) {
500
+ if (this.#shownModal && panel.parentElement !== host)
501
+ host.append(panel);
502
+ else if (!this.#shownModal && panel.parentElement === host)
503
+ this.append(panel);
504
+ }
505
+ if (this.#modalQueued || !host)
506
+ return;
507
+ this.#modalQueued = true;
508
+ // After the commit: showModal() moves focus, which must not re-enter the render effect.
509
+ queueMicrotask(() => {
510
+ this.#modalQueued = false;
511
+ const host = this.#modalHost();
512
+ if (!host)
513
+ return;
514
+ const wants = this.#shownModal && this.#open.peek() && this.#state.peek() !== "minimized";
515
+ if (wants && !host.open) {
516
+ // Modals don't stack: with a foreign modal open, showModal() throws.
517
+ // Fall back to show(), which keeps the window usable inside that
518
+ // modal's subtree and inert with the rest of the page otherwise.
519
+ try {
520
+ host.showModal();
521
+ }
522
+ catch {
523
+ host.show();
524
+ }
525
+ }
526
+ else if (!wants && host.open)
527
+ this.#closeHost();
528
+ });
337
529
  }
338
530
  #panel() { return this.querySelector(".aster-window"); }
339
531
  }
532
+ function sizeOf(panel) {
533
+ return { width: panel.offsetWidth, height: panel.offsetHeight };
534
+ }
340
535
  function beside(opener, panel) {
341
536
  const r = opener?.getBoundingClientRect();
342
537
  if (!r)
package/dist/yaml.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { type CompositeDefinition, type CompositeLocation } from "./composition/definition.ts";
2
+ /**
3
+ * Compiles a YAML composition definition into the same plain-data definition
4
+ * that `defineComposite` accepts, plus the file/line/column of every node.
5
+ *
6
+ * YAML carries configuration, never code: tags, anchors and aliases are
7
+ * rejected, so a definition can only contain JSON-compatible values. Pass the
8
+ * sources to `defineCompositeRegistry(...).validate(definition, sources)` to
9
+ * check model, component and renderer names with located diagnostics before
10
+ * mounting.
11
+ *
12
+ * @param text The YAML 1.2 document, at most 256 KiB.
13
+ * @param file The path reported in diagnostics.
14
+ * @returns The detached definition and a path-to-location map.
15
+ */
16
+ export declare function compileCompositeYaml(text: string, file?: string): {
17
+ definition: CompositeDefinition;
18
+ sources: Record<string, CompositeLocation>;
19
+ };
package/dist/yaml.js ADDED
@@ -0,0 +1,57 @@
1
+ import { isAlias, isMap, isNode, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
2
+ import { CompositeError, defineComposite, located } from "./composition/definition.js";
3
+ /**
4
+ * Compiles a YAML composition definition into the same plain-data definition
5
+ * that `defineComposite` accepts, plus the file/line/column of every node.
6
+ *
7
+ * YAML carries configuration, never code: tags, anchors and aliases are
8
+ * rejected, so a definition can only contain JSON-compatible values. Pass the
9
+ * sources to `defineCompositeRegistry(...).validate(definition, sources)` to
10
+ * check model, component and renderer names with located diagnostics before
11
+ * mounting.
12
+ *
13
+ * @param text The YAML 1.2 document, at most 256 KiB.
14
+ * @param file The path reported in diagnostics.
15
+ * @returns The detached definition and a path-to-location map.
16
+ */
17
+ export function compileCompositeYaml(text, file = "composite.yaml") {
18
+ if (text.length > 262144)
19
+ throw new Error(`${file}: definition exceeds 256 KiB`);
20
+ const lines = new LineCounter();
21
+ const doc = parseDocument(text, { version: "1.2", schema: "core", lineCounter: lines, uniqueKeys: true, strict: true });
22
+ const invalid = doc.errors[0] ?? doc.warnings[0];
23
+ if (invalid) {
24
+ const position = invalid.linePos?.[0];
25
+ throw new Error(position ? `${file}:${position.line}:${position.col}: ${invalid.message}` : `${file}: ${invalid.message}`);
26
+ }
27
+ if (doc.directives?.yaml.version !== "1.2")
28
+ throw new Error(`${file}: only YAML 1.2 is supported`);
29
+ const sources = Object.create(null);
30
+ let count = 0;
31
+ function walk(node, path, depth) {
32
+ if (++count > 10000 || depth > 40)
33
+ throw located(new CompositeError(path, "definition exceeds node/depth limit"), sources);
34
+ if (!node)
35
+ return;
36
+ const { line, col } = lines.linePos(node.range?.[0] ?? 0);
37
+ sources[path] = { file, line, column: col };
38
+ if (isAlias(node) || node.tag || node.anchor)
39
+ throw located(new CompositeError(path, "tags, anchors and aliases are not supported"), sources);
40
+ if (isMap(node))
41
+ for (const pair of node.items) {
42
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string")
43
+ throw located(new CompositeError(path, "mapping keys must be strings"), sources);
44
+ const next = `${path}.${pair.key.value}`;
45
+ walk(isNode(pair.value) ? pair.value : null, next, depth + 1);
46
+ }
47
+ else if (isSeq(node))
48
+ node.items.forEach((item, i) => walk(isNode(item) ? item : null, `${path}.${i}`, depth + 1));
49
+ }
50
+ walk(doc.contents, "definition", 0);
51
+ try {
52
+ return { definition: defineComposite(doc.toJS({ maxAliasCount: 0 })), sources };
53
+ }
54
+ catch (error) {
55
+ throw located(error, sources);
56
+ }
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getaster/elements",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -8,12 +8,18 @@
8
8
  "import": "./dist/index.js",
9
9
  "default": "./dist/index.js"
10
10
  },
11
- "./styles/*": "./styles/*"
11
+ "./styles/*": "./styles/*",
12
+ "./yaml": {
13
+ "types": "./dist/yaml.d.ts",
14
+ "import": "./dist/yaml.js",
15
+ "default": "./dist/yaml.js"
16
+ }
12
17
  },
13
18
  "dependencies": {
14
- "@getaster/data": "0.3.0",
19
+ "@getaster/data": "0.4.0",
15
20
  "@preact/signals-core": "1.14.4",
16
- "lit-html": "3.3.3"
21
+ "lit-html": "3.3.3",
22
+ "yaml": "^2.9.1"
17
23
  },
18
24
  "bin": {
19
25
  "aster-theme-check": "./bin/aster-theme-check.js"