@timmo001/groundwork-ui 0.9.3 → 0.9.5

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.
@@ -1628,6 +1628,9 @@
1628
1628
  "kind": "field",
1629
1629
  "name": "#nav",
1630
1630
  "privacy": "private",
1631
+ "type": {
1632
+ "text": "HTMLDivElement"
1633
+ },
1631
1634
  "readonly": true
1632
1635
  },
1633
1636
  {
@@ -2228,6 +2231,9 @@
2228
2231
  "kind": "field",
2229
2232
  "name": "#value",
2230
2233
  "privacy": "private",
2234
+ "type": {
2235
+ "text": "HTMLSpanElement"
2236
+ },
2231
2237
  "readonly": true
2232
2238
  },
2233
2239
  {
@@ -2696,6 +2702,9 @@
2696
2702
  "kind": "field",
2697
2703
  "name": "#bar",
2698
2704
  "privacy": "private",
2705
+ "type": {
2706
+ "text": "HTMLDivElement"
2707
+ },
2699
2708
  "readonly": true
2700
2709
  },
2701
2710
  {
@@ -3816,6 +3825,9 @@
3816
3825
  "kind": "field",
3817
3826
  "name": "#header",
3818
3827
  "privacy": "private",
3828
+ "type": {
3829
+ "text": "HTMLElement"
3830
+ },
3819
3831
  "readonly": true
3820
3832
  },
3821
3833
  {
@@ -3828,6 +3840,9 @@
3828
3840
  "kind": "field",
3829
3841
  "name": "#body",
3830
3842
  "privacy": "private",
3843
+ "type": {
3844
+ "text": "HTMLDivElement"
3845
+ },
3831
3846
  "readonly": true
3832
3847
  },
3833
3848
  {
@@ -5461,6 +5476,9 @@
5461
5476
  "kind": "field",
5462
5477
  "name": "#content",
5463
5478
  "privacy": "private",
5479
+ "type": {
5480
+ "text": "HTMLSpanElement"
5481
+ },
5464
5482
  "readonly": true
5465
5483
  },
5466
5484
  {
@@ -5932,6 +5950,9 @@
5932
5950
  "kind": "field",
5933
5951
  "name": "#dialog",
5934
5952
  "privacy": "private",
5953
+ "type": {
5954
+ "text": "HTMLDialogElement"
5955
+ },
5935
5956
  "readonly": true
5936
5957
  },
5937
5958
  {
@@ -7809,6 +7830,9 @@
7809
7830
  "kind": "field",
7810
7831
  "name": "#details",
7811
7832
  "privacy": "private",
7833
+ "type": {
7834
+ "text": "HTMLDetailsElement"
7835
+ },
7812
7836
  "readonly": true
7813
7837
  },
