@timmo001/groundwork-ui 0.9.2 → 0.9.4

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.
@@ -3613,7 +3613,7 @@
3613
3613
  "type": {
3614
3614
  "text": "\"button\"|\"submit\"|\"reset\""
3615
3615
  },
3616
- "description": "The native button type. Defaults to `button`.",
3616
+ "description": "What a click does: nothing more, or submit or reset the form the button is in. Defaults to `button`.",
3617
3617
  "name": "type"
3618
3618
  },
3619
3619
  {
@@ -10318,7 +10318,7 @@
10318
10318
  "declarations": [
10319
10319
  {
10320
10320
  "kind": "class",
10321
- "description": "A button that opens a menu on the Popover API, so it shows in the top\nlayer and closes on Esc or a click outside. Set `items`; arrow keys, Home\nand End move between them, and choosing one fires `select` with its\n`value` and closes the menu.\n\nContextual actions, such as \"Add a schedule\", go in `actions`: they stay\nbelow the items as the list scrolls, so they're found without scrolling to\nthe end, and with no items the menu says `empty` above them.\n\nItems render inside the component, so a page's own button styles can't\nreach them.",
10321
+ "description": "A button that opens a menu on the Popover API, so it shows in the top\nlayer and closes on Esc or a click outside. Set `items`; arrow keys, Home\nand End move between them, and choosing one fires `select` with its\n`value` and closes the menu.\n\nContextual actions, such as \"Add a schedule\", go in `actions`: they stay\nbelow the items as the list scrolls, so they're found without scrolling to\nthe end, and with no items the menu says `empty` above them.\n\nFor a menu of choices, such as sort and order, give each item `checked`:\nthe picked one shows a tick, and `separator` starts the next group.\n\nItems render inside the component, so a page's own button styles can't\nreach them.",
10322
10322
  "name": "GwMenuButton",
10323
10323
  "cssProperties": [
10324
10324
  {
@@ -10344,7 +10344,7 @@
10344
10344
  "text": "Properties"
10345
10345
  },
10346
10346
  "static": true,
10347
- "default": "{ label: {}, icon: {}, empty: {}, items: { type: \"json\" }, actions: { type: \"json\" }, size: { reflect: true }, }",
10347
+ "default": "{ label: {}, heading: {}, icon: {}, empty: {}, items: { type: \"json\" }, actions: { type: \"json\" }, size: { reflect: true }, active: { type: \"boolean\", reflect: true }, labels: { reflect: true }, }",
10348
10348
  "inheritedFrom": {
10349
10349
  "name": "GwElement",
10350
10350
  "module": "src/element.ts"
@@ -10357,7 +10357,7 @@
10357
10357
  "text": "readonly CSSStyleSheet[]"
10358
10358
  },
10359
10359
  "static": true,
10360
- "default": "[ base, controlSizes(\"menu-button\"), css` :host { position: relative; display: inline-block; anchor-scope: --gw-menu; } .trigger { --gw-size: var(--gw-menu-button-scale, 1); --gw-scale: calc(var(--gw-size) * var(--gw-control-scale, 1)); --gw-icon-size: calc(1.25rem * var(--gw-scale)); display: inline-flex; gap: calc(var(--gw-space-2) * var(--gw-size)); align-items: center; box-sizing: border-box; height: calc(2.5rem * var(--gw-scale)); padding: 0 calc(var(--gw-space-4) * var(--gw-size)); font: inherit; font-size: calc(var(--gw-font-size-s) * var(--gw-size)); font-weight: var(--gw-font-weight-strong); color: var(--gw-color-text-strong); cursor: pointer; background: var(--gw-color-fill-neutral-resting); border: var(--gw-border-width) solid var(--gw-border-color); border-radius: var(--gw-radius-control); transition: background-color var(--gw-motion-fast) var(--gw-motion-easing); anchor-name: --gw-menu; } .trigger:hover { background: var(--gw-color-fill-neutral-hovered); } .chevron { width: 0.4rem; height: 0.4rem; margin-inline-start: var(--gw-space-1); border-inline-end: 1.5px solid currentColor; border-block-end: 1.5px solid currentColor; rotate: 45deg; translate: 0 -0.125rem; transition: rotate var(--gw-motion-normal) var(--gw-motion-easing), translate var(--gw-motion-normal) var(--gw-motion-easing); } .trigger[aria-expanded=\"true\"] .chevron { rotate: 225deg; translate: 0 0.125rem; } [popover] { position-anchor: --gw-menu; position-try-fallbacks: flip-block, flip-inline; inset: auto; top: anchor(bottom); left: anchor(left); box-sizing: border-box; min-width: 15rem; max-height: min(24rem, 60vh); padding: 0; margin: var(--gw-space-1) 0 0; overflow: hidden; color: var(--gw-color-text-primary); background: var(--gw-color-surface-overlay); border: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal); border-radius: var(--gw-radius-l); box-shadow: var(--gw-shadow-m); opacity: 0; translate: 0 -0.25rem; transition: opacity var(--gw-motion-fast) var(--gw-motion-easing), translate var(--gw-motion-normal) var(--gw-motion-easing), display var(--gw-motion-normal) allow-discrete, overlay var(--gw-motion-normal) allow-discrete; } [popover]:popover-open { display: flex; flex-direction: column; opacity: 1; translate: 0 0; } @starting-style { [popover]:popover-open { opacity: 0; translate: 0 -0.25rem; } } /* The items scroll; the actions stay put below them, so \"Add\" is always in reach rather than at the end of a long list */ .items, .actions { display: flex; flex-direction: column; gap: var(--gw-space-0-5); padding: var(--gw-space-1); } .items { flex: 1; min-height: 0; overflow-y: auto; } .actions { border-block-start: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal); } .empty { padding: var(--gw-space-3); margin: 0; font-size: var(--gw-font-size-s); color: var(--gw-color-text-secondary); } .item { display: flex; gap: var(--gw-space-3); align-items: center; box-sizing: border-box; min-height: 2.5rem; padding: var(--gw-space-2) var(--gw-space-3); font: inherit; font-size: var(--gw-font-size-s); color: var(--gw-color-text-primary); text-align: start; cursor: pointer; background: transparent; border: 0; border-radius: var(--gw-radius-m); transition: background-color var(--gw-motion-fast) var(--gw-motion-easing); --gw-icon-size: 1.125rem; } .item:hover, .item:focus-visible { background: var(--gw-color-fill-neutral-hovered); outline: none; } .item:focus-visible { box-shadow: inset 0 0 0 var(--gw-focus-width) var(--gw-color-focus); } .item:disabled { color: var(--gw-color-text-disabled); cursor: not-allowed; background: transparent; } .action { font-weight: var(--gw-font-weight-strong); color: var(--gw-color-accent); } .text { display: flex; flex-direction: column; gap: var(--gw-space-0-5); } .description { font-size: var(--gw-font-size-xs); color: var(--gw-color-text-secondary); } `, ]",
10360
+ "default": "[ base, controlSizes(\"menu-button\"), css` :host { position: relative; display: inline-block; anchor-scope: --gw-menu; } .trigger { --gw-size: var(--gw-menu-button-scale, 1); --gw-scale: calc(var(--gw-size) * var(--gw-control-scale, 1)); --gw-icon-size: calc(1.25rem * var(--gw-scale)); display: inline-flex; gap: calc(var(--gw-space-2) * var(--gw-size)); align-items: center; box-sizing: border-box; height: calc(2.5rem * var(--gw-scale)); padding: 0 calc(var(--gw-space-4) * var(--gw-size)); font: inherit; font-size: calc(var(--gw-font-size-s) * var(--gw-size)); font-weight: var(--gw-font-weight-strong); color: var(--gw-color-text-strong); cursor: pointer; background: var(--gw-color-fill-neutral-resting); border: var(--gw-border-width) solid var(--gw-border-color); border-radius: var(--gw-radius-control); transition: background-color var(--gw-motion-fast) var(--gw-motion-easing); anchor-name: --gw-menu; } .trigger:hover { background: var(--gw-color-fill-neutral-hovered); } :host([active]) .trigger { color: var(--gw-color-accent); border-color: var(--gw-color-accent); } /* Icon only, square, with the text kept for assistive technology */ :host([labels=\"hide\"]) .trigger.has-icon { padding: 0 calc(var(--gw-space-2) * var(--gw-size)); } :host([labels=\"hide\"]) .has-icon .label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @media (width < 40rem) { :host([labels=\"auto\"]) .trigger.has-icon { padding: 0 calc(var(--gw-space-2) * var(--gw-size)); } :host([labels=\"auto\"]) .has-icon .label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } } .chevron { width: 0.4rem; height: 0.4rem; margin-inline-start: var(--gw-space-1); border-inline-end: 1.5px solid currentColor; border-block-end: 1.5px solid currentColor; rotate: 45deg; translate: 0 -0.125rem; transition: rotate var(--gw-motion-normal) var(--gw-motion-easing), translate var(--gw-motion-normal) var(--gw-motion-easing); } .trigger[aria-expanded=\"true\"] .chevron { rotate: 225deg; translate: 0 0.125rem; } [popover] { position-anchor: --gw-menu; position-try-fallbacks: flip-block, flip-inline; inset: auto; top: anchor(bottom); left: anchor(left); box-sizing: border-box; min-width: 15rem; max-height: min(24rem, 60vh); padding: 0; margin: var(--gw-space-1) 0 0; overflow: hidden; color: var(--gw-color-text-primary); background: var(--gw-color-surface-overlay); border: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal); border-radius: var(--gw-radius-l); box-shadow: var(--gw-shadow-m); opacity: 0; translate: 0 -0.25rem; transition: opacity var(--gw-motion-fast) var(--gw-motion-easing), translate var(--gw-motion-normal) var(--gw-motion-easing), display var(--gw-motion-normal) allow-discrete, overlay var(--gw-motion-normal) allow-discrete; } [popover]:popover-open { display: flex; flex-direction: column; opacity: 1; translate: 0 0; } @starting-style { [popover]:popover-open { opacity: 0; translate: 0 -0.25rem; } } /* The items scroll; the actions stay put below them, so \"Add\" is always in reach rather than at the end of a long list */ .items, .actions { display: flex; flex-direction: column; gap: var(--gw-space-0-5); padding: var(--gw-space-1); } .items { flex: 1; min-height: 0; overflow-y: auto; } .actions { border-block-start: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal); } .empty { padding: var(--gw-space-3); margin: 0; font-size: var(--gw-font-size-s); color: var(--gw-color-text-secondary); } .item { display: flex; gap: var(--gw-space-3); align-items: center; box-sizing: border-box; min-height: 2.5rem; padding: var(--gw-space-2) var(--gw-space-3); font: inherit; font-size: var(--gw-font-size-s); color: var(--gw-color-text-primary); text-align: start; cursor: pointer; background: transparent; border: 0; border-radius: var(--gw-radius-m); transition: background-color var(--gw-motion-fast) var(--gw-motion-easing); --gw-icon-size: 1.125rem; } .item:hover, .item:focus-visible { background: var(--gw-color-fill-neutral-hovered); outline: none; } .item:focus-visible { box-shadow: inset 0 0 0 var(--gw-focus-width) var(--gw-color-focus); } .item:disabled { color: var(--gw-color-text-disabled); cursor: not-allowed; background: transparent; } .action { font-weight: var(--gw-font-weight-strong); color: var(--gw-color-accent); } /* Holds the tick's place on every choice, so the labels line up */ .tick { flex: none; width: var(--gw-icon-size); height: var(--gw-icon-size); color: var(--gw-color-accent); } .item[aria-checked=\"false\"] .tick { visibility: hidden; } .separator { flex: none; height: 0; margin: var(--gw-space-0-5) calc(-1 * var(--gw-space-1)); border: 0; border-block-start: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal); } .text { display: flex; flex-direction: column; gap: var(--gw-space-0-5); } .description { font-size: var(--gw-font-size-xs); color: var(--gw-color-text-secondary); } `, ]",
10361
10361
  "inheritedFrom": {
10362
10362
  "name": "GwElement",
10363
10363
  "module": "src/element.ts"
@@ -10371,6 +10371,15 @@
10371
10371
  },
10372
10372
  "default": "\"\""
10373
10373
  },
10374
+ {
10375
+ "kind": "field",
10376
+ "name": "heading",
10377
+ "type": {
10378
+ "text": "string"
10379
+ },
10380
+ "description": "What the menu is for, such as \"Sort by\", when the trigger shows the current choice.",
10381
+ "default": "\"\""
10382
+ },
10374
10383
  {
10375
10384
  "kind": "field",
10376
10385
  "name": "icon",
@@ -10413,6 +10422,24 @@
10413
10422
  },
10414
10423
  "default": "\"medium\""
10415
10424
  },
10425
+ {
10426
+ "kind": "field",
10427
+ "name": "active",
10428
+ "type": {
10429
+ "text": "boolean"
10430
+ },
10431
+ "description": "Marks the trigger as set, such as a grouping that's on.",
10432
+ "default": "false"
10433
+ },
10434
+ {
10435
+ "kind": "field",
10436
+ "name": "labels",
10437
+ "type": {
10438
+ "text": "\"show\" | \"hide\" | \"auto\""
10439
+ },
10440
+ "description": "Whether the trigger's text shows beside its icon: always, never, or not on narrow screens.",
10441
+ "default": "\"show\""
10442
+ },
10416
10443
  {
10417
10444
  "kind": "field",
10418
10445
  "name": "#trigger",
@@ -10423,6 +10450,18 @@
10423
10450
  "kind": "field",
10424
10451
  "name": "#triggerText",
10425
10452
  "privacy": "private",
10453
+ "type": {
10454
+ "text": "HTMLSpanElement"
10455
+ },
10456
+ "readonly": true
10457
+ },
10458
+ {
10459
+ "kind": "field",
10460
+ "name": "#heading",
10461
+ "privacy": "private",
10462
+ "type": {
10463
+ "text": "HTMLSpanElement"
10464
+ },
10426
10465
  "readonly": true
10427
10466
  },
10428
10467
  {
@@ -10500,6 +10539,11 @@
10500
10539
  }
10501
10540
  ]
10502
10541
  },
