@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.
- package/package.json +2 -2
- package/src/controls/button/button.stories.js +7 -1
- package/src/controls/button/style.css +11 -0
- package/src/controls/button/template.js +4 -4
- package/src/controls/checkbox/checkbox.stories.js +12 -0
- package/src/controls/checkbox/style.css +11 -0
- package/src/controls/checkbox/template.js +9 -1
- package/src/controls/combobox/style.css +14 -7
- package/src/controls/combobox/template.js +12 -2
- package/src/controls/radio-group/radio-group.stories.js +21 -1
- package/src/controls/radio-group/style.css +11 -0
- package/src/controls/radio-group/template.js +11 -1
- package/src/controls/rating/rating.stories.js +8 -0
- package/src/controls/rating/style.css +26 -4
- package/src/controls/rating/template.js +29 -12
- package/src/controls/slider/slider.stories.js +28 -1
- package/src/controls/slider/style.css +102 -0
- package/src/controls/slider/template.js +114 -15
- package/src/controls/switch/style.css +11 -0
- package/src/controls/switch/switch.stories.js +10 -0
- package/src/controls/switch/template.js +14 -2
- package/src/data-display/chip/chip.stories.js +17 -0
- package/src/data-display/chip/style.css +5 -0
- package/src/data-display/chip/template.js +3 -1
- package/src/data-display/divider/divider.stories.js +9 -0
- package/src/data-display/divider/template.js +12 -4
- package/src/data-display/tooltip/style.css +22 -14
- package/src/examples/dashboard/primitive-content.js +6 -6
- package/src/examples/dashboard/style.css +11 -5
- package/src/feedback/alert/alert.stories.js +9 -1
- package/src/feedback/alert/style.css +5 -0
- package/src/feedback/alert/template.js +2 -1
- package/src/feedback/dialog/dialog.stories.js +31 -0
- package/src/feedback/dialog/style.css +27 -13
- package/src/feedback/dialog/template.js +68 -18
- package/src/feedback/progress/progress.stories.js +5 -0
- package/src/feedback/progress/style.css +47 -23
- package/src/feedback/progress/template.js +18 -11
- package/src/feedback/snackbar/style.css +13 -0
- package/src/feedback/snackbar/template.js +7 -0
- package/src/layout/flex/flex.stories.js +1 -1
- package/src/layout/flex/style.css +8 -0
- package/src/navigation/drawer/drawer.stories.js +3 -7
- package/src/navigation/drawer/style.css +59 -18
- package/src/navigation/drawer/template.js +137 -80
- package/src/navigation/menu/menu.stories.js +15 -0
- package/src/navigation/menu/style.css +19 -0
- package/src/navigation/menu/template.js +59 -6
- package/src/navigation/speed-dial/style.css +32 -5
- package/src/navigation/speed-dial/template.js +41 -12
- package/src/navigation/tabs/template.js +52 -4
- package/src/shared/anchor.js +11 -0
- package/src/shared/top-layer.js +83 -0
- package/src/surfaces/accordion/accordion.stories.js +9 -0
- package/src/surfaces/accordion/style.css +14 -7
- package/src/surfaces/accordion/template.js +31 -20
- package/src/surfaces/card/card.stories.js +24 -0
- package/src/surfaces/card/style.css +15 -0
- package/src/surfaces/card/template.js +2 -0
- package/types/controls/button.d.ts +1 -1
- package/types/controls/checkbox.d.ts +2 -0
- package/types/controls/radio-group.d.ts +2 -0
- package/types/controls/rating.d.ts +4 -0
- package/types/controls/slider.d.ts +6 -2
- package/types/controls/switch.d.ts +2 -0
- package/types/data-display/chip.d.ts +2 -0
- package/types/data-display/divider.d.ts +2 -0
- package/types/feedback/alert.d.ts +1 -1
- package/types/feedback/dialog.d.ts +5 -1
- package/types/feedback/progress.d.ts +2 -0
- package/types/layout/flex.d.ts +1 -1
- package/types/navigation/drawer.d.ts +8 -3
- package/types/navigation/menu.d.ts +17 -0
- package/types/navigation/speed-dial.d.ts +5 -0
- package/types/surfaces/accordion.d.ts +2 -0
- 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
|
-
*
|
|
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
|
|
46
|
-
"iw-drawer",
|
|
47
|
-
`iw-drawer-${anchor}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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-
|
|
110
|
-
"aria-label":
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
+
),
|
|
116
|
+
],
|
|
117
|
+
})}
|
|
118
|
+
`,
|
|
119
|
+
)}
|
|
120
|
+
${Flex.render({
|
|
121
|
+
direction: "column",
|
|
122
|
+
className: "iw-drawer-body",
|
|
123
|
+
children,
|
|
121
124
|
})}
|
|
122
|
-
${when(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|