@oscarpalmer/oui 0.13.0 → 0.15.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/README.md +2 -0
- package/dist/css/popover.css +9 -0
- package/dist/css/tooltip.css +9 -3
- package/dist/css/value.css +1 -0
- package/dist/js/floatable.cjs +134 -0
- package/dist/js/floatable.js +134 -0
- package/dist/js/focus-trap/embedded.cjs +150 -0
- package/dist/js/focus-trap/embedded.js +150 -0
- package/dist/js/focus-trap/standalone.cjs +8 -0
- package/dist/js/focus-trap/standalone.js +7 -0
- package/dist/js/index.cjs +5 -1
- package/dist/js/index.js +6 -1
- package/dist/js/popover.cjs +160 -0
- package/dist/js/popover.js +160 -0
- package/dist/js/tooltip.cjs +83 -126
- package/dist/js/tooltip.js +83 -126
- package/package.json +7 -7
- package/src/css/popover.scss +9 -0
- package/src/css/tooltip.scss +7 -3
- package/src/js/floatable.ts +196 -0
- package/src/js/focus-trap/embedded.ts +215 -0
- package/src/js/focus-trap/standalone.ts +9 -0
- package/src/js/index.ts +3 -1
- package/src/js/popover.ts +221 -0
- package/src/js/tooltip.ts +97 -161
- package/types/floatable.d.cts +16 -0
- package/types/floatable.d.ts +12 -0
- package/types/focus-trap/embedded.d.cts +15 -0
- package/types/focus-trap/embedded.d.ts +11 -0
- package/types/index.d.cts +11 -0
- package/types/index.d.ts +3 -1
- package/types/popover.d.cts +14 -0
- package/types/popover.d.ts +15 -0
- package/dist/js/focus-trap.cjs +0 -104
- package/dist/js/focus-trap.js +0 -103
- package/src/js/focus-trap.ts +0 -133
- /package/types/{focus-trap.d.cts → focus-trap/standalone.d.cts} +0 -0
- /package/types/{focus-trap.d.ts → focus-trap/standalone.d.ts} +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {clamp} from '@oscarpalmer/atoms/number';
|
|
2
|
+
import {on} from '@oscarpalmer/toretto/event';
|
|
3
|
+
import {findAncestor} from '@oscarpalmer/toretto/find';
|
|
4
|
+
import {getFocusable} from '@oscarpalmer/toretto/focusable';
|
|
5
|
+
|
|
6
|
+
export class FocusTrap {
|
|
7
|
+
active = true;
|
|
8
|
+
|
|
9
|
+
constructor(public element: HTMLElement) {
|
|
10
|
+
element.setAttribute('aria-modal', 'true');
|
|
11
|
+
element.setAttribute('tabindex', '-1');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
destroy() {
|
|
15
|
+
this.element = undefined as never;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
disable() {
|
|
19
|
+
if (this.active && !this.element.hasAttribute(`${selector}-noescape`)) {
|
|
20
|
+
setDisabled(this, true);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
enable() {
|
|
25
|
+
if (!this.active) {
|
|
26
|
+
setDisabled(this, false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//
|
|
32
|
+
|
|
33
|
+
export function createFocusTrap(element: HTMLElement): FocusTrap {
|
|
34
|
+
let focusTrap = focusTraps.get(element);
|
|
35
|
+
|
|
36
|
+
if (focusTrap == null) {
|
|
37
|
+
focusTrap = new FocusTrap(element);
|
|
38
|
+
|
|
39
|
+
focusTraps.set(element, focusTrap);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return focusTrap;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onEscape(event: KeyboardEvent): void {
|
|
46
|
+
focusTraps
|
|
47
|
+
.get(
|
|
48
|
+
findAncestor(event.target as HTMLElement, `[${selector}]`) as HTMLElement,
|
|
49
|
+
)
|
|
50
|
+
?.disable();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onFocusIn(event: Event): void {
|
|
54
|
+
lastTarget =
|
|
55
|
+
findAncestor(event.target as HTMLElement, `[${selector}]`) == null
|
|
56
|
+
? undefined
|
|
57
|
+
: (event.target as HTMLElement);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
61
|
+
switch (event.key) {
|
|
62
|
+
case 'Escape':
|
|
63
|
+
onEscape(event);
|
|
64
|
+
break;
|
|
65
|
+
|
|
66
|
+
case 'Tab':
|
|
67
|
+
onTab(event);
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
default:
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function onPointerdown(event: MouseEvent | TouchEvent): void {
|
|
76
|
+
const lastFocusTrap = findAncestor(
|
|
77
|
+
lastTarget as HTMLElement,
|
|
78
|
+
`[${selector}]`,
|
|
79
|
+
) as HTMLElement;
|
|
80
|
+
|
|
81
|
+
const nextFocusTrap = findAncestor(
|
|
82
|
+
event.target as HTMLElement,
|
|
83
|
+
`[${selector}]`,
|
|
84
|
+
) as HTMLElement;
|
|
85
|
+
|
|
86
|
+
if (lastFocusTrap != null && nextFocusTrap !== lastFocusTrap) {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
|
|
89
|
+
lastTarget?.focus();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (disabled.size === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const focusTrap of disabled) {
|
|
97
|
+
if (focusTrap.element !== nextFocusTrap) {
|
|
98
|
+
focusTrap.enable();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onTab(event: KeyboardEvent): void {
|
|
105
|
+
const element = findAncestor(
|
|
106
|
+
event.target as HTMLElement,
|
|
107
|
+
`[${selector}]`,
|
|
108
|
+
) as HTMLElement;
|
|
109
|
+
|
|
110
|
+
const focusTrap = focusTraps.get(element);
|
|
111
|
+
|
|
112
|
+
if (focusTrap == null) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (focusTrap.active) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const elements = getFocusable(element);
|
|
121
|
+
|
|
122
|
+
const index = elements.indexOf(event.target as HTMLElement);
|
|
123
|
+
|
|
124
|
+
if (index === -1 || elements.length === 0) {
|
|
125
|
+
tabToTrap(focusTrap, elements, element);
|
|
126
|
+
} else {
|
|
127
|
+
tabToElement(event, focusTrap, elements, index);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function setDisabled(focusTrap: FocusTrap, value: boolean): void {
|
|
132
|
+
focusTrap.element.setAttribute('aria-modal', String(!value));
|
|
133
|
+
|
|
134
|
+
focusTrap.active = !value;
|
|
135
|
+
|
|
136
|
+
if (value) {
|
|
137
|
+
disabled.add(focusTrap);
|
|
138
|
+
} else {
|
|
139
|
+
disabled.delete(focusTrap);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function tabToElement(
|
|
144
|
+
event: KeyboardEvent,
|
|
145
|
+
focusTrap: FocusTrap,
|
|
146
|
+
elements: Element[],
|
|
147
|
+
index: number,
|
|
148
|
+
): void {
|
|
149
|
+
const next = clamp(
|
|
150
|
+
index + (event.shiftKey ? -1 : 1),
|
|
151
|
+
0,
|
|
152
|
+
elements.length - 1,
|
|
153
|
+
true,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (!focusTrap.active) {
|
|
157
|
+
if (
|
|
158
|
+
(index === length - 1 && next === 0) ||
|
|
159
|
+
(index === 0 && next === length - 1)
|
|
160
|
+
) {
|
|
161
|
+
lastTarget = undefined;
|
|
162
|
+
|
|
163
|
+
focusTrap.enable();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
(elements[next] as HTMLElement).focus();
|
|
170
|
+
|
|
171
|
+
lastTarget = elements[next] as HTMLElement;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function tabToTrap(
|
|
175
|
+
focusTrap: FocusTrap,
|
|
176
|
+
elements: Element[],
|
|
177
|
+
element: HTMLElement,
|
|
178
|
+
): void {
|
|
179
|
+
if (focusTrap.active) {
|
|
180
|
+
const next = length === 0 ? element : elements[0];
|
|
181
|
+
|
|
182
|
+
(next as HTMLElement).focus();
|
|
183
|
+
|
|
184
|
+
lastTarget = next as HTMLElement;
|
|
185
|
+
} else {
|
|
186
|
+
lastTarget = undefined;
|
|
187
|
+
|
|
188
|
+
focusTrap.enable();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
//
|
|
193
|
+
|
|
194
|
+
const disabled = new Set<FocusTrap>();
|
|
195
|
+
|
|
196
|
+
const eventOptions = {
|
|
197
|
+
capture: true,
|
|
198
|
+
passive: false,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const focusTraps = new WeakMap<HTMLElement, FocusTrap>();
|
|
202
|
+
|
|
203
|
+
let lastTarget: HTMLElement | undefined;
|
|
204
|
+
|
|
205
|
+
export const selector = 'oui-focus-trap';
|
|
206
|
+
|
|
207
|
+
//
|
|
208
|
+
|
|
209
|
+
on(document, 'focusin', onFocusIn, eventOptions);
|
|
210
|
+
|
|
211
|
+
on(document, 'keydown', onKeydown, eventOptions);
|
|
212
|
+
|
|
213
|
+
on(document, 'mousedown', onPointerdown, eventOptions);
|
|
214
|
+
|
|
215
|
+
on(document, 'touchstart', onPointerdown, eventOptions);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {attributable} from '../attributable';
|
|
2
|
+
import {createFocusTrap, focusTraps, selector} from './embedded';
|
|
3
|
+
|
|
4
|
+
function removeFocusTraps(element: HTMLElement): void {
|
|
5
|
+
focusTraps.get(element)?.destroy();
|
|
6
|
+
focusTraps.delete(element);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
attributable(selector, createFocusTrap, removeFocusTraps);
|
package/src/js/index.ts
CHANGED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {isNullableOrWhitespace} from '@oscarpalmer/atoms/is';
|
|
2
|
+
import {on} from '@oscarpalmer/toretto/event';
|
|
3
|
+
import {findAncestor} from '@oscarpalmer/toretto/find';
|
|
4
|
+
import {getFocusable} from '@oscarpalmer/toretto/focusable';
|
|
5
|
+
import {activate, deactivate, Floatable} from './floatable';
|
|
6
|
+
import {createFocusTrap, type FocusTrap} from './focus-trap/embedded';
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface HTMLElementTagNameMap {
|
|
10
|
+
'oui-popover': OuiPopoverElement;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const mapped = new Map<HTMLElement, Floatable>();
|
|
15
|
+
|
|
16
|
+
let index = 0;
|
|
17
|
+
|
|
18
|
+
export class OuiPopoverElement extends HTMLElement {
|
|
19
|
+
#floatable: Floatable;
|
|
20
|
+
#focusTrap: FocusTrap;
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
super();
|
|
24
|
+
|
|
25
|
+
const content = this.querySelector(':scope > [oui-popover-content]');
|
|
26
|
+
const toggle = this.querySelector(':scope > [oui-popover-toggle]');
|
|
27
|
+
|
|
28
|
+
if (!(content instanceof HTMLElement)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'A oui-popover must have a direct child element with the [oui-popover-content]-attribute',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!(toggle instanceof HTMLButtonElement)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'A oui-popover must have a direct child element with the [oui-popover-toggle]-attribute and be a button',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
content.hidden = true;
|
|
41
|
+
|
|
42
|
+
content.setAttribute('role', 'dialog');
|
|
43
|
+
content.setAttribute('oui-focus-trap', '');
|
|
44
|
+
content.setAttribute('oui-focus-trap-noescape', '');
|
|
45
|
+
|
|
46
|
+
if (isNullableOrWhitespace(content.id)) {
|
|
47
|
+
content.setAttribute('id', `oui-popover-content-${++index}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
toggle.setAttribute('aria-controls', content.id);
|
|
51
|
+
toggle.setAttribute('aria-haspopup', 'dialog');
|
|
52
|
+
|
|
53
|
+
this.#floatable = new Floatable(toggle, content);
|
|
54
|
+
this.#focusTrap = createFocusTrap(content);
|
|
55
|
+
|
|
56
|
+
mapped.set(toggle, this.#floatable);
|
|
57
|
+
mapped.set(content, this.#floatable);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
close(): void {
|
|
61
|
+
this.hidePopover();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
hidePopover(): void {
|
|
65
|
+
if (this.#floatable.active) {
|
|
66
|
+
closeAbove(this.#floatable.content);
|
|
67
|
+
deactivate(this.#floatable, active, order);
|
|
68
|
+
|
|
69
|
+
this.#floatable.anchor.focus();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
open(): void {
|
|
74
|
+
this.showPopover();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
showPopover(): void {
|
|
78
|
+
if (!this.#floatable.active) {
|
|
79
|
+
toggle(this.#floatable.anchor);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
toggle(): void {
|
|
84
|
+
this.togglePopover();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
togglePopover(options?: boolean): boolean {
|
|
88
|
+
if (typeof options === 'boolean') {
|
|
89
|
+
if (options === true) {
|
|
90
|
+
this.showPopover();
|
|
91
|
+
} else {
|
|
92
|
+
this.hidePopover();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.#floatable.active) {
|
|
99
|
+
this.hidePopover();
|
|
100
|
+
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.showPopover();
|
|
105
|
+
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//
|
|
111
|
+
|
|
112
|
+
function closeAll(): void {
|
|
113
|
+
for (const floatable of active) {
|
|
114
|
+
deactivate(floatable, active, order);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function closeAbove(element: HTMLElement): void {
|
|
119
|
+
const index = order.findIndex(
|
|
120
|
+
floatable => floatable.anchor === element || floatable.content === element,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (index === -1 || index === order.length - 1) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
while (index < order.length - 1) {
|
|
128
|
+
const floatable = order.pop();
|
|
129
|
+
|
|
130
|
+
if (floatable != null) {
|
|
131
|
+
deactivate(floatable, active, order);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
order[index].content.focus();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function focus(content: HTMLElement): void {
|
|
139
|
+
let target = getFocusable(content)[0];
|
|
140
|
+
|
|
141
|
+
if (!(target instanceof HTMLElement)) {
|
|
142
|
+
target = content;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
requestAnimationFrame(() => {
|
|
146
|
+
(target as HTMLElement).focus();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function onClick(event: MouseEvent): void {
|
|
151
|
+
const related = findAncestor(
|
|
152
|
+
event.target as never,
|
|
153
|
+
'[oui-popover-toggle], [oui-popover-content]',
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (!(related instanceof HTMLElement)) {
|
|
157
|
+
closeAll();
|
|
158
|
+
} else if (related.hasAttribute('oui-popover-content')) {
|
|
159
|
+
closeAbove(related);
|
|
160
|
+
} else {
|
|
161
|
+
toggle(related);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
166
|
+
if (event.key !== 'Escape' || order.length === 0) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const related = findAncestor(event.target as never, '[oui-popover-content]');
|
|
171
|
+
|
|
172
|
+
if (!(related instanceof HTMLElement)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
closeAbove(related);
|
|
177
|
+
|
|
178
|
+
const floatable = mapped.get(related);
|
|
179
|
+
|
|
180
|
+
if (floatable != null) {
|
|
181
|
+
deactivate(floatable, active, order);
|
|
182
|
+
|
|
183
|
+
floatable.anchor.focus();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function toggle(anchor: HTMLElement): void {
|
|
188
|
+
const content = findAncestor(anchor, '[oui-popover-content]');
|
|
189
|
+
|
|
190
|
+
if (content instanceof HTMLElement) {
|
|
191
|
+
closeAbove(content);
|
|
192
|
+
} else {
|
|
193
|
+
closeAll();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const floatable = mapped.get(anchor);
|
|
197
|
+
|
|
198
|
+
if (floatable == null) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (floatable.active) {
|
|
203
|
+
deactivate(floatable, active, order);
|
|
204
|
+
} else {
|
|
205
|
+
activate(floatable, active, order);
|
|
206
|
+
focus(floatable.content);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
//
|
|
211
|
+
|
|
212
|
+
customElements.define('oui-popover', OuiPopoverElement);
|
|
213
|
+
|
|
214
|
+
on(document, 'click', onClick);
|
|
215
|
+
on(document, 'keydown', onKeydown);
|
|
216
|
+
|
|
217
|
+
//
|
|
218
|
+
|
|
219
|
+
const active = new Set<Floatable>();
|
|
220
|
+
|
|
221
|
+
const order: Floatable[] = [];
|