10542
+ {
10543
+ "kind": "method",
10544
+ "name": "#tick",
10545
+ "privacy": "private"
10546
+ },
10503
10547
  {
10504
10548
  "kind": "method",
10505
10549
  "name": "create",
@@ -10829,6 +10873,13 @@
10829
10873
  "description": "The trigger's text.",
10830
10874
  "name": "label"
10831
10875
  },
10876
+ {
10877
+ "type": {
10878
+ "text": "string"
10879
+ },
10880
+ "description": "What the menu is for, such as \"Sort by\", when the trigger shows the current choice: it names the menu, starts the trigger's accessible name and is its tooltip.",
10881
+ "name": "heading"
10882
+ },
10832
10883
  {
10833
10884
  "type": {
10834
10885
  "text": "string"
@@ -10863,6 +10914,20 @@
10863
10914
  },
10864
10915
  "description": "The trigger's size, 24 to 56px tall. Defaults to `medium`.",
10865
10916
  "name": "size"
10917
+ },
10918
+ {
10919
+ "type": {
10920
+ "text": "boolean"
10921
+ },
10922
+ "description": "Marks the trigger as set, such as a grouping that's on, with an accent border and text.",
10923
+ "name": "active"
10924
+ },
10925
+ {
10926
+ "type": {
10927
+ "text": "\"show\"|\"hide\"|\"auto\""
10928
+ },
10929
+ "description": "Whether the trigger's text shows beside its icon: always, never, or not on narrow screens. Defaults to `show`.",
10930
+ "name": "labels"
10866
10931
  }
