@inglorious/ui 8.6.0 → 9.1.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 (76) hide show
  1. package/package.json +2 -2
  2. package/src/controls/button/button.stories.js +7 -1
  3. package/src/controls/button/style.css +11 -0
  4. package/src/controls/button/template.js +4 -4
  5. package/src/controls/checkbox/checkbox.stories.js +12 -0
  6. package/src/controls/checkbox/style.css +11 -0
  7. package/src/controls/checkbox/template.js +9 -1
  8. package/src/controls/combobox/style.css +14 -7
  9. package/src/controls/combobox/template.js +12 -2
  10. package/src/controls/radio-group/radio-group.stories.js +21 -1
  11. package/src/controls/radio-group/style.css +11 -0
  12. package/src/controls/radio-group/template.js +11 -1
  13. package/src/controls/rating/rating.stories.js +8 -0
  14. package/src/controls/rating/style.css +26 -4
  15. package/src/controls/rating/template.js +29 -12
  16. package/src/controls/slider/slider.stories.js +28 -1
  17. package/src/controls/slider/style.css +102 -0
  18. package/src/controls/slider/template.js +114 -15
  19. package/src/controls/switch/style.css +11 -0
  20. package/src/controls/switch/switch.stories.js +10 -0
  21. package/src/controls/switch/template.js +14 -2
  22. package/src/data-display/chip/chip.stories.js +17 -0
  23. package/src/data-display/chip/style.css +5 -0
  24. package/src/data-display/chip/template.js +3 -1
  25. package/src/data-display/divider/divider.stories.js +9 -0
  26. package/src/data-display/divider/template.js +12 -4
  27. package/src/data-display/tooltip/style.css +22 -14
  28. package/src/examples/dashboard/primitive-content.js +6 -6
  29. package/src/examples/dashboard/style.css +11 -5
  30. package/src/feedback/alert/alert.stories.js +9 -1
  31. package/src/feedback/alert/style.css +5 -0
  32. package/src/feedback/alert/template.js +2 -1
  33. package/src/feedback/dialog/dialog.stories.js +31 -0
  34. package/src/feedback/dialog/style.css +27 -13
  35. package/src/feedback/dialog/template.js +68 -18
  36. package/src/feedback/progress/progress.stories.js +5 -0
  37. package/src/feedback/progress/style.css +47 -23
  38. package/src/feedback/progress/template.js +18 -11
  39. package/src/feedback/snackbar/style.css +13 -0
  40. package/src/feedback/snackbar/template.js +7 -0
  41. package/src/layout/flex/flex.stories.js +1 -1
  42. package/src/layout/flex/style.css +8 -0
  43. package/src/navigation/drawer/drawer.stories.js +3 -7
  44. package/src/navigation/drawer/style.css +59 -18
  45. package/src/navigation/drawer/template.js +137 -80
  46. package/src/navigation/menu/menu.stories.js +15 -0
  47. package/src/navigation/menu/style.css +19 -0
  48. package/src/navigation/menu/template.js +59 -6
  49. package/src/navigation/speed-dial/style.css +32 -5
  50. package/src/navigation/speed-dial/template.js +41 -12
  51. package/src/navigation/tabs/template.js +52 -4
  52. package/src/shared/anchor.js +11 -0
  53. package/src/shared/top-layer.js +83 -0
  54. package/src/surfaces/accordion/accordion.stories.js +9 -0
  55. package/src/surfaces/accordion/style.css +14 -7
  56. package/src/surfaces/accordion/template.js +31 -20
  57. package/src/surfaces/card/card.stories.js +24 -0
  58. package/src/surfaces/card/style.css +15 -0
  59. package/src/surfaces/card/template.js +2 -0
  60. package/types/controls/button.d.ts +1 -1
  61. package/types/controls/checkbox.d.ts +2 -0
  62. package/types/controls/radio-group.d.ts +2 -0
  63. package/types/controls/rating.d.ts +4 -0
  64. package/types/controls/slider.d.ts +6 -2
  65. package/types/controls/switch.d.ts +2 -0
  66. package/types/data-display/chip.d.ts +2 -0
  67. package/types/data-display/divider.d.ts +2 -0
  68. package/types/feedback/alert.d.ts +1 -1
  69. package/types/feedback/dialog.d.ts +5 -1
  70. package/types/feedback/progress.d.ts +2 -0
  71. package/types/layout/flex.d.ts +1 -1
  72. package/types/navigation/drawer.d.ts +8 -3
  73. package/types/navigation/menu.d.ts +17 -0
  74. package/types/navigation/speed-dial.d.ts +5 -0
  75. package/types/surfaces/accordion.d.ts +2 -0
  76. package/types/surfaces/card.d.ts +5 -0
