@timmo001/groundwork-ui 0.9.17 → 0.9.18

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.
@@ -10402,7 +10402,7 @@
10402
10402
  "declarations": [
10403
10403
  {
10404
10404
  "kind": "class",
10405
- "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.",
10405
+ "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.\nItems with `checkbox` toggle instead, firing `check` and keeping the menu\nopen, and `group` lists items under a heading.\n\nItems render inside the component, so a page's own button styles can't\nreach them.",
10406
10406
  "name": "GwMenuButton",
10407
10407
  "cssProperties": [
10408
10408
  {
@@ -10428,7 +10428,7 @@
10428
10428
  "text": "Properties"
10429
10429
  },
10430
10430
  "static": true,
10431
- "default": "{ label: {}, heading: {}, icon: {}, empty: {}, items: { type: \"json\" }, actions: { type: \"json\" }, size: { reflect: true }, appearance: { reflect: true }, active: { type: \"boolean\", reflect: true }, labels: { reflect: true }, }",
10431
+ "default": "{ label: {}, heading: {}, icon: {}, empty: {}, items: { type: \"json\" }, actions: { type: \"json\" }, size: { reflect: true }, appearance: { reflect: true }, active: { type: \"boolean\", reflect: true }, labels: { reflect: true }, loading: { type: \"boolean\", reflect: true }, }",
10432
10432
  "inheritedFrom": {
10433
10433
  "name": "GwElement",
10434
10434
  "module": "src/element.ts"
@@ -10441,7 +10441,7 @@
10441
10441
  "text": "readonly CSSStyleSheet[]"
10442
10442
  },
10443
10443
  "static": true,
10444
- "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); } /* The other appearances draw the trigger as gw-button draws a neutral one, a pill, so it sits in a row of buttons without a seam */ :host([appearance=\"accent\"]) .trigger, :host([appearance=\"outlined\"]) .trigger, :host([appearance=\"plain\"]) .trigger { background: transparent; border: max(var(--gw-border-width), 1px) solid transparent; border-radius: var(--gw-radius-pill); transition: background-color var(--gw-motion-fast) var(--gw-motion-easing), border-color var(--gw-motion-fast) var(--gw-motion-easing), color var(--gw-motion-fast) var(--gw-motion-easing); } :host([appearance=\"outlined\"]) .trigger { border-color: var(--gw-color-text-secondary); } :host([appearance=\"outlined\"]) .trigger:hover, :host([appearance=\"plain\"]) .trigger:hover { background: color-mix( in oklch, var(--gw-color-text-secondary) 16%, transparent ); } /* A solid fill, with text that flips between black and white by the fill's lightness, as gw-button's accent does */ :host([appearance=\"accent\"]) .trigger { color: oklch( from var(--gw-color-accent) clamp(0, (0.66 - l) * 1000, 1) 0 0 ); background: var(--gw-color-accent); } :host([appearance=\"accent\"]) .trigger:hover { background: oklch( from var(--gw-color-accent) calc(l + (0.6 - l) * 0.2) c h ); } :host([active]:not([appearance=\"accent\"])) .trigger { color: var(--gw-color-accent); border-color: var(--gw-color-accent); } /* Icon only, square like an icon-only gw-button, such as a ⋮ for more actions, with the text kept for assistive technology */ :host([labels=\"hide\"]) .trigger.has-icon { justify-content: center; min-width: calc(2.5rem * var(--gw-scale)); padding: 0; } :host([labels=\"hide\"]) .has-icon .chevron { display: none; } :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; } /* Below the button down to the window's edge, or above it when there's more room there, growing to its content and scrolling past that */ [popover] { position-anchor: --gw-menu; position-try-fallbacks: flip-inline; /* The area is the room on that side, so 100% is all of it */ position-area: block-end span-inline-end; inset: auto; box-sizing: border-box; min-width: 15rem; max-width: calc(100vw - 2 * var(--gw-space-2)); max-height: min(24rem, calc(100% - 2 * var(--gw-space-2))); padding: 0; margin: var(--gw-space-1) 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); } `, ]",
10444
+ "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); } /* While a chosen action runs, a ring takes the icon's place */ .ring { display: none; flex: none; width: calc(var(--gw-icon-size) * 0.8); height: calc(var(--gw-icon-size) * 0.8); border: calc(2px * var(--gw-scale)) solid color-mix(in oklch, currentColor 25%, transparent); border-top-color: currentColor; border-radius: 50%; animation: spin 0.9s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } :host([loading]) .trigger { cursor: progress; } :host([loading]) .ring { display: block; } :host([loading]) .trigger > .icon { display: none; } /* The other appearances draw the trigger as gw-button draws a neutral one, a pill, so it sits in a row of buttons without a seam */ :host([appearance=\"accent\"]) .trigger, :host([appearance=\"outlined\"]) .trigger, :host([appearance=\"plain\"]) .trigger { background: transparent; border: max(var(--gw-border-width), 1px) solid transparent; border-radius: var(--gw-radius-pill); transition: background-color var(--gw-motion-fast) var(--gw-motion-easing), border-color var(--gw-motion-fast) var(--gw-motion-easing), color var(--gw-motion-fast) var(--gw-motion-easing); } :host([appearance=\"outlined\"]) .trigger { border-color: var(--gw-color-text-secondary); } :host([appearance=\"outlined\"]) .trigger:hover, :host([appearance=\"plain\"]) .trigger:hover { background: color-mix( in oklch, var(--gw-color-text-secondary) 16%, transparent ); } /* A solid fill, with text that flips between black and white by the fill's lightness, as gw-button's accent does */ :host([appearance=\"accent\"]) .trigger { color: oklch( from var(--gw-color-accent) clamp(0, (0.66 - l) * 1000, 1) 0 0 ); background: var(--gw-color-accent); } :host([appearance=\"accent\"]) .trigger:hover { background: oklch( from var(--gw-color-accent) calc(l + (0.6 - l) * 0.2) c h ); } :host([active]:not([appearance=\"accent\"])) .trigger { color: var(--gw-color-accent); border-color: var(--gw-color-accent); } /* Icon only, square like an icon-only gw-button, such as a ⋮ for more actions, with the text kept for assistive technology */ :host([labels=\"hide\"]) .trigger.has-icon { justify-content: center; min-width: calc(2.5rem * var(--gw-scale)); padding: 0; } :host([labels=\"hide\"]) .has-icon .chevron { display: none; } :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; } /* Below the button down to the window's edge, or above it when there's more room there, growing to its content and scrolling past that */ [popover] { position-anchor: --gw-menu; position-try-fallbacks: flip-inline; /* The area is the room on that side, so 100% is all of it */ position-area: block-end span-inline-end; inset: auto; box-sizing: border-box; min-width: 15rem; max-width: calc(100vw - 2 * var(--gw-space-2)); max-height: min(24rem, calc(100% - 2 * var(--gw-space-2))); padding: 0; margin: var(--gw-space-1) 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; } .item[role=\"menuitemcheckbox\"] .box { flex: none; color: var(--gw-color-text-secondary); } .item[role=\"menuitemcheckbox\"][aria-checked=\"true\"] .box { color: var(--gw-color-accent); } .group { display: flex; flex-direction: column; gap: var(--gw-space-0-5); } .group:not(:first-child) { padding-block-start: var(--gw-space-1); margin-block-start: var(--gw-space-0-5); border-block-start: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal); } .group-heading { padding: var(--gw-space-1) var(--gw-space-3) 0; margin: 0; font-size: var(--gw-font-size-xs); font-weight: var(--gw-font-weight-strong); color: var(--gw-color-text-secondary); } .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); } `, ]",
10445
10445
  "inheritedFrom": {
10446
10446
  "name": "GwElement",
10447
10447
  "module": "src/element.ts"
@@ -10533,6 +10533,15 @@
10533
10533
  "description": "Whether the trigger's text shows beside its icon: always, never, or not on narrow screens.",
10534
10534
  "default": "\"show\""
10535
10535
  },
10536
+ {
10537
+ "kind": "field",
10538
+ "name": "loading",
10539
+ "type": {
10540
+ "text": "boolean"
10541
+ },
10542
+ "description": "Shows a spinning ring in the icon's place and disables the trigger.",
10543
+ "default": "false"
10544
+ },
10536
10545
  {
10537
10546
  "kind": "field",
10538
10547
  "name": "#trigger",
@@ -10613,6 +10622,19 @@
10613
10622
  }
10614
10623
  ]
10615
10624
  },
10625
+ {
10626
+ "kind": "method",
10627
+ "name": "#check",
10628
+ "privacy": "private",
10629
+ "parameters": [
10630
+ {
10631
+ "name": "item",
10632
+ "type": {
10633
+ "text": "MenuItem"
10634
+ }
10635
+ }
10636
+ ]
10637
+ },
10616
10638
  {
10617
10639
  "kind": "method",
10618
10640
  "name": "#renderItem",
@@ -10632,11 +10654,38 @@
10632
10654
  }
10633
10655
  ]