10867
10932
  ],
10868
10933
  "superclass": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-ui",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Groundwork's framework-free web components, styled by the --gw-* tokens",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/gw-button.ts CHANGED
@@ -32,7 +32,7 @@ import { base, controlSizes, createIcon, setIconPath } from "./styles.js";
32
32
  * @attr {"brand"|"neutral"|"info"|"success"|"warning"|"danger"} variant - The color. Defaults to `brand`.
33
33
  * @attr {"accent"|"filled"|"outlined"|"plain"} appearance - How the color is applied. Defaults to `accent`.
34
34
  * @attr {"xs"|"small"|"medium"|"large"|"xl"} size - The size, 24 to 56px tall. Defaults to `medium`.
35
- * @attr {"button"|"submit"|"reset"} type - The native button type. Defaults to `button`.
35
+ * @attr {"button"|"submit"|"reset"} type - What a click does: nothing more, or submit or reset the form the button is in. Defaults to `button`.
36
36
  * @attr {string} href - Renders a link to this address instead of a button.
37
37
  * @attr {string} label - The accessible name, when the text alone isn't enough.
38
38
  * @attr {boolean} disabled - Disables the button.
@@ -423,6 +423,25 @@ export class GwButton extends GwElement {
423
423
  if (link !== null && !event.composedPath().includes(link)) {
424
424
  link.click();
425
425
  }
426
+
427
+ // The native button is in the shadow root, so it has no form of its
428
+ // own; submit and reset act on the form around the element instead
429
+ const form = this.closest("form");
430
+
431
+ if (
432
+ form === null ||
433
+ this.disabled ||
434
+ event.defaultPrevented ||
435
+ !(this.#control instanceof HTMLButtonElement)
436
+ ) {
437
+ return;
438
+ }
439
+
440
+ if (this.type === "submit") {
441
+ form.requestSubmit();
442
+ } else if (this.type === "reset") {
443
+ form.reset();
444
+ }
426
445
  });