@@ -3,18 +3,38 @@
3
3
  * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
4
4
  */
5
5
 
6
- import { html } from "@inglorious/web"
6
+ import { html, nothing } from "@inglorious/web"
7
+ import { classMap } from "@inglorious/web/directives/class-map"
8
+ import { ref } from "@inglorious/web/directives/ref"
7
9
  import { when } from "@inglorious/web/directives/when"
8
10
 
9
11
  import { Button } from "../../controls/button/index.js"
10
12
  import { Icon } from "../../data-display/icon/index.js"
11
- import { Backdrop } from "../../feedback/backdrop/index.js"
12
13
  import { Flex } from "../../layout/flex/index.js"
14
+ import { applyElementProps } from "../../shared/apply-element-props.js"
13
15
  import { close, leftPanelClose } from "../../shared/icons.js"
16
+ import { syncPopover } from "../../shared/top-layer.js"
17
+
18
+ /** Where a responsive drawer docks. Keep in step with style.css. */
19
+ const DOCKED_QUERY = "(min-width: 64rem)"
20
+
21
+ /**
22
+ * Latest props of each responsive drawer, so they can be re-synced when the
23
+ * viewport crosses the breakpoint without anything re-rendering.
24
+ * @type {Map<HTMLElement, DrawerProps>}
25
+ */
26
+ const responsiveDrawers = new Map()
27
+
28
+ /** @type {MediaQueryList | undefined} */
29
+ let dockedMedia
14
30
 
15
31
  /**
16
32
  * Renders a side drawer panel with optional header, body, and collapse controls.
17
- * Can show a backdrop when open and supports left/right anchoring.
33
+ * While it overlays the page it is a native popover: it sits in the top layer
34
+ * over a `::backdrop`, and Esc or a press outside it (the backdrop included)
35
+ * closes it.
36
+ * `variant` sets when it overlays: `responsive` below 64rem and docked above,
37
+ * `temporary` at every width, `permanent` never.
18
38
  * @param {DrawerProps} props
19
39
  * @returns {TemplateResult}
20
40
  */