10634
10656
  },
10657
+ {
10658
+ "kind": "method",
10659
+ "name": "#renderItems",
10660
+ "privacy": "private",
10661
+ "parameters": [
10662
+ {
10663
+ "name": "items",
10664
+ "type": {
10665
+ "text": "readonly MenuItem[]"
10666
+ }
10667
+ }
10668
+ ],
10669
+ "description": "Items in runs that share a group, each run under its heading."
10670
+ },
10635
10671
  {
10636
10672
  "kind": "method",
10637
10673
  "name": "#tick",
10638
10674
  "privacy": "private"
10639
10675
  },
10676
+ {
10677
+ "kind": "method",
10678
+ "name": "#box",
10679
+ "privacy": "private",
10680
+ "parameters": [
10681
+ {
10682
+ "name": "checked",
10683
+ "type": {
10684
+ "text": "boolean"
10685
+ }
10686
+ }
10687
+ ]
10688
+ },
10640
10689
  {
10641
10690
  "kind": "method",
10642
10691
  "name": "create",
@@ -10956,6 +11005,13 @@
10956
11005
  "text": "CustomEvent<{ value: string }>"
10957
11006
  },
10958
11007
  "description": "When an item or action is chosen, with its `value` in `detail`."
11008
+ },
11009
+ {
11010
+ "name": "check",
11011
+ "type": {
11012
+ "text": "CustomEvent<{ value: string, checked: boolean }>"
11013
+ },
11014
+ "description": "When a checkbox item is toggled, with its `value` and the state it should change to in `detail`."
10959
11015
  }