7814
7838
  {
@@ -10318,7 +10342,7 @@
10318
10342
  "declarations": [
10319
10343
  {
10320
10344
  "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.",
10345
+ "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
10346
  "name": "GwMenuButton",
10323
10347
  "cssProperties": [
10324
10348
  {
@@ -10344,7 +10368,7 @@
10344
10368
  "text": "Properties"
10345
10369
  },
10346
10370
  "static": true,
10347
- "default": "{ label: {}, icon: {}, empty: {}, items: { type: \"json\" }, actions: { type: \"json\" }, size: { reflect: true }, }",
10371
+ "default": "{ label: {}, heading: {}, icon: {}, empty: {}, items: { type: \"json\" }, actions: { type: \"json\" }, size: { reflect: true }, active: { type: \"boolean\", reflect: true }, labels: { reflect: true }, }",
10348
10372
  "inheritedFrom": {
10349
10373
  "name": "GwElement",
10350
10374
  "module": "src/element.ts"
@@ -10357,7 +10381,7 @@
10357
10381
  "text": "readonly CSSStyleSheet[]"
10358
10382
  },
10359
10383
  "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); } `, ]",
10384
+ "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
10385
  "inheritedFrom": {
10362
10386
  "name": "GwElement",
10363
10387
  "module": "src/element.ts"
@@ -10371,6 +10395,15 @@
10371
10395
  },
10372
10396
  "default": "\"\""
10373
10397
  },
10398
+ {
10399
+ "kind": "field",
10400
+ "name": "heading",
10401
+ "type": {
10402
+ "text": "string"
10403
+ },
10404
+ "description": "What the menu is for, such as \"Sort by\", when the trigger shows the current choice.",
10405
+ "default": "\"\""
10406
+ },
10374
10407
  {
10375
10408
  "kind": "field",
10376
10409
  "name": "icon",
@@ -10413,6 +10446,24 @@
10413
10446
  },
10414
10447
  "default": "\"medium\""
10415
10448
  },
10449
+ {
10450
+ "kind": "field",
10451
+ "name": "active",
10452
+ "type": {
10453
+ "text": "boolean"
10454
+ },
10455
+ "description": "Marks the trigger as set, such as a grouping that's on.",
10456
+ "default": "false"
10457
+ },
10458
+ {
10459
+ "kind": "field",
10460
+ "name": "labels",
10461
+ "type": {
10462
+ "text": "\"show\" | \"hide\" | \"auto\""
10463
+ },
10464
+ "description": "Whether the trigger's text shows beside its icon: always, never, or not on narrow screens.",
10465
+ "default": "\"show\""
10466
+ },
10416
10467
  {
10417
10468
  "kind": "field",
10418
10469
  "name": "#trigger",
@@ -10423,6 +10474,18 @@
10423
10474
  "kind": "field",
10424
10475
  "name": "#triggerText",
10425
10476
  "privacy": "private",
10477
+ "type": {
10478
+ "text": "HTMLSpanElement"
10479
+ },
10480
+ "readonly": true
10481
+ },
10482
+ {
10483
+ "kind": "field",
10484
+ "name": "#heading",
10485
+ "privacy": "private",
10486
+ "type": {
10487
+ "text": "HTMLSpanElement"
10488
+ },
10426
10489
  "readonly": true
10427
10490
  },
10428
10491
  {
@@ -10500,6 +10563,11 @@
10500
10563
  }
10501
10564
  ]
10502
10565
  },
10566
+ {
10567
+ "kind": "method",
10568
+ "name": "#tick",
10569
+ "privacy": "private"
10570
+ },
10503
10571
  {
10504
10572
  "kind": "method",
10505
10573
  "name": "create",
@@ -10829,6 +10897,13 @@
10829
10897
  "description": "The trigger's text.",
10830
10898
  "name": "label"
10831
10899
  },
10900
+ {
10901
+ "type": {
10902
+ "text": "string"
10903
+ },
10904
+ "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.",
10905
+ "name": "heading"
10906
+ },
10832
10907
  {
10833
10908
  "type": {
10834
10909
  "text": "string"
@@ -10863,6 +10938,20 @@
10863
10938
  },
10864
10939
  "description": "The trigger's size, 24 to 56px tall. Defaults to `medium`.",
10865
10940
  "name": "size"
10941
+ },
10942
+ {
10943
+ "type": {
10944
+ "text": "boolean"
10945
+ },
10946
+ "description": "Marks the trigger as set, such as a grouping that's on, with an accent border and text.",
10947
+ "name": "active"
10948
+ },
10949
+ {
10950
+ "type": {
10951
+ "text": "\"show\"|\"hide\"|\"auto\""
10952
+ },
10953
+ "description": "Whether the trigger's text shows beside its icon: always, never, or not on narrow screens. Defaults to `show`.",
10954
+ "name": "labels"
10866
10955
  }
10867
10956
  ],
10868
10957
  "superclass": {
@@ -14260,6 +14349,9 @@
14260
14349
  "kind": "field",
14261
14350
  "name": "#heading",
14262
14351
  "privacy": "private",
14352
+ "type": {
14353
+ "text": "HTMLDivElement"
14354
+ },
14263
14355
  "readonly": true
14264
14356
  },
14265
14357
  {
@@ -16014,6 +16106,9 @@
16014
16106
  "kind": "field",
16015
16107
  "name": "#descriptionSkeleton",
16016
16108
  "privacy": "private",
16109
+ "type": {
16110
+ "text": "HTMLDivElement"
16111
+ },
16017
16112
  "readonly": true
16018
16113
  },
16019
16114
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-ui",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Groundwork's framework-free web components, styled by the --gw-* tokens",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "scripts": {
29
29
  "check": "bun run --parallel --no-exit-on-error lint typecheck format:check",
30
30
  "lint": "oxlint .",
31
- "typecheck": "tsc",
31
+ "typecheck": "tsc && bun run scripts/typecheck-alone.ts",
32
32
  "format": "prettier --write .",
33
33
  "format:check": "prettier --check .",
34
34
  "gen": "bun run scripts/manifest.ts",
package/src/gw-app-bar.ts CHANGED
@@ -237,7 +237,11 @@ export class GwAppBar extends GwElement {
237
237
  this.#menuIcon,
238
238
  );
239
239
 
240
- readonly #nav = h("div", { class: "nav", part: "nav" }, h("slot"));
240
+ readonly #nav: HTMLDivElement = h(
241
+ "div",
242
+ { class: "nav", part: "nav" },
243
+ h("slot"),
244
+ );
241
245
 