@@ -25,6 +45,7 @@ export function render(props) {
25
45
  isOpen = false,
26
46
  isHidden = false,
27
47
  anchor = "left",
48
+ variant = "responsive",
28
49
  isCollapsed = false,
29
50
  hasBackdrop = true,
30
51
  title,
@@ -42,89 +63,125 @@ export function render(props) {
42
63
  .map((name) => [name, true]),
43
64
  )
44
65
 
45
- const drawerClassName = [
46
- "iw-drawer",
47
- `iw-drawer-${anchor}`,
48
- isOpen && "iw-drawer-open",
49
- isHidden && "iw-drawer-hidden",
50
- isCollapsed && "iw-drawer-collapsed",
51
- ...Object.keys(extraClasses),
52
- ]
53
- .filter(Boolean)
54
- .join(" ")
55
-
56
- return html`
57
- ${Flex.render({
58
- element: "aside",
59
- direction: "column",
60
- className: drawerClassName,
61
- children: [
62
- when(
63
- title || onClose,
64
- () => html`
65
- ${Flex.render({
66
- align: "center",
67
- justify: "between",
68
- gap: "sm",
69
- padding: "sm",
70
- className: "iw-drawer-header",
71
- children: [
72
- when(
73
- title,
74
- () => html`<div class="iw-drawer-title">${title}</div>`,
75
- ),
76
- when(onClose, () =>
77
- Button.render({
78
- color: "secondary",
79
- variant: "ghost",
80
- shape: "square",
81
- className: "iw-drawer-close",
82
- "aria-label": "Close",
83
- onClick: onClose,
84
- children: Icon.render({ icon: close, size: "lg" }),
85
- }),
86
- ),
87
- ],
88
- })}
89
- `,
90
- ),
66
+ const classes = {
67
+ "iw-drawer": true,
68
+ [`iw-drawer-${anchor}`]: true,
69
+ [`iw-drawer-${variant}`]: true,
70
+ "iw-drawer-open": isOpen,
71
+ "iw-drawer-hidden": isHidden,
72
+ "iw-drawer-collapsed": isCollapsed,
73
+ "iw-drawer-no-backdrop": !hasBackdrop,
74
+ ...extraClasses,
75
+ }
91
76
 
92
- Flex.render({
93
- direction: "column",
94
- className: "iw-drawer-body",
95
- children,
96
- }),
77
+ // Without onClose nothing would take isOpen down, so the browser is not
78
+ // allowed to dismiss it: a manual popover ignores Esc and outside presses.
79
+ const popover =
80
+ variant === "permanent" ? nothing : onClose ? "auto" : "manual"
97
81
 
98
- when(
99
- onCollapseToggle,
100
- () => html`
101
- ${Flex.render({
102
- justify: "end",
103
- padding: "sm",
104
- className: "iw-drawer-footer",
105
- children: Button.render({
82
+ return html`<aside
83
+ class=${classMap(classes)}
84
+ popover=${popover}
85
+ ${ref((element) => {
86
+ if (!element) return
87
+ applyElementProps(element, rest)
88
+ sync(element, { isOpen, variant, onClose })
89
+ })}
90
+ >
91
+ ${when(
92
+ title || onClose,
93
+ () => html`
94
+ ${Flex.render({
95
+ align: "center",
96
+ justify: "between",
97
+ gap: "sm",
98
+ padding: "sm",
99
+ className: "iw-drawer-header",
100
+ children: [
101
+ when(
102
+ title,
103
+ () => html`<div class="iw-drawer-title">${title}</div>`,
104
+ ),
105
+ when(onClose, () =>
106
+ Button.render({
106
107
  color: "secondary",
107
108
  variant: "ghost",
108
109
  shape: "square",
109
- className: "iw-drawer-toggler",
110
- "aria-label": isCollapsed
111
- ? "Expand sidebar"
112
- : "Collapse sidebar",
113
- onClick: onCollapseToggle,
114
- children: Icon.render({ icon: leftPanelClose, size: "lg" }),
110
+ className: "iw-drawer-close",
111
+ "aria-label": "Close",
112
+ onClick: onClose,
113
+ children: Icon.render({ icon: close, size: "lg" }),
115
114
  }),
116
- })}
117
- `,
118
- ),
119
- ],
120
- ...rest,
115
+ ),
116
+ ],
117
+ })}
118
+ `,
119
+ )}
120
+ ${Flex.render({
121
+ direction: "column",
122
+ className: "iw-drawer-body",
123
+ children,
121
124
  })}
122
- ${when(isOpen && hasBackdrop, () =>
123
- Backdrop.render({
124
- isOpen: true,
125
- className: "iw-drawer-backdrop",
126
- onClick: onClose,
127
- }),
125
+ ${when(
126
+ onCollapseToggle,
127
+ () => html`
128
+ ${Flex.render({
129
+ justify: "end",
130
+ padding: "sm",
131
+ className: "iw-drawer-footer",
132
+ children: Button.render({
133
+ color: "secondary",
134
+ variant: "ghost",
135
+ shape: "square",
136
+ className: "iw-drawer-toggler",
137
+ "aria-label": isCollapsed ? "Expand sidebar" : "Collapse sidebar",
138
+ onClick: onCollapseToggle,
139
+ children: Icon.render({ icon: leftPanelClose, size: "lg" }),
140
+ }),
141
+ })}
142
+ `,
128
143
  )}
129
- `
144
+ </aside>`
145
+ }
146
+
147
+ /**
148
+ * Shows the popover only while the drawer overlays the page, and remembers a
149
+ * responsive drawer so a breakpoint crossing can re-sync it.
150
+ * @param {HTMLElement} element
151
+ * @param {Pick<DrawerProps, "isOpen" | "variant" | "onClose">} props
152
+ */
153
+ function sync(element, props) {
154
+ if (props.variant === "responsive") {
155
+ watchDockedMedia()
156
+ responsiveDrawers.set(element, props)
157
+ } else {
158
+ responsiveDrawers.delete(element)
159
+ }
160
+
161
+ syncPopover(element, {
162
+ isOpen: props.isOpen && isOverlay(props.variant),
163
+ onClose: props.onClose,
164
+ })
165
+ }
166
+
167
+ function isOverlay(variant) {
168
+ if (variant === "permanent") return false
169
+ if (variant === "temporary") return true
170
+ return !dockedMedia?.matches
171
+ }
172
+
173
+ /**
174
+ * Listens once for the breakpoint. Widening the window docks an open
175
+ * responsive drawer, which then has to leave the top layer: a silent hide,
176
+ * since nothing asked the store to close it.
177
+ */
178
+ function watchDockedMedia() {
179
+ if (dockedMedia || !globalThis.matchMedia) return
180
+ dockedMedia = globalThis.matchMedia(DOCKED_QUERY)
181
+ dockedMedia.addEventListener("change", () => {
182
+ for (const [element, props] of responsiveDrawers) {
183
+ if (element.isConnected) sync(element, props)
184
+ else responsiveDrawers.delete(element)
185
+ }
186
+ })
130
187
  }
@@ -12,6 +12,11 @@ export default {
12
12
  },
13
13
  items: { control: "object", description: "Menu items and separators." },
14
14
  isDense: { control: "boolean", description: "Compact item spacing." },
15
+ trigger: {
16
+ control: "text",
17
+ description: "Content of a button that opens the menu as a popover.",
18
+ },
19
+ onToggle: { action: "onToggle" },
15
20
  onItemClick: { action: "onItemClick" },
16
21
  },
17
22
  parameters: {
@@ -34,3 +39,13 @@ export const Default = {
34
39
  ],
35
40
  },
36
41
  }
42
+
43
+ // Anchored to its trigger. Without onToggle wired to a store, the browser
44
+ // keeps the open state on its own.
45
+ export const WithTrigger = {
46
+ args: {
47
+ id: "story-menu",
48
+ trigger: "Actions",
49
+ items: Default.args.items,
50
+ },
51
+ }
@@ -3,12 +3,31 @@
3
3
  flex-direction: column;
4
4
  min-width: 12rem;
5
5
  padding: var(--iw-space-2);
6
+ color: var(--iw-color-text);
6
7
  background: var(--iw-color-surface);
7
8
  border: 1px solid var(--iw-color-border);
8
9
  border-radius: var(--iw-radius-md);
9
10
  box-shadow: var(--iw-shadow-lg);
10
11
  }
11
12
 
13
+ /* Anchored to its trigger: below it and aligned with its start, flipping up
14
+ or across when there is no room. The browser's popover box is centred in
15
+ the viewport, so its insets and margins are reset; its hidden state has to
16
+ be restated, because the display above outranks it. */
17
+ .iw-menu[popover] {
18
+ inset: auto;
19
+ margin: var(--iw-space-1) 0 0;
20
+ position-area: block-end span-inline-end;
21
+ position-try-fallbacks:
22
+ flip-block,
23
+ flip-inline,
24
+ flip-block flip-inline;
25
+
26
+ &:not(:popover-open) {
27
+ display: none;
28
+ }
29
+ }
30
+
12
31
  .iw-menu-item {
13
32
  border: 0 none;
14
33
  background: transparent;
@@ -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
+ }