10960
11016
  ],
10961
11017
  "attributes": [
@@ -11028,6 +11084,13 @@
11028
11084
  },
11029
11085
  "description": "Whether the trigger's text shows beside its icon: always, never (a square icon button without the chevron, such as ⋮ for more actions), or not on narrow screens. Defaults to `show`.",
11030
11086
  "name": "labels"
11087
+ },
11088
+ {
11089
+ "type": {
11090
+ "text": "boolean"
11091
+ },
11092
+ "description": "Shows a spinning ring in the icon's place and disables the trigger, while a chosen action runs.",
11093
+ "name": "loading"
11031
11094
  }
11032
11095
  ],
11033
11096
  "superclass": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-ui",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
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/events.ts CHANGED
@@ -16,6 +16,7 @@ export interface GwEventMap {
16
16
  };
17
17
  "gw-menu-button": {
18
18
  select: CustomEvent<{ value: string }>;
19
+ check: CustomEvent<{ value: string, checked: boolean }>;
19
20
  };
20
21
  "gw-notification": {
21
22
  dismiss: Event;
@@ -20,13 +20,31 @@ export interface MenuItem {
20
20
  * order. Give it on every item in the group: the picked one shows a tick.
21
21
  */
22
22
  readonly checked?: boolean;
23
+ /**
24
+ * Makes the item a checkbox, such as a column to show: choosing it fires
25
+ * `check` with the state it should change to and keeps the menu open, so
26
+ * several can be changed in a row. `checked` is its current state.
27
+ */
28
+ readonly checkbox?: boolean;
23
29
  /** Draws a line above the item, starting a new group. */
24
30
  readonly separator?: boolean;
31
+ /**
32
+ * Names the group the item is in. Items in a row with the same group are
33
+ * listed together under it as a heading, after a line.
34
+ */
35
+ readonly group?: string;
25
36
  }
26
37
 
27
38
  // mdi's check, for the picked item in a menu of choices
28
39
  const tick = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
29
40
 
41
+ // mdi's checkbox-marked and checkbox-blank-outline, for checkbox items
42
+ const boxChecked =
43
+ "M10,17L5,12L6.41,10.58L10,14.17L17.59,6.58L19,8M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z";
44
+
45
+ const boxEmpty =
46
+ "M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z";
47
+
30
48
  /**
31
49
  * A button that opens a menu on the Popover API, so it shows in the top
32
50
  * layer and closes on Esc or a click outside. Set `items`; arrow keys, Home
@@ -39,6 +57,8 @@ const tick = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
39
57
  *
40
58
  * For a menu of choices, such as sort and order, give each item `checked`:
41
59
  * the picked one shows a tick, and `separator` starts the next group.
60
+ * Items with `checkbox` toggle instead, firing `check` and keeping the menu
61
+ * open, and `group` lists items under a heading.
42
62
  *
43
63
  * Items render inside the component, so a page's own button styles can't
44
64
  * reach them.
@@ -56,6 +76,7 @@ const tick = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
56
76
  * @attr {"accent"|"filled"|"outlined"|"plain"} appearance - How the trigger is drawn: `filled` is a neutral fill with a border, and the others match a neutral `gw-button` of that appearance, so it sits in a row of them. Defaults to `filled`.
57
77
  * @attr {boolean} active - Marks the trigger as set, such as a grouping that's on, with an accent border and text.
58
78
  * @attr {"show"|"hide"|"auto"} labels - Whether the trigger's text shows beside its icon: always, never (a square icon button without the chevron, such as ⋮ for more actions), or not on narrow screens. Defaults to `show`.
79
+ * @attr {boolean} loading - Shows a spinning ring in the icon's place and disables the trigger, while a chosen action runs.
59
80
  *
60
81
  * @csspart trigger - The button that opens the menu.
61
82
  *
@@ -63,6 +84,7 @@ const tick = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
63
84
  * @cssprop --gw-control-scale - Scales its height and icon with the theme's density. The theme sets it.
64
85
  *
65
86
  * @fires {CustomEvent<{ value: string }>} select - When an item or action is chosen, with its `value` in `detail`.
87
+ * @fires {CustomEvent<{ value: string, checked: boolean }>} check - When a checkbox item is toggled, with its `value` and the state it should change to in `detail`.
66
88
  */
67
89
  export class GwMenuButton extends GwElement {
68
90
  static override properties = {
@@ -76,6 +98,7 @@ export class GwMenuButton extends GwElement {
76
98
  appearance: { reflect: true },
77
99
  active: { type: "boolean", reflect: true },
78
100
  labels: { reflect: true },
101
+ loading: { type: "boolean", reflect: true },
79
102
  } as const;
80
103
 
81
104
  static override styles = [
@@ -116,6 +139,37 @@ export class GwMenuButton extends GwElement {
116
139
  background: var(--gw-color-fill-neutral-hovered);
117
140
  }
118
141
 
142
+ /* While a chosen action runs, a ring takes the icon's place */
143
+ .ring {
144
+ display: none;
145
+ flex: none;
146
+ width: calc(var(--gw-icon-size) * 0.8);
147
+ height: calc(var(--gw-icon-size) * 0.8);
148
+ border: calc(2px * var(--gw-scale)) solid
149
+ color-mix(in oklch, currentColor 25%, transparent);
150
+ border-top-color: currentColor;
151
+ border-radius: 50%;
152
+ animation: spin 0.9s linear infinite;
153
+ }
154
+
155
+ @keyframes spin {
156
+ to {
157
+ transform: rotate(360deg);
158
+ }
159
+ }
160
+
161
+ :host([loading]) .trigger {
162
+ cursor: progress;
163
+ }
164
+
165
+ :host([loading]) .ring {
166
+ display: block;
167
+ }
168
+
169
+ :host([loading]) .trigger > .icon {
170
+ display: none;
171
+ }
172
+
119
173
  /* The other appearances draw the trigger as gw-button draws a neutral
120
174
  one, a pill, so it sits in a row of buttons without a seam */
121
175
  :host([appearance="accent"]) .trigger,
@@ -342,6 +396,36 @@ export class GwMenuButton extends GwElement {
342
396
  visibility: hidden;
343
397
  }
344
398
 
399
+ .item[role="menuitemcheckbox"] .box {
400
+ flex: none;
401
+ color: var(--gw-color-text-secondary);
402
+ }
403
+
404
+ .item[role="menuitemcheckbox"][aria-checked="true"] .box {
405
+ color: var(--gw-color-accent);
406
+ }
407
+
408
+ .group {
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: var(--gw-space-0-5);
412
+ }
413
+
414
+ .group:not(:first-child) {
415
+ padding-block-start: var(--gw-space-1);
416
+ margin-block-start: var(--gw-space-0-5);
417
+ border-block-start: max(var(--gw-border-width), 1px) solid
418
+ var(--gw-color-border-normal);
419
+ }
420
+
421
+ .group-heading {
422
+ padding: var(--gw-space-1) var(--gw-space-3) 0;
423
+ margin: 0;
424
+ font-size: var(--gw-font-size-xs);
425
+ font-weight: var(--gw-font-weight-strong);
426
+ color: var(--gw-color-text-secondary);
427
+ }
428
+
345
429
  .separator {
346
430
  flex: none;
347
431
  height: 0;
@@ -380,6 +464,8 @@ export class GwMenuButton extends GwElement {
380
464
  declare active: boolean;
381
465
  /** Whether the trigger's text shows beside its icon: always, never, or not on narrow screens. */
382
466
  declare labels: "show" | "hide" | "auto";
467
+ /** Shows a spinning ring in the icon's place and disables the trigger. */
468
+ declare loading: boolean;
383
469
 
384
470
  readonly #trigger = h("button", {
385
471
  class: "trigger",
@@ -425,6 +511,7 @@ export class GwMenuButton extends GwElement {
425
511
  this.appearance = "filled";
426
512
  this.active = false;
427
513
  this.labels = "show";
514
+ this.loading = false;
428
515
  }
429
516
 
430
517
  /** The enabled items then actions, in the order arrow keys move through them. */
@@ -493,18 +580,36 @@ export class GwMenuButton extends GwElement {
493
580
  this.#trigger.focus();
494
581
  }
495
582
 
583
+ // A checkbox keeps the menu open, so several can be changed in a row
584
+ #check(item: MenuItem) {
585
+ this.dispatchEvent(
586
+ new CustomEvent("check", {
587
+ detail: { value: item.value, checked: item.checked !== true },
588
+ bubbles: true,
589
+ composed: true,
590
+ }),
591
+ );
592
+ }
593
+
496
594
  #renderItem(item: MenuItem, kind: "item" | "action") {
497
- const choice = item.checked !== undefined;
595
+ const checkbox = item.checkbox === true;
596
+ const choice = !checkbox && item.checked !== undefined;
498
597
 
499
598
  const button = h(
500
599
  "button",
501
600
  {
502
601
  class: kind === "action" ? "item action" : "item",
503
- role: choice ? "menuitemradio" : "menuitem",
504
- "aria-checked": choice ? String(item.checked) : null,
602
+ role: checkbox
603
+ ? "menuitemcheckbox"
604
+ : choice
605
+ ? "menuitemradio"
606
+ : "menuitem",
607
+ "aria-checked":
608
+ checkbox || choice ? String(item.checked === true) : null,
505
609
  value: item.value,
506
610
  disabled: item.disabled === true,
507
611
  },
612
+ checkbox ? this.#box(item.checked === true) : null,
508
613
  choice ? this.#tick() : null,
509
614
  item.icon ? createIcon(item.icon) : null,
510
615
  h(
@@ -517,13 +622,55 @@ export class GwMenuButton extends GwElement {
517
622
  ),
518
623
  );
519
624
 
520
- button.addEventListener("click", () => this.#choose(item.value));
625
+ button.addEventListener("click", () => {
626
+ if (checkbox) {
627
+ this.#check(item);
628
+ } else {
629
+ this.#choose(item.value);
630
+ }
631
+ });
521
632
 
522
633
  return item.separator === true
523
634
  ? [h("hr", { class: "separator", role: "separator" }), button]
524
635
  : [button];
525
636
  }
526
637
 
638
+ /** Items in runs that share a group, each run under its heading. */
639
+ #renderItems(items: readonly MenuItem[]) {
640
+ if (!items.some((item) => item.group !== undefined)) {
641
+ return items.flatMap((item) => this.#renderItem(item, "item"));
642
+ }
643
+
644
+ const runs: { group: string | undefined; items: MenuItem[] }[] = [];
645
+
646
+ for (const item of items) {
647
+ const run = runs.at(-1);
648
+
649
+ if (run !== undefined && run.group === item.group) {
650
+ run.items.push(item);
651
+ } else {
652
+ runs.push({ group: item.group, items: [item] });
653
+ }
654
+ }
655
+
656
+ return runs.map(({ group, items: grouped }, index) => {
657
+ const id = `group-${index}`;
658
+
659
+ return h(
660
+ "div",
661
+ {
662
+ class: "group",
663
+ role: "group",
664
+ "aria-labelledby": group === undefined ? null : id,
665
+ },
666
+ group === undefined
667
+ ? null
668
+ : h("p", { class: "group-heading", id, role: "none" }, group),
669
+ ...grouped.flatMap((item) => this.#renderItem(item, "item")),
670
+ );
671
+ });
672
+ }
673
+
527
674
  #tick() {
528
675
  const icon = createIcon(tick);
529
676
 
@@ -532,9 +679,18 @@ export class GwMenuButton extends GwElement {
532
679
  return icon;
533
680
  }
534
681
 
682
+ #box(checked: boolean) {
683
+ const icon = createIcon(checked ? boxChecked : boxEmpty);
684
+
685
+ icon.classList.add("box");
686
+
687
+ return icon;
688
+ }
689
+
535
690
  protected override create(root: ShadowRoot) {
536
691
  this.#trigger.append(
537
692
  this.#triggerIcon,
693
+ h("span", { class: "ring", "aria-hidden": "true" }),
538
694
  this.#triggerText,
539
695
  h("span", { class: "chevron", "aria-hidden": "true" }),
540
696
  );
@@ -564,15 +720,30 @@ export class GwMenuButton extends GwElement {
564
720
  : null),
565
721
  );
566
722
  this.#popover.setAttribute("aria-label", this.heading || this.label);
723
+ this.#trigger.disabled = this.loading;
724
+ setAttribute(this.#trigger, "aria-busy", this.loading ? "true" : null);
567
725
 
568
726
  if (changed.has("items") || changed.has("empty")) {
569
727
  const items = Array.isArray(this.items) ? this.items : [];
570
728
 
729
+ // A checkbox's change re-renders the list with the menu open, so focus
730
+ // goes back to the item that had it
731
+ const focused = this.shadowRoot?.activeElement;
732
+
733
+ const value =
734
+ focused instanceof HTMLButtonElement && this.#items.contains(focused)
735
+ ? focused.value
736
+ : undefined;
737
+
571
738
  this.#items.replaceChildren(
572
739
  ...(items.length === 0
573
740
  ? [h("p", { class: "empty", role: "none" }, this.empty)]
574
- : items.flatMap((item) => this.#renderItem(item, "item"))),
741
+ : this.#renderItems(items)),
575
742
  );
743
+
744
+ if (value !== undefined) {
745
+ this.#buttons.find((button) => button.value === value)?.focus();
746
+ }
576
747
  }
577
748
 
578
749
  if (changed.has("actions")) {