@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.
- package/package.json +1 -1
- package/src/controls/button/template.js +4 -4
- package/src/controls/combobox/style.css +14 -7
- package/src/controls/combobox/template.js +12 -2
- package/src/controls/rating/style.css +26 -4
- package/src/controls/rating/template.js +29 -12
- package/src/controls/switch/template.js +3 -1
- 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/dialog/dialog.stories.js +9 -0
- package/src/feedback/dialog/style.css +13 -12
- package/src/feedback/dialog/template.js +63 -18
- 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/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/style.css +14 -7
- package/src/surfaces/accordion/template.js +27 -20
- package/types/controls/rating.d.ts +4 -0
- package/types/feedback/dialog.d.ts +3 -1
- package/types/feedback/progress.d.ts +2 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -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:
|
|
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-
|
|
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
|
|
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
|
|
58
|
-
*
|
|
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`<
|
|
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
|
-
<
|
|
95
|
+
<summary
|
|
82
96
|
class="iw-accordion-trigger"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
</
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
20
|
+
render(props: DialogProps): TemplateResult
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export declare const Dialog: DialogType
|
|
@@ -5,19 +5,24 @@ export interface DrawerProps {
|
|
|
5
5
|
type?: string
|
|
6
6
|
isOpen?: boolean
|
|
7
7
|
anchor?: "left" | "right" | "top" | "bottom"
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
25
|
+
render(props: DrawerProps): TemplateResult
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export declare const Drawer: DrawerType
|