@inglorious/ui 8.6.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/controls/button/template.js +4 -4
  3. package/src/controls/combobox/style.css +14 -7
  4. package/src/controls/combobox/template.js +12 -2
  5. package/src/controls/rating/style.css +26 -4
  6. package/src/controls/rating/template.js +29 -12
  7. package/src/controls/switch/template.js +3 -1
  8. package/src/data-display/tooltip/style.css +22 -14
  9. package/src/examples/dashboard/primitive-content.js +6 -6
  10. package/src/examples/dashboard/style.css +11 -5
  11. package/src/feedback/dialog/dialog.stories.js +9 -0
  12. package/src/feedback/dialog/style.css +13 -12
  13. package/src/feedback/dialog/template.js +63 -18
  14. package/src/feedback/progress/style.css +47 -23
  15. package/src/feedback/progress/template.js +18 -11
  16. package/src/feedback/snackbar/style.css +13 -0
  17. package/src/feedback/snackbar/template.js +7 -0
  18. package/src/navigation/drawer/drawer.stories.js +3 -7
  19. package/src/navigation/drawer/style.css +59 -18
  20. package/src/navigation/drawer/template.js +137 -80
  21. package/src/navigation/menu/menu.stories.js +15 -0
  22. package/src/navigation/menu/style.css +19 -0
  23. package/src/navigation/menu/template.js +59 -6
  24. package/src/navigation/speed-dial/style.css +32 -5
  25. package/src/navigation/speed-dial/template.js +41 -12
  26. package/src/navigation/tabs/template.js +52 -4
  27. package/src/shared/anchor.js +11 -0
  28. package/src/shared/top-layer.js +83 -0
  29. package/src/surfaces/accordion/style.css +14 -7
  30. package/src/surfaces/accordion/template.js +27 -20
  31. package/types/controls/rating.d.ts +4 -0
  32. package/types/feedback/dialog.d.ts +3 -1
  33. package/types/feedback/progress.d.ts +2 -0
  34. package/types/navigation/drawer.d.ts +8 -3
  35. package/types/navigation/menu.d.ts +17 -0
  36. package/types/navigation/speed-dial.d.ts +5 -0
  37. package/types/surfaces/accordion.d.ts +2 -0
@@ -4,40 +4,76 @@
4
4
  * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
5
5
  */
6
6
 
7
- import { html } from "@inglorious/web"
7
+ import { html, nothing } from "@inglorious/web"
8
8
  import { classMap } from "@inglorious/web/directives/class-map"
9
9
  import { ref } from "@inglorious/web/directives/ref"
10
10
 
11
+ import { Button } from "../../controls/button/index.js"
12
+ import { anchorName } from "../../shared/anchor.js"
11
13
  import { applyElementProps } from "../../shared/apply-element-props.js"
14
+ import { syncPopover } from "../../shared/top-layer.js"
12
15
 