427
446
 
428
447
  const focus = () => {
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @module
6
6
  */
7
- import { GwElement, css, h } from "./element.js";
7
+ import { GwElement, css, h, setAttribute } from "./element.js";
8
8
  import { base, controlSizes, createIcon, setIconPath } from "./styles.js";
9
9
 
10
10
  export interface MenuItem {
@@ -15,8 +15,18 @@ export interface MenuItem {
15
15
  /** SVG path data for an icon before the label. */
16
16
  readonly icon?: string;
17
17
  readonly disabled?: boolean;
18
+ /**
19
+ * Whether the item is the one picked, for a menu of choices such as a sort
20
+ * order. Give it on every item in the group: the picked one shows a tick.
21
+ */
22
+ readonly checked?: boolean;
23
+ /** Draws a line above the item, starting a new group. */
24
+ readonly separator?: boolean;
18
25
  }
19
26
 
27
+ // mdi's check, for the picked item in a menu of choices
28
+ const tick = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
29
+
20
30
  /**
21
31
  * A button that opens a menu on the Popover API, so it shows in the top
22
32
  * layer and closes on Esc or a click outside. Set `items`; arrow keys, Home
@@ -27,6 +37,9 @@ export interface MenuItem {
27
37
  * below the items as the list scrolls, so they're found without scrolling to
28
38
  * the end, and with no items the menu says `empty` above them.
29
39
  *
40
+ * For a menu of choices, such as sort and order, give each item `checked`:
41
+ * the picked one shows a tick, and `separator` starts the next group.
42
+ *
30
43
  * Items render inside the component, so a page's own button styles can't
31
44
  * reach them.
32
45
  *
@@ -34,11 +47,14 @@ export interface MenuItem {
34
47
  * @summary A button that opens a menu of items and actions.
35
48
  *
36
49
  * @attr {string} label - The trigger's text.
50
+ * @attr {string} heading - What the menu is for, such as "Sort by", when the trigger shows the current choice: it names the menu, starts the trigger's accessible name and is its tooltip.
37
51
  * @attr {string} icon - SVG path data for an icon before the trigger's text.
38
52
  * @attr {string} empty - What the menu says with no items. Defaults to `Nothing here yet`.
39
53
  * @attr {MenuItem[]} items - The items, as JSON.
40
54
  * @attr {MenuItem[]} actions - Actions pinned under the items, as JSON.
41
55
  * @attr {"xs"|"small"|"medium"|"large"|"xl"} size - The trigger's size, 24 to 56px tall. Defaults to `medium`.
56
+ * @attr {boolean} active - Marks the trigger as set, such as a grouping that's on, with an accent border and text.
57
+ * @attr {"show"|"hide"|"auto"} labels - Whether the trigger's text shows beside its icon: always, never, or not on narrow screens. Defaults to `show`.
42
58
  *
43
59
  * @csspart trigger - The button that opens the menu.
44
60
  *
@@ -50,11 +66,14 @@ export interface MenuItem {
50
66
  export class GwMenuButton extends GwElement {
51
67
  static override properties = {
52
68
  label: {},
69
+ heading: {},
53
70
  icon: {},
54
71
  empty: {},
55
72
  items: { type: "json" },
56
73
  actions: { type: "json" },
57
74
  size: { reflect: true },
75
+ active: { type: "boolean", reflect: true },
76
+ labels: { reflect: true },
58
77
  } as const;
59
78
 
60
79
  static override styles = [
@@ -95,6 +114,40 @@ export class GwMenuButton extends GwElement {
95
114
  background: var(--gw-color-fill-neutral-hovered);
96
115
  }
97
116
 
117
+ :host([active]) .trigger {
118
+ color: var(--gw-color-accent);
119
+ border-color: var(--gw-color-accent);
120
+ }
121
+
122
+ /* Icon only, square, with the text kept for assistive technology */
123
+ :host([labels="hide"]) .trigger.has-icon {
124
+ padding: 0 calc(var(--gw-space-2) * var(--gw-size));
125
+ }
126
+
127
+ :host([labels="hide"]) .has-icon .label {
128
+ position: absolute;
129
+ width: 1px;
130
+ height: 1px;
131
+ overflow: hidden;
132
+ clip-path: inset(50%);
133
+ white-space: nowrap;
134
+ }
135
+
136
+ @media (width < 40rem) {
137
+ :host([labels="auto"]) .trigger.has-icon {
138
+ padding: 0 calc(var(--gw-space-2) * var(--gw-size));
139
+ }
140
+
141
+ :host([labels="auto"]) .has-icon .label {
142
+ position: absolute;
143
+ width: 1px;
144
+ height: 1px;
145
+ overflow: hidden;
146
+ clip-path: inset(50%);
147
+ white-space: nowrap;
148
+ }
149
+ }
150
+
98
151
  .chevron {
99
152
  width: 0.4rem;
100
153
  height: 0.4rem;
@@ -223,6 +276,27 @@ export class GwMenuButton extends GwElement {
223
276
  color: var(--gw-color-accent);
224
277
  }
225
278
 
279
+ /* Holds the tick's place on every choice, so the labels line up */
280
+ .tick {
281
+ flex: none;
282
+ width: var(--gw-icon-size);
283
+ height: var(--gw-icon-size);
284
+ color: var(--gw-color-accent);
285
+ }
286
+
287
+ .item[aria-checked="false"] .tick {
288
+ visibility: hidden;
289
+ }
290
+
291
+ .separator {
292
+ flex: none;
293
+ height: 0;
294
+ margin: var(--gw-space-0-5) calc(-1 * var(--gw-space-1));
295
+ border: 0;
296
+ border-block-start: max(var(--gw-border-width), 1px) solid
297
+ var(--gw-color-border-normal);
298
+ }
299
+
226
300
  .text {
227
301
  display: flex;
228
302
  flex-direction: column;
@@ -237,6 +311,8 @@ export class GwMenuButton extends GwElement {
237
311
  ];
238
312
 
239
313
  declare label: string;
314
+ /** What the menu is for, such as "Sort by", when the trigger shows the current choice. */
315
+ declare heading: string;
240
316
  /** SVG path data for an icon before the trigger's label. */
241
317
  declare icon: string;
242
318
  /** What the menu says when it has no items, such as "No backups yet". */
@@ -244,6 +320,10 @@ export class GwMenuButton extends GwElement {
244
320
  declare items: readonly MenuItem[];
245
321
  declare actions: readonly MenuItem[];
246
322
  declare size: "xs" | "small" | "medium" | "large" | "xl";
323
+ /** Marks the trigger as set, such as a grouping that's on. */
324
+ declare active: boolean;
325
+ /** Whether the trigger's text shows beside its icon: always, never, or not on narrow screens. */
326
+ declare labels: "show" | "hide" | "auto";
247
327
 
248
328
  readonly #trigger = h("button", {
249
329
  class: "trigger",
@@ -253,7 +333,16 @@ export class GwMenuButton extends GwElement {
253
333
  "aria-expanded": "false",
254
334
  });
255
335
 
256
- readonly #triggerText = document.createTextNode("");
336
+ readonly #triggerText: HTMLSpanElement = h("span", {
337
+ class: "label",
338
+ id: "label",
339
+ });
340
+
341
+ // Read before the label, for a trigger that shows the current choice
342
+ readonly #heading: HTMLSpanElement = h("span", {
343
+ id: "heading",
344
+ hidden: true,
345
+ });
257
346
 
