@timmo001/groundwork-ui 0.4.1 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-ui",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Groundwork's framework-free web components, styled by the --gw-* tokens",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "README.md",
15
15
  "LICENSE"
16
16
  ],
17
- "customElements": ".manifest-check/custom-elements.json",
17
+ "customElements": "custom-elements.json",
18
18
  "sideEffects": true,
19
19
  "publishConfig": {
20
20
  "access": "public",
package/src/events.ts CHANGED
@@ -35,6 +35,9 @@ export interface GwEventMap {
35
35
  "gw-select": {
36
36
  change: Event;
37
37
  };
38
+ "gw-theme-gallery": {
39
+ theme: CustomEvent<{ id: string }>;
40
+ };
38
41
  "gw-theme-picker": {
39
42
  reset: Event;
40
43
  mode: CustomEvent<{ mode: string }>;
package/src/gw-alert.ts CHANGED
@@ -23,6 +23,10 @@ import { base, type Tone } from "./styles.js";
23
23
  * @slot actions - Buttons beside the message.
24
24
  *
25
25
  * @csspart alert - The alert.
26
+ *
27
+ * @cssprop --gw-tone - The icon color, for a tone of your own. `tone` sets it.
28
+ * @cssprop --gw-tone-fill - The background. `tone` sets it.
29
+ * @cssprop --gw-tone-border - The border. `tone` sets it.
26
30
  */
27
31
  export class GwAlert extends GwElement {
28
32
  static override properties = {
package/src/gw-app-bar.ts CHANGED
@@ -13,21 +13,20 @@ const menuIcon = "M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z";
13
13
  const closeIcon =
14
14
  "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z";
15
15
 
16
- /** Below this width the nav moves into a menu. */
17
- const menuBelow = 600;
18
-
19
16
  /**
20
17
  * A site or app header pinned to the top of the page, with a brand, nav
21
18
  * links and actions, full width with its content kept to the content width.
22
19
  * It's transparent at first, then gets a blurred surface and a bottom border
23
20
  * once the page scrolls, or once the element
24
21
  * `reveal-after` names scrolls out of view, such as a hero. With
25
- * `reveal-after` the brand stays hidden until then, so a large logo in the
26
- * hero hands over to the small one in the bar.
22
+ * `reveal-after` the brand stays hidden, taking no room, until then, so a
23
+ * large logo in the hero hands over to the small one in the bar. The nav
24
+ * slides over as it opens.
27
25
  *
28
26
  * `overlay` lays it over the top of the page rather than taking room, for a
29
- * page that opens with a hero. Below 600px wide the nav moves into a menu
30
- * opened from a button, which closes on Esc, a click outside or a link.
27
+ * page that opens with a hero. Below `menu-below`, 600px wide by default,
28
+ * the nav moves into a menu opened from a button, which closes on Esc, a
29
+ * click outside or a link. Raise it when the nav and actions need more room.
31
30
  *
32
31
  * @element gw-app-bar
33
32
  * @summary A header pinned to the top, floating once the page scrolls.
@@ -35,6 +34,8 @@ const menuBelow = 600;
35
34
  * @attr {string} reveal-after - A CSS selector for the element, such as a hero, to scroll past before the bar floats and the brand shows.
36
35
  * @attr {boolean} overlay - Lays the bar over the top of the page instead of taking room.
37
36
  * @attr {boolean} floating - Whether it floats. Set by the bar as the page scrolls.
37
+ * @attr {number} menu-below - The viewport width in pixels below which the nav moves into a menu. Defaults to `600`.
38
+ * @attr {boolean} collapsed - Whether the nav is in the menu. Set by the bar from `menu-below`.
38
39
  * @attr {boolean} menu-open - Whether the nav menu is open on narrow screens.
39
40
  * @attr {string} menu-label - The menu button's accessible name. Defaults to `Menu`.
40
41
  *
@@ -43,7 +44,9 @@ const menuBelow = 600;
43
44
  * @slot actions - Icon links and buttons at the end.
44
45
  *
45
46
  * @csspart bar - The bar.
47
+ * @csspart brand - The brand's container.
46
48
  * @csspart nav - The nav links' container, the menu panel on narrow screens.
49
+ * @csspart actions - The actions' container.
47
50
  *
48
51
  * @fires {Event} toggle - When the nav menu opens or closes.
49
52
  */
@@ -54,6 +57,8 @@ export class GwAppBar extends GwElement {
54
57
  floating: { type: "boolean", reflect: true },
55
58
  menuOpen: { type: "boolean", attribute: "menu-open", reflect: true },
56
59
  menuLabel: { attribute: "menu-label" },
60
+ menuBelow: { type: "number", attribute: "menu-below" },
61
+ collapsed: { type: "boolean", reflect: true },
57
62
  } as const;
58
63
 
59
64
  static override styles = [
@@ -97,24 +102,43 @@ export class GwAppBar extends GwElement {
97
102
  backdrop-filter: blur(12px) saturate(1.4);
98
103
  }
99
104
 
105
+ /* A one-column grid, so a hidden brand can fold its width to nothing
106
+ and the nav slides over as it opens, rather than leaving a gap */
100
107
  .brand {
101
- display: flex;
108
+ display: grid;
102
109
  flex: none;
110
+ grid-template-columns: 1fr;
103
111
  align-items: center;
104
112
  transition:
113
+ grid-template-columns var(--gw-motion-slow) var(--gw-motion-easing),
114
+ margin var(--gw-motion-slow) var(--gw-motion-easing),
105
115
  opacity var(--gw-motion-normal) var(--gw-motion-easing),
106
116
  translate var(--gw-motion-normal) var(--gw-motion-easing);
107
117
  }
108
118
 
119
+ .brand > slot {
120
+ display: flex;
121
+ align-items: center;
122
+ min-width: 0;
123
+ overflow: clip;
124
+ overflow-clip-margin: calc(
125
+ var(--gw-focus-width) + var(--gw-focus-offset)
126
+ );
127
+ }
128
+
109
129
  :host([reveal-after]:not([floating])) .brand {
130
+ grid-template-columns: 0fr;
131
+ margin-inline-end: calc(var(--gw-space-3) * -1);
110
132
  pointer-events: none;
111
133
  visibility: hidden;
112
134
  opacity: 0;
113
135
  translate: 0 -0.25rem;
114
136
  transition:
137
+ grid-template-columns var(--gw-motion-slow) var(--gw-motion-easing),
138
+ margin var(--gw-motion-slow) var(--gw-motion-easing),
115
139
  opacity var(--gw-motion-normal) var(--gw-motion-easing),
116
140
  translate var(--gw-motion-normal) var(--gw-motion-easing),
117
- visibility var(--gw-motion-normal) allow-discrete;
141
+ visibility var(--gw-motion-slow) allow-discrete;
118
142
  }
119
143
 
120
144
  .nav {
@@ -152,37 +176,37 @@ export class GwAppBar extends GwElement {
152
176
  background: var(--gw-color-fill-neutral-hovered);
153
177
  }
154
178
 
155
- @media (max-width: ${menuBelow - 0.02}px) {
156
- .menu {
157
- display: inline-grid;
158
- }
159
-
160
- .nav {
161
- position: absolute;
162
- inset-block-start: 100%;
163
- inset-inline: 0;
164
- flex-direction: column;
165
- gap: var(--gw-space-0-5);
166
- align-items: stretch;
167
- padding: var(--gw-space-2) var(--gw-space-3);
168
- visibility: hidden;
169
- background: var(--gw-color-surface-default);
170
- border-block-end: max(var(--gw-border-width), 1px) solid
171
- var(--gw-color-border-quiet);
172
- box-shadow: var(--gw-shadow-m);
173
- opacity: 0;
174
- translate: 0 -0.5rem;
175
- transition:
176
- opacity var(--gw-motion-fast) var(--gw-motion-easing),
177
- translate var(--gw-motion-normal) var(--gw-motion-easing),
178
- visibility var(--gw-motion-normal) allow-discrete;
179
- }
180
-
181
- :host([menu-open]) .nav {
182
- visibility: visible;
183
- opacity: 1;
184
- translate: 0 0;
185
- }
179
+ /* Below menu-below, which sets collapsed, the nav folds into a menu */
180
+ :host([collapsed]) .menu {
181
+ display: inline-grid;
182
+ }
183
+
184
+ :host([collapsed]) .nav {
185
+ position: absolute;
186
+ inset-block-start: 100%;
187
+ inset-inline: 0;
188
+ flex-direction: column;
189
+ gap: var(--gw-space-0-5);
190
+ align-items: stretch;
191
+ padding: var(--gw-space-2)
192
+ max(var(--gw-space-3), (100% - var(--gw-content-width)) / 2);
193
+ visibility: hidden;
194
+ background: var(--gw-color-surface-default);
195
+ border-block-end: max(var(--gw-border-width), 1px) solid
196
+ var(--gw-color-border-quiet);
197
+ box-shadow: var(--gw-shadow-m);
198
+ opacity: 0;
199
+ translate: 0 -0.5rem;
200
+ transition:
201
+ opacity var(--gw-motion-fast) var(--gw-motion-easing),
202
+ translate var(--gw-motion-normal) var(--gw-motion-easing),
203
+ visibility var(--gw-motion-normal) allow-discrete;
204
+ }
205
+
206
+ :host([collapsed][menu-open]) .nav {
207
+ visibility: visible;
208
+ opacity: 1;
209
+ translate: 0 0;
186
210
  }
187
211
  `,
188
212
  ];
@@ -192,6 +216,18 @@ export class GwAppBar extends GwElement {
192
216
  declare floating: boolean;
193
217
  declare menuOpen: boolean;
194
218
  declare menuLabel: string;
219
+ /** The viewport width in pixels below which the nav moves into a menu. */
220
+ declare menuBelow: number;
221
+ /** Whether the nav is in the menu. Set by the bar from `menuBelow`. */
222
+ declare collapsed: boolean;
223
+
224
+ #narrow: MediaQueryList | undefined;
225
+
226
+ readonly #onNarrow = () => {
227
+ this.collapsed = this.#narrow?.matches === true;
228
+
229
+ if (!this.collapsed) this.toggleMenu(false);
230
+ };
195
231
 
196
232
  readonly #menuIcon = createIcon(menuIcon);
197
233
 
@@ -214,10 +250,23 @@ export class GwAppBar extends GwElement {
214
250
  this.floating = false;
215
251
  this.menuOpen = false;
216
252
  this.menuLabel = "Menu";
253
+ this.menuBelow = 600;
254
+ this.collapsed = false;
255
+ }
256
+
257
+ /** Follows the viewport across `menuBelow`, setting `collapsed`. */
258
+ #watchWidth() {
259
+ this.#narrow?.removeEventListener("change", this.#onNarrow);
260
+ this.#narrow = window.matchMedia(
261
+ `(max-width: ${(Number(this.menuBelow) || 600) - 0.02}px)`,
262
+ );
263
+ this.#narrow.addEventListener("change", this.#onNarrow);
264
+ this.#onNarrow();
217
265
  }
218
266
 
219
267
  override connectedCallback() {
220
268
  super.connectedCallback();
269
+ this.#watchWidth();
221
270
  document.addEventListener("keydown", this.#onKeydown);
222
271
  document.addEventListener("pointerdown", this.#onOutside, true);
223
272
 
@@ -232,6 +281,8 @@ export class GwAppBar extends GwElement {
232
281
 
233
282
  override disconnectedCallback() {
234
283
  this.#unwatch();
284
+ this.#narrow?.removeEventListener("change", this.#onNarrow);
285
+ this.#narrow = undefined;
235
286
  document.removeEventListener("keydown", this.#onKeydown);
236
287
  document.removeEventListener("pointerdown", this.#onOutside, true);
237
288
  super.disconnectedCallback();
@@ -317,10 +368,19 @@ export class GwAppBar extends GwElement {
317
368
  h(
318
369
  "div",
319
370
  { class: "bar", part: "bar" },
320
- h("div", { class: "brand" }, h("slot", { name: "brand" })),
371
+ // The menu button leads, so a revealed brand opens beside it
321
372
  this.#menu,
373
+ h(
374
+ "div",
375
+ { class: "brand", part: "brand" },
376
+ h("slot", { name: "brand" }),
377
+ ),
322
378
  this.#nav,
323
- h("div", { class: "actions" }, h("slot", { name: "actions" })),
379
+ h(
380
+ "div",
381
+ { class: "actions", part: "actions" },
382
+ h("slot", { name: "actions" }),
383
+ ),
324
384
  ),
325
385
  );
326
386
  }
@@ -331,6 +391,10 @@ export class GwAppBar extends GwElement {
331
391
  setIconPath(this.#menuIcon, this.menuOpen ? closeIcon : menuIcon);
332
392
 
333
393
  if (changed.has("revealAfter") && this.isConnected) this.#watch();
394
+
395
+ if (changed.has("menuBelow") && this.#narrow !== undefined) {
396
+ this.#watchWidth();
397
+ }
334
398
  }
335
399
  }
336
400
 
package/src/gw-badge.ts CHANGED
@@ -27,6 +27,10 @@ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
27
27
  * @slot - The value.
28
28
  *
29
29
  * @csspart badge - The badge.
30
+ * @csspart label - The label above the value.
31
+ * @csspart value - The value.
32
+ *
33
+ * @cssprop --gw-tone - The icon's color, for a tone of your own. `tone` sets it.
30
34
  */
31
35
  export class GwBadge extends GwElement {
32
36
  static override properties = {
@@ -113,7 +117,7 @@ export class GwBadge extends GwElement {
113
117
 
114
118
  readonly #iconSkeleton = h("gw-skeleton-icon");
115
119
 
116
- readonly #label = h("span", { class: "label" });
120
+ readonly #label = h("span", { class: "label", part: "label" });
117
121
 
118
122
  readonly #skeleton = h("gw-skeleton-text", {
119
123
  style: "--gw-skeleton-width: 3rem",
@@ -138,7 +142,12 @@ export class GwBadge extends GwElement {
138
142
  "span",
139
143
  { class: "info" },
140
144
  this.#label,
141
- h("span", { class: "content" }, this.#skeleton, this.#value),
145
+ h(
146
+ "span",
147
+ { class: "content", part: "value" },
148
+ this.#skeleton,
149
+ this.#value,
150
+ ),
142
151
  ),
143
152
  );
144
153
  root.append(this.#badge);
package/src/gw-banner.ts CHANGED
@@ -27,6 +27,10 @@ import { base, type Tone } from "./styles.js";
27
27
  * @slot actions - One button, opening what it's about.
28
28
  *
29
29
  * @csspart bar - The bar.
30
+ *
31
+ * @cssprop --gw-tone - The text and icon color, for a tone of your own. `tone` sets it.
32
+ * @cssprop --gw-tone-fill - The background. `tone` sets it.
33
+ * @cssprop --gw-tone-border - The border. `tone` sets it.
30
34
  */
31
35
  export class GwBanner extends GwElement {
32
36
  static override properties = {
package/src/gw-button.ts CHANGED
@@ -12,6 +12,11 @@ import { base, createIcon, setIconPath } from "./styles.js";
12
12
  * theme and mode around it. With `href` it renders a link that looks the
13
13
  * same. Put an icon in the `icon` slot.
14
14
  *
15
+ * For a link the page's HTML has to carry, such as one with `rel="me"`, a
16
+ * `target` or `aria-current="page"`, put a plain `<a>` inside instead of
17
+ * `href`. The button styles it, a click anywhere on the pill follows it, and
18
+ * `aria-current` on it gets a current style.
19
+ *
15
20
  * `variant` picks the color and `appearance` how it's applied, from most to
16
21
  * least important: `accent` (a solid fill), `filled` (a soft tint),
17
22
  * `outlined` (a stroke) or `plain` (text only). Every button is a pill.
@@ -35,10 +40,10 @@ import { base, createIcon, setIconPath } from "./styles.js";
35
40
  * @attr {string} icon - SVG path data for an icon before the text.
36
41
  * @attr {"show"|"hide"|"auto"} labels - Whether the text shows beside an icon: always, never, or not on narrow screens. Defaults to `show`.
37
42
  *
38
- * @slot - The text.
43
+ * @slot - The text, or an `<a>` holding it, in place of `href`.
39
44
  * @slot icon - An icon before the text, in place of `icon`.
40
45
  *
41
- * @csspart button - The native button or link.
46
+ * @csspart button - The native button or link, or the pill around a slotted link.
42
47
  *
43
48
  * @cssprop --gw-button-scale - Scales every dimension, around 1 for medium.
44
49
  * @cssprop --gw-button-tone - The color, set by `variant`.
@@ -204,6 +209,27 @@ export class GwButton extends GwElement {
204
209
  border-color: var(--gw-button-tone);
205
210
  }
206
211
 
212
+ /* A slotted link carries the text; the pill draws its focus ring */
213
+ ::slotted(a) {
214
+ color: inherit;
215
+ text-decoration: none;
216
+ outline: none;
217
+ }
218
+
219
+ .button.focused {
220
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
221
+ outline-offset: var(--gw-focus-offset);
222
+ }
223
+
224
+ :host([appearance="plain"]) .button.current,
225
+ :host([appearance="outlined"]) .button.current {
226
+ background: color-mix(in oklch, var(--gw-button-tone) 16%, transparent);
227
+ }
228
+
229
+ :host([appearance="filled"]) .button.current {
230
+ background: color-mix(in oklch, var(--gw-button-tone) 32%, transparent);
231
+ }
232
+
207
233
  .button:disabled,
208
234
  .button[aria-disabled="true"] {
209
235
  color: var(--gw-color-text-disabled);
@@ -350,10 +376,18 @@ export class GwButton extends GwElement {
350
376
 
351
377
  readonly #over = h("span", { class: "ring over" });
352
378
 
353
- #control: HTMLButtonElement | HTMLAnchorElement = h("button", {
354
- class: "button",
355
- part: "button",
356
- });
379
+ #control: HTMLButtonElement | HTMLAnchorElement | HTMLSpanElement = h(
380
+ "button",
381
+ {
382
+ class: "button",
383
+ part: "button",
384
+ },
385
+ );
386
+
387
+ /** A plain `<a>` the page put inside, which the button wraps instead of `href`. */
388
+ #link(): HTMLAnchorElement | null {
389
+ return this.querySelector(":scope > a:not([slot])");
390
+ }
357
391
 
358
392
  constructor() {
359
393
  super();
@@ -373,12 +407,43 @@ export class GwButton extends GwElement {
373
407
  if (this.loading) {
374
408
  event.preventDefault();
375
409
  event.stopImmediatePropagation();
410
+
411
+ return;
412
+ }
413
+
414
+ // A click on the pill around a slotted link follows the link
415
+ const link = this.#link();
416
+
417
+ if (link !== null && !event.composedPath().includes(link)) {
418
+ link.click();
376
419
  }
377
420
  });
378
421
 
422
+ const focus = () => {
423
+ this.#control.classList.toggle(
424
+ "focused",
425
+ this.#link()?.matches(":focus-visible") === true,
426
+ );
427
+ };
428
+
429
+ this.addEventListener("focusin", focus);
430
+ this.addEventListener("focusout", focus);
431
+
379
432
  this.#iconSlot.addEventListener("slotchange", () => {
380
433
  this.requestUpdate("icon");
381
434
  });
435
+
436
+ this.#text.addEventListener("slotchange", () => {
437
+ this.requestUpdate("href");
438
+ });
439
+
440
+ new MutationObserver(() => {
441
+ this.requestUpdate("href");
442
+ }).observe(this, {
443
+ subtree: true,
444
+ attributes: true,
445
+ attributeFilter: ["aria-current"],
446
+ });
382
447
  }
383
448
 
384
449
  protected override create(root: ShadowRoot) {
@@ -387,13 +452,16 @@ export class GwButton extends GwElement {
387
452
  }
388
453
 
389
454
  protected override update(changed: ReadonlySet<string>) {
455
+ const link = this.#link();
456
+
390
457
  if (changed.has("href")) {
391
- const wanted = this.href ? "A" : "BUTTON";
458
+ const wanted = link ? "SPAN" : this.href ? "A" : "BUTTON";
392
459
 
393
460
  if (this.#control.tagName !== wanted) {
394
- const next = this.href
395
- ? h("a", { class: "button", part: "button" })
396
- : h("button", { class: "button", part: "button" });
461
+ const next = h(link ? "span" : this.href ? "a" : "button", {
462
+ class: "button",
463
+ part: "button",
464
+ });
397
465
 
398
466
  next.append(...this.#control.childNodes);
399
467
  this.#control.replaceWith(next);
@@ -407,6 +475,11 @@ export class GwButton extends GwElement {
407
475
  this.icon !== "" || this.querySelector("[slot='icon']") !== null;
408
476
 
409
477
  control.classList.toggle("has-icon", hasIcon);
478
+ control.classList.toggle(
479
+ "current",
480
+ link !== null &&
481
+ (link.getAttribute("aria-current") ?? "false") !== "false",
482
+ );
410
483
  setIconPath(this.#icon, this.icon);
411
484
  this.#icon.style.display = this.icon ? "" : "none";
412
485
  setAttribute(
@@ -416,13 +489,19 @@ export class GwButton extends GwElement {
416
489
  ? this.textContent.trim() || null
417
490
  : null,
418
491
  );
419
- setAttribute(control, "aria-label", this.label || null);
420
492
  setAttribute(control, "aria-busy", this.loading ? "true" : null);
421
493
 
494
+ if (control instanceof HTMLSpanElement) {
495
+ // The slotted link is the control, so it carries the name
496
+ setAttribute(control, "aria-label", null);
497
+ } else {
498
+ setAttribute(control, "aria-label", this.label || null);
499
+ }
500
+
422
501
  if (control instanceof HTMLAnchorElement) {
423
502
  setAttribute(control, "href", this.disabled ? null : this.href);
424
503
  setAttribute(control, "aria-disabled", this.disabled ? "true" : null);
425
- } else {
504
+ } else if (control instanceof HTMLButtonElement) {
426
505
  control.type = this.type;
427
506
  control.disabled = this.disabled;
428
507
  }
package/src/gw-card.ts CHANGED
@@ -28,6 +28,9 @@ import { base } from "./styles.js";
28
28
  * @slot actions - Buttons beside the heading.
29
29
  *
30
30
  * @csspart card - The card's surface.
31
+ * @csspart header - The row with the heading and actions.
32
+ * @csspart heading - The heading.
33
+ * @csspart body - The content.
31
34
  */
32
35
  export class GwCard extends GwElement {
33
36
  static override properties = {
@@ -107,14 +110,14 @@ export class GwCard extends GwElement {
107
110
 
108
111
  readonly #header = h(
109
112
  "header",
110
- {},
111
- h("h2", {}, this.#title, this.#titleSkeleton),
113
+ { part: "header" },
114
+ h("h2", { part: "heading" }, this.#title, this.#titleSkeleton),
112
115
  h("slot", { name: "actions" }),
113
116
  );
114
117
 
115
118
  readonly #skeleton = h("gw-skeleton-lines");
116
119
 
117
- readonly #body = h("div", { class: "body" }, h("slot"));
120
+ readonly #body = h("div", { class: "body", part: "body" }, h("slot"));
118
121
 
119
122
  #card: HTMLElement = h("section", { class: "card", part: "card" });
120
123
 
package/src/gw-chip.ts CHANGED
@@ -24,6 +24,9 @@ import { base, createIcon, setIconPath, type Tone } from "./styles.js";
24
24
  * @slot icon - An icon before the text, in place of `icon`.
25
25
  *
26
26
  * @csspart chip - The chip.
27
+ *
28
+ * @cssprop --gw-tone-fill - The tint, for a tone of your own. `tone` sets it.
29
+ * @cssprop --gw-tone-border - The border. `tone` sets it.
27
30
  */
28
31
  export class GwChip extends GwElement {
29
32
  static override properties = {
@@ -28,6 +28,10 @@ const sheetBelow = 600;
28
28
  * @attr {string} shortcut - The key that opens it with Cmd or Ctrl, such as `mod+k`, or `none`. Defaults to `mod+k`.
29
29
  * @attr {boolean} open - Whether it shows.
30
30
  *
31
+ * @csspart dialog - The menu's surface.
32
+ * @csspart list - The `gw-option-list` of commands.
33
+ * @csspart footer - The row of key hints.
34
+ *
31
35
  * @fires {CustomEvent<{ value: string }>} pick - When a command is picked, with its `value` in `detail`.
32
36
  */
33
37
  export class GwCommandMenu extends GwElement {
@@ -150,15 +154,15 @@ export class GwCommandMenu extends GwElement {
150
154
  declare shortcut: string;
151
155
  declare open: boolean;
152
156
 
153
- readonly #list = h("gw-option-list", { search: true });
157
+ readonly #list = h("gw-option-list", { search: true, part: "list" });
154
158
 
155
159
  readonly #dialog = h(
156
160
  "dialog",
157
- {},
161
+ { part: "dialog" },
158
162
  this.#list,
159
163
  h(
160
164
  "footer",
161
- { "aria-hidden": "true" },
165
+ { part: "footer", "aria-hidden": "true" },
162
166
  h("span", {}, h("kbd", {}, "↑"), " ", h("kbd", {}, "↓"), " to move"),
163
167
  h("span", {}, h("kbd", {}, "Enter"), " to pick"),
164
168
  h("span", {}, h("kbd", {}, "Esc"), " to close"),
package/src/gw-dialog.ts CHANGED
@@ -28,6 +28,12 @@ const closeIcon =
28
28
  * @slot - The content.
29
29
  * @slot actions - Buttons; one with a `value` closes it with that value.
30
30
  *
31
+ * @csspart dialog - The dialog's surface.
32
+ * @csspart header - The row with the heading and close button.
33
+ * @csspart heading - The heading.
34
+ * @csspart body - The content.
35
+ * @csspart footer - The actions row.
36
+ *
31
37
  * @fires {Event} close - When it closes; `returnValue` holds the button's value.
32
38
  */
33
39
  export class GwDialog extends GwElement {
@@ -139,9 +145,13 @@ export class GwDialog extends GwElement {
139
145
  this.open = false;
140
146
  }
141
147
 
142
- readonly #heading = h("h2", { id: "heading" });
148
+ readonly #heading = h("h2", { id: "heading", part: "heading" });
143
149
 
144
- readonly #footer = h("footer", {}, h("slot", { name: "actions" }));
150
+ readonly #footer = h(
151
+ "footer",
152
+ { part: "footer" },
153
+ h("slot", { name: "actions" }),
154
+ );
145
155
 
146
156
  readonly #closeButton = h(
147
157
  "gw-button",
@@ -158,9 +168,9 @@ export class GwDialog extends GwElement {
158
168
 
159
169
  readonly #dialog = h(
160
170
  "dialog",
161
- { "aria-labelledby": "heading" },
162
- h("header", {}, this.#heading, this.#closeButton),
163
- h("div", { class: "body" }, h("slot")),
171
+ { "aria-labelledby": "heading", part: "dialog" },
172
+ h("header", { part: "header" }, this.#heading, this.#closeButton),
173
+ h("div", { class: "body", part: "body" }, h("slot")),
164
174
  this.#footer,
165
175
  );
166
176
 
package/src/gw-dot.ts CHANGED
@@ -17,6 +17,8 @@ import { base, type Tone } from "./styles.js";
17
17
  * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The color. Defaults to `neutral`.
18
18
  * @attr {string} label - What it means, for assistive technology.
19
19
  * @attr {boolean} pulse - Pulses, for something live.
20
+ *
21
+ * @cssprop --gw-tone - The color, for a tone of your own. `tone` sets it.
20
22
  */
21
23
  export class GwDot extends GwElement {
22
24
  static override properties = {
@@ -25,6 +25,8 @@ import { base } from "./styles.js";
25
25
  *
26
26
  * @csspart details - The native `<details>`.
27
27
  * @csspart summary - The summary row.
28
+ * @csspart heading - The heading.
29
+ * @csspart description - The line under the heading.
28
30
  * @csspart content - The content's wrapper.
29
31
  *
30
32
  * @fires {Event} toggle - When it opens or closes.
@@ -169,9 +171,12 @@ export class GwExpansion extends GwElement {
169
171
  declare description: string;
170
172
  declare open: boolean;
171
173
 
172
- readonly #heading = h("span", { class: "heading" });
174
+ readonly #heading = h("span", { class: "heading", part: "heading" });
173
175
 
174
- readonly #description = h("span", { class: "description" });
176
+ readonly #description = h("span", {
177
+ class: "description",
178
+ part: "description",
179
+ });
175
180
 
176
181
  readonly #details = h(
177
182
  "details",