13
16
  export const Menu = {
14
17
  /**
15
18
  * Main entrypoint for a menu list. Delegates to the base renderer for overrides.
16
19
  * Menus render selectable items and optional dividers.
20
+ * With a `trigger`, the menu is a native popover anchored to a button that
21
+ * opens it: the browser places it, flipping it to stay on screen, and closes
22
+ * it on Esc, a press outside, or a pick.
17
23
  * @param {MenuProps} props
18
24
  * @returns {TemplateResult | null}
19
25
  */
20
26
  render(props) {
21
- return this.renderMenu(props)
27
+ if (!props.trigger) return this.renderMenu(props)
28
+ return html`${this.renderTrigger(props)}${this.renderMenu(props)}`
22
29
  },
23
30
 
24
31
  /**
25
- * Renders the menu container and iterates items when open.
26
- * Each item is delegated to `renderItem`.
32
+ * Renders the button that opens an anchored menu. It is the popover's
33
+ * `commandfor` invoker, so the browser toggles the menu from it and does
34
+ * not count a press on it as a press outside.
35
+ * @param {MenuProps} props
36
+ * @returns {TemplateResult}
37
+ */
38
+ renderTrigger(props) {
39
+ const { id, isOpen = false, trigger, triggerProps, onToggle } = props
40
+
41
+ return Button.render({
42
+ variant: "ghost",
43
+ ...triggerProps,
44
+ children: trigger,
45
+ commandfor: id,
46
+ command: "toggle-popover",
47
+ "aria-haspopup": "menu",
48
+ // Left to the browser when it alone keeps the open state.
49
+ "aria-expanded": onToggle ? String(isOpen) : undefined,
50
+ style: `anchor-name: ${anchorName(id)}`,
51
+ })
52
+ },
53
+
54
+ /**
55
+ * Renders the menu container and iterates items.
56
+ * Each item is delegated to `renderItem`. An inline menu is only rendered
57
+ * while open; an anchored one stays in the page as a hidden popover.
27
58
  * @param {MenuProps} props
28
59
  * @returns {TemplateResult | null}
29
60
  */
30
61
  renderMenu(props) {
31
62
  const {
32
63
  type, // eslint-disable-line no-unused-vars
64
+ id,
33
65
  isOpen = false,
34
66
  items = [],
35
67
  isDense = false,
36
68
  className = "",
69
+ trigger,
70
+ triggerProps, // eslint-disable-line no-unused-vars
71
+ onToggle,
72
+ onItemClick, // eslint-disable-line no-unused-vars
37
73
  ...rest
38
74
  } = props
39
75
 
40
- if (!isOpen) return null
76
+ if (!trigger && !isOpen) return null
41
77
 
42
78
  const extraClasses = Object.fromEntries(
43
79
  className
@@ -47,13 +83,27 @@ export const Menu = {
47
83
  )
48
84
 
49
85
  return html`<div
86
+ id=${id || nothing}
50
87
  class=${classMap({
51
88
  "iw-menu": true,
52
89
  "iw-menu-dense": isDense,
53
90
  ...extraClasses,
54
91
  })}
55
92
  role="menu"
56
- ${ref((el) => applyElementProps(el, rest))}
93
+ popover=${trigger ? "auto" : nothing}
94
+ style=${trigger ? `position-anchor: ${anchorName(id)}` : nothing}
95
+ ${ref((element) => {
96
+ if (!element) return
97
+ applyElementProps(element, rest)
98
+ // Without onToggle the browser alone opens and closes it.
99
+ if (trigger && onToggle) {
100
+ syncPopover(element, {
101
+ isOpen,
102
+ onOpen: () => onToggle(true),
103
+ onClose: () => onToggle(false),
104
+ })
105
+ }
106
+ })}
57
107
  >
58
108
  ${items.map((item, index) => this.renderItem(item, index, props))}
59
109
  </div>`
@@ -72,6 +122,7 @@ export const Menu = {
72
122
  return html`<div class="iw-menu-divider" role="separator"></div>`
73
123
  }
74
124
 
125
+ // In an anchored menu each item also hides the popover once picked.
75
126
  return html`<button
76
127
  type="button"
77
128
  class=${classMap({
@@ -79,6 +130,8 @@ export const Menu = {
79
130
  "iw-menu-item-selected": item.isSelected,
80
131
  })}
81
132
  role="menuitem"
133
+ commandfor=${props.trigger ? props.id : nothing}
134
+ command=${props.trigger ? "hide-popover" : nothing}
82
135
  ?disabled=${item.isDisabled}
83
136
  @click=${() => {
84
137
  item.onClick?.(item.value ?? index)
@@ -1,22 +1,49 @@
1
+ /* The actions are a popover anchored to the trigger and placed on the side
2
+ `direction` names. The browser's popover box is centred in the viewport,
3
+ so its insets and box are reset; its hidden state has to be restated,
4
+ because the display below outranks it. */
1
5
  .iw-speed-dial {
6
+ anchor-scope: --iw-speed-dial;
2
7
  display: inline-flex;
3
- flex-direction: column;
4
- align-items: center;
5
- gap: var(--iw-space-2);
8
+ }
9
+
10
+ .iw-speed-dial-trigger {
11
+ anchor-name: --iw-speed-dial;
6
12
  }
7
13
 
8
14
  .iw-speed-dial-actions {
15
+ position-anchor: --iw-speed-dial;
16
+ position-area: top;
17
+ inset: auto;
18
+ margin: 0 0 var(--iw-space-2);
19
+ padding: 0;
20
+ border: 0;
21
+ overflow: visible;
22
+ background: transparent;
9
23
  display: inline-flex;
10
24
  flex-direction: column;
25
+ align-items: center;
11
26
  gap: var(--iw-space-2);
27
+
28
+ &:not(:popover-open) {
29
+ display: none;
30
+ }
12
31
  }
13
32
 
14
33
  .iw-speed-dial-down .iw-speed-dial-actions {
15
- order: 1;
34
+ position-area: bottom;
35
+ margin: var(--iw-space-2) 0 0;
36
+ }
37
+
38
+ .iw-speed-dial-left .iw-speed-dial-actions {
39
+ position-area: left;
40
+ margin: 0 var(--iw-space-2) 0 0;
41
+ flex-direction: row;
16
42
  }
17
43
 
18
- .iw-speed-dial-left .iw-speed-dial-actions,
19
44
  .iw-speed-dial-right .iw-speed-dial-actions {
45
+ position-area: right;
46
+ margin: 0 0 0 var(--iw-space-2);
20
47
  flex-direction: row;
21
48
  }
22
49
 
@@ -4,8 +4,11 @@
4
4
  * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
5
5
  */
6
6
 
7
- import { html } from "@inglorious/web"
7
+ import { html, nothing } from "@inglorious/web"
8
8
  import { classMap } from "@inglorious/web/directives/class-map"
9
+ import { ref } from "@inglorious/web/directives/ref"
10
+
11
+ import { syncPopover } from "../../shared/top-layer.js"
9
12
 
10
13
  export const SpeedDial = {
11
14
  /**
@@ -19,8 +22,10 @@ export const SpeedDial = {
19
22
  },
20
23
 
21
24
  /**
22
- * Renders the speed dial container, trigger, and optional actions list.
25
+ * Renders the speed dial container, trigger, and actions list.
23
26
  * Actions are delegated to `renderAction`.
27
+ * The actions are a native popover anchored to the trigger, which toggles
28
+ * it as its invoker: Esc, a press outside or an action closes it.
24
29
  * @param {SpeedDialProps} props
25
30
  * @returns {TemplateResult}
26
31
  */
@@ -31,6 +36,7 @@ export const SpeedDial = {
31
36
  icon = "+",
32
37
  ariaLabel = "Speed dial",
33
38
  actions = [],
39
+ onToggle,
34
40
  } = props
35
41
 
36
42
  return html`<div
@@ -39,24 +45,36 @@ export const SpeedDial = {
39
45
  [`iw-speed-dial-${direction}`]: true,
40
46
  "iw-speed-dial-open": isOpen,
41
47
  })}
48
+ ${ref((element) => {
49
+ if (!element) return
50
+ const trigger = element.querySelector(".iw-speed-dial-trigger")
51
+ const popover = element.querySelector(".iw-speed-dial-actions")
52
+ // A property, not `commandfor`, so the link needs no id.
53
+ trigger.commandForElement = popover
54
+ // Without onToggle the browser alone opens and closes it.
55
+ if (onToggle) {
56
+ syncPopover(popover, {
57
+ isOpen,
58
+ onOpen: () => onToggle(true),
59
+ onClose: () => onToggle(false),
60
+ })
61
+ }
62
+ })}
42
63
  >
43
- ${
44
- isOpen
45
- ? html`<div class="iw-speed-dial-actions">
46
- ${actions.map((action, index) =>
47
- this.renderAction(action, index, props),
48
- )}
49
- </div>`
50
- : null
51
- }
52
64
  <button
53
65
  type="button"
54
66
  class="iw-speed-dial-trigger"
55
67
  aria-label=${ariaLabel}
56
- @click=${() => props.onToggle?.(!isOpen)}
68
+ aria-expanded=${onToggle ? String(isOpen) : nothing}
69
+ command="toggle-popover"
57
70
  >
58
71
  ${icon}
59
72
  </button>
73
+ <div class="iw-speed-dial-actions" popover="auto">
74
+ ${actions.map((action, index) =>
75
+ this.renderAction(action, index, props),
76
+ )}
77
+ </div>
60
78
  </div>`
61
79
  },
62
80
 
@@ -72,6 +90,17 @@ export const SpeedDial = {
72
90
  return html`<button
73
91
  type="button"
74
92
  class="iw-speed-dial-action"
93
+ command="hide-popover"
94
+ ${ref((element) => {
95
+ // Hides the popover once the action runs, like a menu item. Linked a
96
+ // microtask later: a new action is still outside the popover when its
97
+ // ref runs.
98
+ if (element) {
99
+ queueMicrotask(() => {
100
+ element.commandForElement = element.parentElement
101
+ })
102
+ }
103
+ })}
75
104
  aria-label=${action.label ?? `Speed dial action ${index + 1}`}
76
105
  ?disabled=${action.isDisabled}
77
106
  @click=${() => {
@@ -4,7 +4,7 @@
4
4
  * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
5
5
  */
6
6
 
7
- import { html } from "@inglorious/web"
7
+ import { html, nothing } from "@inglorious/web"
8
8
  import { classMap } from "@inglorious/web/directives/class-map"
9
9
 
10
10
  export const Tabs = {
@@ -40,6 +40,7 @@ export const Tabs = {
40
40
  "iw-tabs-full-width": isFullWidth,
41
41
  })}
42
42
  role="tablist"
43
+ @keydown=${moveBetweenTabs}
43
44
  >
44
45
  ${items.map((item, index) =>
45
46
  this.renderTab(item, index, { ...props, value }),
@@ -47,7 +48,7 @@ export const Tabs = {
47
48
  </div>
48
49
  ${this.renderPanel(
49
50
  items.find((item, index) => (item.value ?? index) === value) ?? null,
50
- props,
51
+ { ...props, value },
51
52
  )}
52
53
  </div>`
53
54
  },
@@ -63,15 +64,24 @@ export const Tabs = {
63
64
  renderTab(item, index, props) {
64
65
  const tabValue = item.value ?? index
65
66
  const selected = tabValue === props.value
67
+ // Only one tab is in the Tab order, and the arrow keys move between them:
68
+ // the selected tab, or the first one while none is.
69
+ const hasSelection = props.items.some(
70
+ (other, otherIndex) => (other.value ?? otherIndex) === props.value,
71
+ )
72
+ const isTabStop = selected || (!hasSelection && index === 0)
66
73
 
67
74
  return html`<button
68
75
  type="button"
69
76
  role="tab"
77
+ id=${props.id ? `${props.id}-tab-${tabValue}` : nothing}
70
78
  class=${classMap({
71
79
  "iw-tab": true,
72
80
  "iw-tab-selected": selected,
73
81
  })}
74
82
  aria-selected=${selected ? "true" : "false"}
83
+ aria-controls=${props.id && selected ? `${props.id}-panel` : nothing}
84
+ tabindex=${isTabStop ? 0 : -1}
75
85
  ?disabled=${item.isDisabled}
76
86
  @click=${() => {
77
87
  item.onClick?.(tabValue)
@@ -89,8 +99,46 @@ export const Tabs = {
89
99
  * @param {TabItem | null} item
90
100
  * @returns {TemplateResult | null}
91
101
  */
92
- renderPanel(item) {
102
+ renderPanel(item, props) {
93
103
  if (!item?.panel) return null
94
- return html`<div class="iw-tab-panel" role="tabpanel">${item.panel}</div>`
104
+ const { id, value } = props ?? {}
105
+ return html`<div
106
+ id=${id ? `${id}-panel` : nothing}
107
+ class="iw-tab-panel"
108
+ role="tabpanel"
109
+ aria-labelledby=${id ? `${id}-tab-${value}` : nothing}
110
+ >
111
+ ${item.panel}
112
+ </div>`
95
113
  },
96
114
  }
115
+
116
+ /**
117
+ * Arrow keys move to the previous or next tab and select it, wrapping at
118
+ * either end; Home and End jump to the first and last. Left and right swap in
119
+ * a right-to-left page, where the tabs run the other way.
120
+ * @param {KeyboardEvent} event
121
+ */
122
+ function moveBetweenTabs(event) {
123
+ const tabs = [
124
+ ...event.currentTarget.querySelectorAll('[role="tab"]:not(:disabled)'),
125
+ ]
126
+ const current = tabs.indexOf(event.target)
127
+ if (current === -1) return
128
+
129
+ const isRtl = getComputedStyle(event.currentTarget).direction === "rtl"
130
+ const forward = isRtl ? "ArrowLeft" : "ArrowRight"
131
+ const backward = isRtl ? "ArrowRight" : "ArrowLeft"
132
+
133
+ let next
134
+ if (event.key === forward) next = (current + 1) % tabs.length
135
+ else if (event.key === backward)
136
+ next = (current - 1 + tabs.length) % tabs.length
137
+ else if (event.key === "Home") next = 0
138
+ else if (event.key === "End") next = tabs.length - 1
139
+ else return
140
+
141
+ event.preventDefault()
142
+ tabs[next].focus()
143
+ tabs[next].click()
144
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The CSS anchor name that ties a popover to the element it opens from.
3
+ * Set as `anchor-name` on the trigger and `position-anchor` on the popover,
4
+ * so the browser positions the popover against it, flipping when it runs out
5
+ * of room. Built from the popover's id, which is unique on the page.
6
+ * @param {string} id
7
+ * @returns {string}
8
+ */
9
+ export function anchorName(id) {
10
+ return `--iw-anchor-${String(id).replace(/[^\w-]/g, "-")}`
11
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Native `<dialog>` and `popover` elements keep their own open state, while the
3
+ * components that use them are driven by an `isOpen` prop from the store. These
4
+ * helpers run from a `ref` on every render and keep the two in step: the prop
5
+ * opens and closes the element, and a change the browser makes on its own
6
+ * (Esc, a light dismiss, a `method="dialog"` form, a `commandfor` invoker) is
7
+ * reported through `onClose` or `onOpen` so the store can catch up.
8
+ *
9
+ * The browser fires `close` and `toggle` after the fact, so the handler tells
10
+ * the two apart by the latest props: a change the props asked for finds
11
+ * `isOpen` already matching, and only one they did not ask for is reported.
12
+ */
13
+
14
+ /**
15
+ * Latest props per element. The listener is added once, and reads the props
16
+ * from here so it never calls an `onClose` captured by an earlier render.
17
+ * @type {WeakMap<Element, { isOpen: boolean, isModal?: boolean, onOpen?: () => void, onClose?: () => void }>}
18
+ */
19
+ const latestProps = new WeakMap()
20
+
21
+ /**
22
+ * Opens or closes a `<dialog>` to match `isOpen`.
23
+ * @param {HTMLDialogElement | undefined} element
24
+ * @param {{ isOpen: boolean, isModal?: boolean, onClose?: () => void }} props
25
+ */
26
+ export function syncDialog(element, props) {
27
+ if (!element) return
28
+ track(element, props, "close", () => false)
29
+ whenConnected(element, () => {
30
+ const { isOpen, isModal = true } = latestProps.get(element)
31
+ if (isOpen && !element.open) {
32
+ if (isModal) element.showModal()
33
+ else element.show()
34
+ }
35
+ if (!isOpen && element.open) element.close()
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Shows or hides a `popover` element to match `isOpen`. Showing an open
41
+ * popover and hiding a closed one are no-ops, so no state check is needed.
42
+ * @param {HTMLElement | undefined} element
43
+ * @param {{ isOpen: boolean, onOpen?: () => void, onClose?: () => void }} props
44
+ */
45
+ export function syncPopover(element, props) {
46
+ if (!element) return
47
+ track(element, props, "toggle", (event) => event.newState === "open")
48
+ whenConnected(element, () => {
49
+ if (!element.popover) return
50
+ if (latestProps.get(element).isOpen) element.showPopover()
51
+ else element.hidePopover()
52
+ })
53
+ }
54
+
55
+ /**
56
+ * @param {Element} element
57
+ * @param {object} props
58
+ * @param {string} eventName
59
+ * @param {(event: Event) => boolean} isOpenAfter whether the event left it open
60
+ */
61
+ function track(element, props, eventName, isOpenAfter) {
62
+ const isTracked = latestProps.has(element)
63
+ latestProps.set(element, props)
64
+ if (isTracked) return
65
+
66
+ element.addEventListener(eventName, (event) => {
67
+ const latest = latestProps.get(element)
68
+ const isOpen = isOpenAfter(event)
69
+ if (isOpen === latest.isOpen) return
70
+ if (isOpen) latest.onOpen?.()
71
+ else latest.onClose?.()
72
+ })
73
+ }
74
+
75
+ /**
76
+ * A ref runs while a new template is still in its fragment, and neither
77
+ * `showModal()` nor `showPopover()` works on an element outside the document.
78
+ * The fragment is inserted synchronously after, so a microtask is late enough.
79
+ */
80
+ function whenConnected(element, callback) {
81
+ if (element.isConnected) callback()
82
+ else queueMicrotask(() => element.isConnected && callback())
83
+ }
@@ -11,20 +11,27 @@
11
11
  border-top: 1px solid var(--iw-color-border);
12
12
  }
13
13
 
14
+ /* A <summary>: flex drops the browser's disclosure marker, and the caret
15
+ stands in for it. */
14
16
  .iw-accordion-trigger {
15
- width: 100%;
16
- border: 0 none;
17
- background: transparent;
18
- color: inherit;
19
17
  padding: var(--iw-space-3) var(--iw-space-4);
20
18
  display: flex;
21
19
  align-items: center;
22
20
  gap: var(--iw-space-2);
23
- text-align: left;
21
+ text-align: start;
24
22
  cursor: pointer;
23
+
24
+ &::-webkit-details-marker {
25
+ display: none;
26
+ }
27
+ }
28
+
29
+ .iw-accordion-item-disabled .iw-accordion-trigger {
30
+ cursor: default;
25
31
  }
26
32
 
27
- .iw-accordion-trigger:hover:enabled {
33
+ .iw-accordion-item:not(.iw-accordion-item-disabled)
34
+ .iw-accordion-trigger:hover {
28
35
  background: color-mix(
29
36
  in srgb,
30
37
  var(--iw-color-surface-hover) 75%,
@@ -50,7 +57,7 @@
50
57
  color: var(--iw-color-text-muted, var(--iw-color-text));
51
58
  }
52
59
 
53
- .iw-accordion-item-expanded .iw-accordion-caret {
60
+ .iw-accordion-item[open] .iw-accordion-caret {
54
61
  transform: rotate(180deg);
55
62
  }
56
63
 
@@ -4,7 +4,7 @@
4
4
  * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
5
5
  */
6
6
 
7
- import { html } from "@inglorious/web"
7
+ import { html, nothing } from "@inglorious/web"
8
8
  import { classMap } from "@inglorious/web/directives/class-map"
9
9
  import { ref } from "@inglorious/web/directives/ref"
10
10
 
@@ -30,8 +30,10 @@ export const Accordion = {
30
30
  renderAccordion(props) {
31
31
  const {
32
32
  type, // eslint-disable-line no-unused-vars
33
+ name, // eslint-disable-line no-unused-vars
33
34
  items = [],
34
35
  className = "",
36
+ onItemToggle, // eslint-disable-line no-unused-vars
35
37
  ...rest
36
38
  } = props
37
39
 
@@ -54,8 +56,11 @@ export const Accordion = {
54
56
  },
55
57
 
56
58
  /**
57
- * Renders a single accordion item with trigger and panel content.
58
- * Handles expand/collapse behavior and item-level callbacks.
59
+ * Renders a single accordion item as a native `<details>`, whose `<summary>`
60
+ * is the trigger. The browser opens and closes it, keeps the panel's
61
+ * content findable (find-in-page opens the item that holds a match), and,
62
+ * when the accordion has a `name`, closes the other items as one opens.
63
+ * Each change is reported so the store can follow.
59
64
  * @param {AccordionItem} item
60
65
  * @param {number} index
61
66
  * @param {AccordionProps} props
@@ -71,33 +76,35 @@ export const Accordion = {
71
76
  icon,
72
77
  } = item
73
78
 
74
- return html`<section
79
+ return html`<details
75
80
  class=${classMap({
76
81
  "iw-accordion-item": true,
77
82
  "iw-accordion-item-expanded": isExpanded,
78
83
  "iw-accordion-item-disabled": isDisabled,
79
84
  })}
85
+ name=${props.name || nothing}
86
+ .open=${isExpanded}
87
+ @toggle=${(event) => {
88
+ const isOpen = event.newState === "open"
89
+ // Also fires when isExpanded opened it, which needs no report.
90
+ if (isOpen === isExpanded) return
91
+ onToggle?.(item, index, isOpen)
92
+ props.onItemToggle?.(item, index, isOpen)
93
+ }}
80
94
  >
81
- <button
95
+ <summary
82
96
  class="iw-accordion-trigger"
83
- type="button"
84
- aria-expanded=${isExpanded}
85
- ?disabled=${isDisabled}
86
- @click=${() => {
87
- if (isDisabled) return
88
- onToggle?.(item, index, !isExpanded)
89
- props.onItemToggle?.(item, index, !isExpanded)
97
+ aria-disabled=${isDisabled ? "true" : nothing}
98
+ @click=${(event) => {
99
+ // A summary cannot be disabled, so its toggle is refused instead.
100
+ if (isDisabled) event.preventDefault()
90
101
  }}
91
102
  >
92
103
  ${icon ? html`<span class="iw-accordion-icon">${icon}</span>` : null}
93
104
  <span class="iw-accordion-title">${title}</span>
94
- <span class="iw-accordion-caret">▾</span>
95
- </button>
96
- ${
97
- isExpanded
98
- ? html`<div class="iw-accordion-panel">${content}</div>`
99
- : null
100
- }
101
- </section>`
105
+ <span class="iw-accordion-caret" aria-hidden="true">▾</span>
106
+ </summary>
107
+ <div class="iw-accordion-panel">${content}</div>
108
+ </details>`
102
109
  },
103
110
  }
@@ -2,6 +2,8 @@ import type { TemplateResult } from "@inglorious/web"
2
2
 
3
3
  export interface RatingProps {
4
4
  id?: string
5
+ /** Groups the radios and names the value in a form. Falls back to `id`. */
6
+ name?: string
5
7
  value?: number
6
8
  max?: number
7
9
  isDisabled?: boolean
@@ -10,6 +12,8 @@ export interface RatingProps {
10
12
  emptySymbol?: string
11
13
  onChange?: (value: number) => void
12
14
  size?: "sm" | "md" | "lg"
15
+ /** Names the group for assistive tech. Defaults to "Rating". */
16
+ ariaLabel?: string
13
17
  }
14
18
 
15
19
  export type RatingType = {
@@ -4,6 +4,8 @@ export interface DialogProps {
4
4
  id?: string
5
5
  type?: string
6
6
  isOpen?: boolean
7
+ /** Modal by default; `false` shows the dialog in place, with the page still usable. */
8
+ isModal?: boolean
7
9
  title?: TemplateResult | string | number
8
10
  description?: TemplateResult | string | number
9
11
  children?: TemplateResult | string | number
@@ -15,7 +17,7 @@ export interface DialogProps {
15
17
  }
16
18
 
17
19
  export type DialogType = {
18
- render(props: DialogProps): TemplateResult | null
20
+ render(props: DialogProps): TemplateResult
19
21
  }
20
22
 
21
23
  export declare const Dialog: DialogType
@@ -15,6 +15,8 @@ export interface ProgressProps {
15
15
  | "info"
16
16
  size?: number
17
17
  thickness?: number
18
+ /** Names the indicator for assistive tech, e.g. "Uploading photo". */
19
+ ariaLabel?: string
18
20
  className?: string
19
21
  [key: string]: unknown
20
22
  }
@@ -5,19 +5,24 @@ export interface DrawerProps {
5
5
  type?: string
6
6
  isOpen?: boolean
7
7
  anchor?: "left" | "right" | "top" | "bottom"
8
- variant?: "temporary" | "persistent" | "permanent" | "responsive"
9
- breakpoint?: "sm" | "md" | "lg" | "xl"
8
+ /**
9
+ * When the drawer overlays the page as a popover: `responsive` (default)
10
+ * below 64rem and docked above, `temporary` at every width, `permanent` never.
11
+ */
12
+ variant?: "responsive" | "temporary" | "permanent"
13
+ isHidden?: boolean
10
14
  isCollapsed?: boolean
11
15
  title?: TemplateResult | string | number
12
16
  children?: TemplateResult | string | number
13
17
  hasBackdrop?: boolean
14
18
  className?: string
15
19
  onClose?: () => void
20
+ onCollapseToggle?: () => void
16
21
  [key: string]: unknown
17
22
  }
18
23
 
19
24
  export type DrawerType = {
20
- render(props: DrawerProps): TemplateResult | null
25
+ render(props: DrawerProps): TemplateResult
21
26
  }
22
27
 
23
28
  export declare const Drawer: DrawerType