258
347
  readonly #triggerIcon = createIcon("");
259
348
 
@@ -271,11 +360,14 @@ export class GwMenuButton extends GwElement {
271
360
  constructor() {
272
361
  super();
273
362
  this.label = "";
363
+ this.heading = "";
274
364
  this.icon = "";
275
365
  this.empty = "Nothing here yet";
276
366
  this.items = [];
277
367
  this.actions = [];
278
368
  this.size = "medium";
369
+ this.active = false;
370
+ this.labels = "show";
279
371
  }
280
372
 
281
373
  /** The enabled items then actions, in the order arrow keys move through them. */
@@ -327,14 +419,18 @@ export class GwMenuButton extends GwElement {
327
419
  }
328
420
 
329
421
  #renderItem(item: MenuItem, kind: "item" | "action") {
422
+ const choice = item.checked !== undefined;
423
+
330
424
  const button = h(
331
425
  "button",
332
426
  {
333
427
  class: kind === "action" ? "item action" : "item",
334
- role: "menuitem",
428
+ role: choice ? "menuitemradio" : "menuitem",
429
+ "aria-checked": choice ? String(item.checked) : null,
335
430
  value: item.value,
336
431
  disabled: item.disabled === true,
337
432
  },
433
+ choice ? this.#tick() : null,
338
434
  item.icon ? createIcon(item.icon) : null,
339
435
  h(
340
436
  "span",
@@ -348,7 +444,17 @@ export class GwMenuButton extends GwElement {
348
444
 
349
445
  button.addEventListener("click", () => this.#choose(item.value));
350
446
 
351
- return button;
447
+ return item.separator === true
448
+ ? [h("hr", { class: "separator", role: "separator" }), button]
449
+ : [button];
450
+ }
451
+
452
+ #tick() {
453
+ const icon = createIcon(tick);
454
+
455
+ icon.classList.add("tick");
456
+
457
+ return icon;
352
458
  }
353
459
 
354
460
  protected override create(root: ShadowRoot) {
@@ -359,14 +465,30 @@ export class GwMenuButton extends GwElement {
359
465
  );
360
466
  this.#popover.addEventListener("toggle", this.#onToggle);
361
467
  this.#popover.addEventListener("keydown", this.#onKeydown);
362
- root.append(this.#trigger, this.#popover);
468
+ root.append(this.#heading, this.#trigger, this.#popover);
363
469
  }
364
470
 
365
471
  protected override update(changed: ReadonlySet<string>) {
366
- this.#triggerText.data = this.label;
472
+ this.#triggerText.textContent = this.label;
367
473
  setIconPath(this.#triggerIcon, this.icon);
368
474
  this.#triggerIcon.toggleAttribute("hidden", !this.icon);
369
- this.#popover.setAttribute("aria-label", this.label);
475
+ this.#trigger.classList.toggle("has-icon", this.icon !== "");
476
+ this.#heading.textContent = this.heading;
477
+ setAttribute(
478
+ this.#trigger,
479
+ "aria-labelledby",
480
+ this.heading ? "heading label" : null,
481
+ );
482
+ // A tooltip saying what the menu is for, or the text when it's hidden
483
+ setAttribute(
484
+ this.#trigger,
485
+ "title",
486
+ this.heading ||
487
+ (this.icon !== "" && this.labels !== "show"
488
+ ? this.label || null
489
+ : null),
490
+ );
491
+ this.#popover.setAttribute("aria-label", this.heading || this.label);
370
492
 
371
493
  if (changed.has("items") || changed.has("empty")) {
372
494
  const items = Array.isArray(this.items) ? this.items : [];
@@ -374,7 +496,7 @@ export class GwMenuButton extends GwElement {
374
496
  this.#items.replaceChildren(
375
497
  ...(items.length === 0
376
498
  ? [h("p", { class: "empty", role: "none" }, this.empty)]
377
- : items.map((item) => this.#renderItem(item, "item"))),
499
+ : items.flatMap((item) => this.#renderItem(item, "item"))),
378
500
  );
379
501
  }
380
502
 
@@ -382,7 +504,7 @@ export class GwMenuButton extends GwElement {
382
504
  const actions = Array.isArray(this.actions) ? this.actions : [];
383
505
 
384
506
  this.#actions.replaceChildren(
385
- ...actions.map((item) => this.#renderItem(item, "action")),
507
+ ...actions.flatMap((item) => this.#renderItem(item, "action")),
386
508
  );
387
509
  this.#actions.hidden = actions.length === 0;
388
510
  }