242
246
  #observer: IntersectionObserver | undefined;
243
247
 
package/src/gw-badge.ts CHANGED
@@ -134,7 +134,7 @@ export class GwBadge extends GwElement {
134
134
  style: "--gw-skeleton-width: 3rem",
135
135
  });
136
136
 
137
- readonly #value = h("span", { class: "value" }, h("slot"));
137
+ readonly #value: HTMLSpanElement = h("span", { class: "value" }, h("slot"));
138
138
 
139
139
  constructor() {
140
140
  super();
package/src/gw-banner.ts CHANGED
@@ -90,7 +90,7 @@ export class GwBanner extends GwElement {
90
90
  declare open: boolean;
91
91
  declare urgent: boolean;
92
92
 
93
- readonly #bar = h(
93
+ readonly #bar: HTMLDivElement = h(
94
94
  "div",
95
95
  { class: "bar", part: "bar" },
96
96
  h("slot", { name: "icon" }),
package/src/gw-card.ts CHANGED
@@ -139,7 +139,7 @@ export class GwCard extends GwElement {
139
139
 
140
140
  readonly #headingSlot = h("slot", { name: "heading" });
141
141
 
142
- readonly #header = h(
142
+ readonly #header: HTMLElement = h(
143
143
  "header",
144
144
  { part: "header" },
145
145
  h(
@@ -154,7 +154,11 @@ export class GwCard extends GwElement {
154
154
 
155
155
  readonly #skeleton = h("gw-skeleton-lines");
156
156
 
157
- readonly #body = h("div", { class: "body", part: "body" }, h("slot"));
157
+ readonly #body: HTMLDivElement = h(
158
+ "div",
159
+ { class: "body", part: "body" },
160
+ h("slot"),
161
+ );
158
162
 
159
163
  #card: HTMLElement = h("section", { class: "card", part: "card" });
160
164
 
package/src/gw-chip.ts CHANGED
@@ -115,7 +115,7 @@ export class GwChip extends GwElement {
115
115
  style: "--gw-skeleton-width: 3.5rem",
116
116
  });
117
117
 
118
- readonly #content = h(
118
+ readonly #content: HTMLSpanElement = h(
119
119
  "span",
120
120
  { class: "content" },
121
121
  h("slot", { name: "icon" }, this.#icon, this.#swatch),
@@ -156,7 +156,7 @@ export class GwCommandMenu extends GwElement {
156
156
 
157
157
  readonly #list = h("gw-option-list", { search: true, part: "list" });
158
158
 
159
- readonly #dialog = h(
159
+ readonly #dialog: HTMLDialogElement = h(
160
160
  "dialog",
161
161
  { part: "dialog" },
162
162
  this.#list,
@@ -178,7 +178,7 @@ export class GwExpansion extends GwElement {
178
178
  part: "description",
179
179
  });
180
180
 
181
- readonly #details = h(
181
+ readonly #details: HTMLDetailsElement = h(
182
182
  "details",
183
183
  { part: "details" },
184
184
  h(
@@ -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
  }
@@ -48,7 +48,11 @@ export class GwSectionTitle extends GwElement {
48
48
 
49
49
  declare level: number;
50
50
 
51
- readonly #heading = h("div", { role: "heading", part: "heading" }, h("slot"));
51
+ readonly #heading: HTMLDivElement = h(
52
+ "div",
53
+ { role: "heading", part: "heading" },
54
+ h("slot"),
55
+ );
52
56
 
53
57
  constructor() {
54
58
  super();
@@ -115,7 +115,7 @@ export class GwSettingRow extends GwElement {
115
115
 
116
116
  readonly #description = h("div", { class: "description" });
117
117
 
118
- readonly #descriptionSkeleton = h(
118
+ readonly #descriptionSkeleton: HTMLDivElement = h(
119
119
  "div",
120
120
  { class: "description" },
121
121
  h("gw-skeleton-text", { style: "--gw-skeleton-width: 12rem" }),