@torpor/ui 0.0.17 → 0.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/CHANGELOG.md +28 -0
- package/dist/Accordion/Accordion.torp +53 -54
- package/dist/Accordion/AccordionContent.torp +5 -5
- package/dist/Accordion/AccordionHeader.torp +5 -4
- package/dist/Accordion/AccordionItem.torp +13 -8
- package/dist/Accordion/AccordionTrigger.torp +5 -5
- package/dist/Accordion/AccordionTypes.ts +1 -0
- package/dist/Dialog/Dialog.torp +156 -0
- package/dist/Dialog/DialogBody.torp +34 -0
- package/dist/Dialog/DialogButton.torp +48 -0
- package/dist/Dialog/DialogFooter.torp +13 -0
- package/dist/Dialog/DialogHeader.torp +34 -0
- package/dist/Dialog/DialogTypes.ts +16 -0
- package/dist/Dialog/index.cjs +53 -0
- package/dist/Dialog/index.cjs.map +1 -0
- package/dist/Dialog/index.js +22 -0
- package/dist/Dialog/index.js.map +1 -0
- package/dist/NavMenu/NavMenu.torp +178 -0
- package/dist/NavMenu/NavMenuButton.torp +46 -0
- package/dist/NavMenu/NavMenuElement.torp +43 -0
- package/dist/NavMenu/NavMenuGroup.torp +30 -0
- package/dist/NavMenu/NavMenuGroupHeader.torp +30 -0
- package/dist/NavMenu/NavMenuLink.torp +52 -0
- package/dist/NavMenu/NavMenuPopout.torp +69 -0
- package/dist/NavMenu/NavMenuPopoutButton.torp +120 -0
- package/dist/NavMenu/NavMenuPopoutContent.torp +79 -0
- package/dist/NavMenu/NavMenuSeparator.torp +14 -0
- package/dist/NavMenu/NavMenuTypes.ts +32 -0
- package/dist/NavMenu/index.cjs +78 -0
- package/dist/NavMenu/index.cjs.map +1 -0
- package/dist/NavMenu/index.js +42 -0
- package/dist/NavMenu/index.js.map +1 -0
- package/dist/Popover/Popover.torp +105 -0
- package/dist/Popover/PopoverContentAnchored.torp +107 -0
- package/dist/Popover/PopoverContentContextual.torp +99 -0
- package/dist/Popover/PopoverContentStatic.torp +78 -0
- package/dist/Popover/PopoverOverlay.torp +32 -0
- package/dist/Popover/PopoverTrigger.torp +29 -0
- package/dist/Popover/PopoverTriggerContextual.torp +37 -0
- package/dist/Popover/PopoverTriggerHover.torp +30 -0
- package/dist/Popover/PopoverTypes.ts +30 -0
- package/dist/Popover/index.cjs +222 -0
- package/dist/Popover/index.cjs.map +1 -0
- package/dist/Popover/index.js +183 -0
- package/dist/Popover/index.js.map +1 -0
- package/dist/Popover/mountClickPopover.ts +50 -0
- package/dist/Popover/mountContextPopover.ts +50 -0
- package/dist/Popover/mountHoverPopover.ts +57 -0
- package/dist/Popover/showPopover.ts +24 -0
- package/dist/Popover/showPopoverPrompt.ts +23 -0
- package/dist/TabGroup/TabContent.torp +4 -4
- package/dist/TabGroup/TabGroup.torp +58 -59
- package/dist/TabGroup/TabGroupTypes.ts +1 -0
- package/dist/TabGroup/TabHeader.torp +10 -8
- package/dist/TabGroup/TabList.torp +4 -3
- package/dist/TabGroup/TabTrigger.torp +6 -6
- package/dist/metafile-cjs.json +1 -1
- package/dist/metafile-esm.json +1 -1
- package/dist/mount/disableScroll.ts +28 -0
- package/dist/mount/hoverTrigger.ts +59 -0
- package/dist/mount/trapFocus.ts +58 -0
- package/dist/utils/documentEvents.ts +17 -0
- package/dist/utils/getScrollParent.ts +22 -0
- package/dist/utils/setContextPosition.ts +31 -0
- package/dist/utils/setPopoverPosition.ts +138 -0
- package/package.json +21 -6
- package/scripts/postbuild.ts +17 -5
- package/src/Accordion/Accordion.torp +53 -54
- package/src/Accordion/AccordionContent.torp +5 -5
- package/src/Accordion/AccordionHeader.torp +5 -4
- package/src/Accordion/AccordionItem.torp +13 -8
- package/src/Accordion/AccordionTrigger.torp +5 -5
- package/src/Accordion/AccordionTypes.ts +1 -0
- package/src/Dialog/Dialog.torp +156 -0
- package/src/Dialog/DialogBody.torp +34 -0
- package/src/Dialog/DialogButton.torp +48 -0
- package/src/Dialog/DialogFooter.torp +13 -0
- package/src/Dialog/DialogHeader.torp +34 -0
- package/src/Dialog/DialogTypes.ts +16 -0
- package/src/Dialog/index.ts +7 -0
- package/src/Dialog.ts +7 -0
- package/src/NavMenu/NavMenu.torp +178 -0
- package/src/NavMenu/NavMenuButton.torp +46 -0
- package/src/NavMenu/NavMenuElement.torp +43 -0
- package/src/NavMenu/NavMenuGroup.torp +30 -0
- package/src/NavMenu/NavMenuGroupHeader.torp +30 -0
- package/src/NavMenu/NavMenuLink.torp +52 -0
- package/src/NavMenu/NavMenuPopout.torp +69 -0
- package/src/NavMenu/NavMenuPopoutButton.torp +120 -0
- package/src/NavMenu/NavMenuPopoutContent.torp +79 -0
- package/src/NavMenu/NavMenuSeparator.torp +14 -0
- package/src/NavMenu/NavMenuTypes.ts +32 -0
- package/src/NavMenu/index.ts +23 -0
- package/src/NavMenu.ts +23 -0
- package/src/Popover/Popover.torp +105 -0
- package/src/Popover/PopoverContentAnchored.torp +107 -0
- package/src/Popover/PopoverContentContextual.torp +99 -0
- package/src/Popover/PopoverContentStatic.torp +78 -0
- package/src/Popover/PopoverOverlay.torp +32 -0
- package/src/Popover/PopoverTrigger.torp +29 -0
- package/src/Popover/PopoverTriggerContextual.torp +37 -0
- package/src/Popover/PopoverTriggerHover.torp +30 -0
- package/src/Popover/PopoverTypes.ts +30 -0
- package/src/Popover/index.ts +29 -0
- package/src/Popover/mountClickPopover.ts +50 -0
- package/src/Popover/mountContextPopover.ts +50 -0
- package/src/Popover/mountHoverPopover.ts +57 -0
- package/src/Popover/showPopover.ts +24 -0
- package/src/Popover/showPopoverPrompt.ts +23 -0
- package/src/Popover.ts +29 -0
- package/src/TabGroup/TabContent.torp +4 -4
- package/src/TabGroup/TabGroup.torp +58 -59
- package/src/TabGroup/TabGroupTypes.ts +1 -0
- package/src/TabGroup/TabHeader.torp +10 -8
- package/src/TabGroup/TabList.torp +4 -3
- package/src/TabGroup/TabTrigger.torp +6 -6
- package/src/mount/disableScroll.ts +28 -0
- package/src/mount/hoverTrigger.ts +59 -0
- package/src/mount/trapFocus.ts +58 -0
- package/src/utils/documentEvents.ts +17 -0
- package/src/utils/getScrollParent.ts +22 -0
- package/src/utils/setContextPosition.ts +31 -0
- package/src/utils/setPopoverPosition.ts +138 -0
- package/test/Accordion/accessibility.test.ts +0 -3
- package/test/Accordion/for-items.test.ts +2 -4
- package/test/Accordion/keyboard.test.ts +0 -3
- package/test/Accordion/multiple-item-toggle.test.ts +3 -5
- package/test/Accordion/single-item-toggle.test.ts +3 -5
- package/test/Dialog/accessibility-alert.test.ts +40 -0
- package/test/Dialog/accessibility-modal.test.ts +52 -0
- package/test/Dialog/components/CustomDialog.torp +20 -0
- package/test/Dialog/components/DialogAccessibility.torp +31 -0
- package/test/Dialog/components/DialogFunction.torp +22 -0
- package/test/Dialog/components/DialogVariable.torp +35 -0
- package/test/Dialog/function.test.ts +26 -0
- package/test/Dialog/variable.test.ts +27 -0
- package/test/NavMenu/accessibility.test.ts +97 -0
- package/test/NavMenu/basic.test.ts +22 -0
- package/test/NavMenu/components/NavMenu.torp +28 -0
- package/test/NavMenu/components/NavMenuAccessibility.torp +35 -0
- package/test/TabGroup/for-items.test.ts +2 -4
- package/test/TabGroup/item-trigger.test.ts +3 -5
- package/tsup.config.ts +8 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
interface HoverTriggerOptions {
|
|
2
|
+
onHover: (node: HTMLElement, e: TouchEvent | MouseEvent) => void;
|
|
3
|
+
onExit?: (node: HTMLElement, e: TouchEvent | MouseEvent) => void;
|
|
4
|
+
hoverDelay?: number;
|
|
5
|
+
touchDelay?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function hoverTrigger(node: HTMLElement, options: HoverTriggerOptions): () => void {
|
|
9
|
+
const { onHover, onExit } = options;
|
|
10
|
+
const hoverDelay = options.hoverDelay || 1000;
|
|
11
|
+
const touchDelay = options.touchDelay || 1000;
|
|
12
|
+
|
|
13
|
+
let interval: NodeJS.Timeout | undefined;
|
|
14
|
+
|
|
15
|
+
// Add click event handlers to the node
|
|
16
|
+
node.addEventListener("mouseenter", handleMouseEnter);
|
|
17
|
+
node.addEventListener("mouseleave", handleMouseLeave);
|
|
18
|
+
node.addEventListener("mousemove", handleMouseMove);
|
|
19
|
+
node.addEventListener("touchstart", handleMouseEnter);
|
|
20
|
+
node.addEventListener("touchend", handleMouseLeave);
|
|
21
|
+
|
|
22
|
+
function handleMouseEnter(e: MouseEvent | TouchEvent) {
|
|
23
|
+
// Handle the hover after a delay
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
if (!interval) {
|
|
26
|
+
const delay = e.type.startsWith("mouse") ? hoverDelay : touchDelay;
|
|
27
|
+
interval = setTimeout(() => onHover(node, e), delay);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleMouseLeave(e: MouseEvent | TouchEvent) {
|
|
32
|
+
// Prevent short hovers or touches from firing the event
|
|
33
|
+
if (interval) {
|
|
34
|
+
clearTimeout(interval);
|
|
35
|
+
interval = undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (onExit) {
|
|
39
|
+
onExit(node, e);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleMouseMove(e: MouseEvent) {
|
|
44
|
+
// Reset the interval on mouse move
|
|
45
|
+
if (interval) {
|
|
46
|
+
clearTimeout(interval);
|
|
47
|
+
interval = setTimeout(() => onHover(node, e), hoverDelay);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Remove the event listeners from the node on destroy
|
|
52
|
+
return () => {
|
|
53
|
+
node.removeEventListener("mouseenter", handleMouseEnter);
|
|
54
|
+
node.removeEventListener("mouseleave", handleMouseLeave);
|
|
55
|
+
node.removeEventListener("mousemove", handleMouseMove);
|
|
56
|
+
node.removeEventListener("touchstart", handleMouseEnter);
|
|
57
|
+
node.removeEventListener("touchend", handleMouseLeave);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface TrapFocusOptions {
|
|
2
|
+
focusableElements: HTMLElement[];
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function trapFocus(node: HTMLElement, options?: TrapFocusOptions): () => void {
|
|
6
|
+
options = options || { focusableElements: [] };
|
|
7
|
+
|
|
8
|
+
// Selectors for all things that are focusable:
|
|
9
|
+
const focusableSelectors = [
|
|
10
|
+
"a[href]",
|
|
11
|
+
"area[href]",
|
|
12
|
+
'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
|
|
13
|
+
"select:not([disabled]):not([aria-hidden])",
|
|
14
|
+
"textarea:not([disabled]):not([aria-hidden])",
|
|
15
|
+
"button:not([disabled]):not([aria-hidden])",
|
|
16
|
+
"iframe",
|
|
17
|
+
"object",
|
|
18
|
+
"embed",
|
|
19
|
+
"[contenteditable]",
|
|
20
|
+
'[tabindex]:not([tabindex^="-"])',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Set the focusable elements array in the options, which can be used in the caller e.g. to focus the first element
|
|
24
|
+
options.focusableElements = Array.from(node.querySelectorAll(focusableSelectors.join()));
|
|
25
|
+
|
|
26
|
+
// Store first and last focusable elements
|
|
27
|
+
const firstFocusableElement = options.focusableElements[0] as HTMLElement;
|
|
28
|
+
const lastFocusableElement = options.focusableElements[
|
|
29
|
+
options.focusableElements.length - 1
|
|
30
|
+
] as HTMLElement;
|
|
31
|
+
|
|
32
|
+
// Add the keydown event listener to the node
|
|
33
|
+
node.addEventListener("keydown", handleKey);
|
|
34
|
+
|
|
35
|
+
// On Tab or Shift+Tab, move through the focusable elements, wrapping at either end
|
|
36
|
+
function handleKey(e: KeyboardEvent) {
|
|
37
|
+
if (e.key === "Tab") {
|
|
38
|
+
if (e.shiftKey) {
|
|
39
|
+
if (document.activeElement === firstFocusableElement) {
|
|
40
|
+
// Shift + Tab on the first element, so move to the last one
|
|
41
|
+
lastFocusableElement.focus();
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
if (document.activeElement === lastFocusableElement) {
|
|
46
|
+
// Tab on the last element, so move to the first one
|
|
47
|
+
firstFocusableElement.focus();
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Remove the keydown event listener from the node on destroy
|
|
55
|
+
return () => {
|
|
56
|
+
node.removeEventListener("keydown", handleKey);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function addDocumentEvent<K extends keyof DocumentEventMap>(
|
|
2
|
+
type: K,
|
|
3
|
+
listener: (this: Document, ev: DocumentEventMap[K]) => any
|
|
4
|
+
): void {
|
|
5
|
+
if (typeof document !== 'undefined') {
|
|
6
|
+
document.addEventListener(type, listener);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function removeDocumentEvent<K extends keyof DocumentEventMap>(
|
|
11
|
+
type: K,
|
|
12
|
+
listener: (this: Document, ev: DocumentEventMap[K]) => any
|
|
13
|
+
): void {
|
|
14
|
+
if (typeof document !== 'undefined') {
|
|
15
|
+
document.removeEventListener(type, listener);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// From https://stackoverflow.com/a/76535387
|
|
2
|
+
export default function getScrollParent(node: Element): Element {
|
|
3
|
+
let currentParent = node.parentElement;
|
|
4
|
+
while (currentParent) {
|
|
5
|
+
if (isScrollable(currentParent)) {
|
|
6
|
+
return currentParent;
|
|
7
|
+
}
|
|
8
|
+
currentParent = currentParent.parentElement;
|
|
9
|
+
}
|
|
10
|
+
return document.scrollingElement || document.documentElement;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isScrollable(node: Element): boolean {
|
|
14
|
+
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const style = getComputedStyle(node);
|
|
18
|
+
return ["overflow", "overflow-x", "overflow-y"].some((propertyName) => {
|
|
19
|
+
const value = style.getPropertyValue(propertyName);
|
|
20
|
+
return value === "auto" || value === "scroll";
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
interface Point {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export default function setContextPosition(
|
|
7
|
+
anchor: HTMLElement,
|
|
8
|
+
content: HTMLElement,
|
|
9
|
+
position: Point,
|
|
10
|
+
): void {
|
|
11
|
+
var anchorRect = anchor.getBoundingClientRect();
|
|
12
|
+
var contentRect = content.getBoundingClientRect();
|
|
13
|
+
|
|
14
|
+
content.style.position = "fixed";
|
|
15
|
+
|
|
16
|
+
// Try to make sure the content is entirely on the screen horizontally
|
|
17
|
+
// Just move it left or right -- the pointer is outside the content vertically so moving it left
|
|
18
|
+
// or right won't make the content appear over the cursor
|
|
19
|
+
let left = anchorRect.left + position.x;
|
|
20
|
+
left = Math.min(left, window.innerWidth - contentRect.width);
|
|
21
|
+
left = Math.max(left, 0);
|
|
22
|
+
content.style.left = left + "px";
|
|
23
|
+
|
|
24
|
+
// Try to make sure the content is entirely on the screen vertically
|
|
25
|
+
// Show it above the position if it wouldn't fit
|
|
26
|
+
let top = anchorRect.top + position.y;
|
|
27
|
+
if (top + contentRect.height > window.innerHeight) {
|
|
28
|
+
top -= contentRect.height;
|
|
29
|
+
}
|
|
30
|
+
content.style.top = top + "px";
|
|
31
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export default function setPopoverPosition(
|
|
2
|
+
anchor: HTMLElement,
|
|
3
|
+
content: HTMLElement,
|
|
4
|
+
side: "top" | "right" | "bottom" | "left",
|
|
5
|
+
alignment: "start" | "center" | "end",
|
|
6
|
+
): void {
|
|
7
|
+
var anchorRect = anchor.getBoundingClientRect();
|
|
8
|
+
var contentRect = content.getBoundingClientRect();
|
|
9
|
+
|
|
10
|
+
content.style.position = "fixed";
|
|
11
|
+
|
|
12
|
+
// Check that we there is enough room to show the content on the side requested
|
|
13
|
+
// Otherwise, if there is enough room on the other side, show it there instead
|
|
14
|
+
let calculatedSide = side;
|
|
15
|
+
const canShowOnTop = anchorRect.top - contentRect.height >= 0;
|
|
16
|
+
const canShowAtRight = anchorRect.right + contentRect.width <= window.innerWidth;
|
|
17
|
+
const canShowOnBottom = anchorRect.bottom + contentRect.height <= window.innerHeight;
|
|
18
|
+
const canShowAtLeft = anchorRect.left - contentRect.width >= 0;
|
|
19
|
+
switch (side) {
|
|
20
|
+
case "top": {
|
|
21
|
+
if (!canShowOnTop && canShowOnBottom) {
|
|
22
|
+
calculatedSide = "bottom";
|
|
23
|
+
}
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
case "right": {
|
|
27
|
+
if (!canShowAtRight && canShowAtLeft) {
|
|
28
|
+
calculatedSide = "left";
|
|
29
|
+
}
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case "bottom": {
|
|
33
|
+
if (!canShowOnBottom && canShowOnTop) {
|
|
34
|
+
calculatedSide = "top";
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case "left": {
|
|
39
|
+
if (!canShowAtLeft && canShowAtRight) {
|
|
40
|
+
calculatedSide = "right";
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
default: {
|
|
45
|
+
throw new Error(`Invalid MenuPopout side: ${side}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
switch (calculatedSide) {
|
|
50
|
+
case "top": {
|
|
51
|
+
content.style.top = anchorRect.top - contentRect.height + "px";
|
|
52
|
+
setVerticalAlignment(content, anchorRect, contentRect, alignment);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "right": {
|
|
56
|
+
content.style.left = anchorRect.right + "px";
|
|
57
|
+
setHorizontalAlignment(content, anchorRect, contentRect, alignment);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case "bottom": {
|
|
61
|
+
content.style.top = anchorRect.bottom + "px";
|
|
62
|
+
setVerticalAlignment(content, anchorRect, contentRect, alignment);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "left": {
|
|
66
|
+
content.style.left = anchorRect.left - contentRect.width + "px";
|
|
67
|
+
setHorizontalAlignment(content, anchorRect, contentRect, alignment);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
default: {
|
|
71
|
+
throw new Error(`Invalid MenuPopout side: ${side}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setVerticalAlignment(
|
|
77
|
+
content: HTMLElement,
|
|
78
|
+
anchorRect: DOMRect,
|
|
79
|
+
contentRect: DOMRect,
|
|
80
|
+
alignment: "start" | "center" | "end",
|
|
81
|
+
) {
|
|
82
|
+
let left = 0;
|
|
83
|
+
switch (alignment) {
|
|
84
|
+
case "start": {
|
|
85
|
+
left = anchorRect.left;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "center": {
|
|
89
|
+
left = anchorRect.left + anchorRect.width / 2 - contentRect.width / 2;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "end": {
|
|
93
|
+
left = anchorRect.right - contentRect.width;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
default: {
|
|
97
|
+
throw new Error(`Invalid Popout alignment: ${alignment}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Try to make sure the content is entirely on the screen horizontally
|
|
102
|
+
left = Math.min(left, window.innerWidth - contentRect.width);
|
|
103
|
+
left = Math.max(left, 0);
|
|
104
|
+
|
|
105
|
+
content.style.left = left + "px";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setHorizontalAlignment(
|
|
109
|
+
content: HTMLElement,
|
|
110
|
+
anchorRect: DOMRect,
|
|
111
|
+
contentRect: DOMRect,
|
|
112
|
+
alignment: "start" | "center" | "end",
|
|
113
|
+
) {
|
|
114
|
+
let top = 0;
|
|
115
|
+
switch (alignment) {
|
|
116
|
+
case "start": {
|
|
117
|
+
top = anchorRect.top;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "center": {
|
|
121
|
+
top = anchorRect.top + anchorRect.height / 2 - contentRect.height / 2;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case "end": {
|
|
125
|
+
top = anchorRect.bottom - contentRect.height;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
default: {
|
|
129
|
+
throw new Error(`Invalid Popout alignment: ${alignment}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Try to make sure the content is entirely on the screen vertically
|
|
134
|
+
top = Math.min(top, window.innerHeight - contentRect.height);
|
|
135
|
+
top = Math.max(top, 0);
|
|
136
|
+
|
|
137
|
+
content.style.top = top + "px";
|
|
138
|
+
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { queryByText } from "@testing-library/dom";
|
|
2
2
|
import "@testing-library/jest-dom/vitest";
|
|
3
|
-
import userEvent from "@testing-library/user-event";
|
|
4
3
|
import { mount } from "@torpor/view";
|
|
5
4
|
import { describe, expect, it } from "vitest";
|
|
6
5
|
import AccordionAccessibility from "./components/AccordionAccessibility.torp";
|
|
7
6
|
|
|
8
7
|
describe("Accordion", () => {
|
|
9
8
|
it("Accessibility", async () => {
|
|
10
|
-
const user = userEvent.setup();
|
|
11
|
-
|
|
12
9
|
const container = document.createElement("div");
|
|
13
10
|
document.body.appendChild(container);
|
|
14
11
|
mount(container, AccordionAccessibility, { value: ["1", "3"] });
|
|
@@ -7,8 +7,6 @@ import AccordionList from "./components/AccordionList.torp";
|
|
|
7
7
|
|
|
8
8
|
describe("Accordion", () => {
|
|
9
9
|
it("Items in a for loop", async () => {
|
|
10
|
-
const user = userEvent.setup();
|
|
11
|
-
|
|
12
10
|
let $state = $watch({
|
|
13
11
|
value: "1",
|
|
14
12
|
items: [
|
|
@@ -35,7 +33,7 @@ describe("Accordion", () => {
|
|
|
35
33
|
expect(queryByText(container, "Content 3")).not.toBeInTheDocument();
|
|
36
34
|
|
|
37
35
|
// Clicking header 1 should reveal body 1
|
|
38
|
-
await
|
|
36
|
+
await userEvent.click(getByText(container, "Item 1"));
|
|
39
37
|
expect(queryByText(container, "Content 1")).toBeInTheDocument();
|
|
40
38
|
|
|
41
39
|
$state.items.push({ id: 3, header: "Item 4", text: "Content 4" });
|
|
@@ -47,7 +45,7 @@ describe("Accordion", () => {
|
|
|
47
45
|
expect(queryByText(container, "Content 4")).not.toBeInTheDocument();
|
|
48
46
|
|
|
49
47
|
// Clicking header 4 should reveal body 4
|
|
50
|
-
await
|
|
48
|
+
await userEvent.click(getByText(container, "Item 4"));
|
|
51
49
|
expect(queryByText(container, "Content 1")).not.toBeInTheDocument();
|
|
52
50
|
expect(queryByText(container, "Content 4")).toBeInTheDocument();
|
|
53
51
|
});
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { fireEvent, getByText, queryByText } from "@testing-library/dom";
|
|
2
2
|
import "@testing-library/jest-dom/vitest";
|
|
3
|
-
import userEvent from "@testing-library/user-event";
|
|
4
3
|
import { mount } from "@torpor/view";
|
|
5
4
|
import { describe, expect, it } from "vitest";
|
|
6
5
|
import AccordionKeyboard from "./components/AccordionKeyboard.torp";
|
|
7
6
|
|
|
8
7
|
describe("Accordion", () => {
|
|
9
8
|
it("Keyboard", async () => {
|
|
10
|
-
const user = userEvent.setup();
|
|
11
|
-
|
|
12
9
|
const container = document.createElement("div");
|
|
13
10
|
document.body.appendChild(container);
|
|
14
11
|
mount(container, AccordionKeyboard, { value: ["1", "2"] });
|
|
@@ -7,8 +7,6 @@ import AccordionMultiple from "./components/AccordionMultiple.torp";
|
|
|
7
7
|
|
|
8
8
|
describe("Accordion", () => {
|
|
9
9
|
it("Multiple item toggle", async () => {
|
|
10
|
-
const user = userEvent.setup();
|
|
11
|
-
|
|
12
10
|
const container = document.createElement("div");
|
|
13
11
|
document.body.appendChild(container);
|
|
14
12
|
mount(container, AccordionMultiple);
|
|
@@ -18,16 +16,16 @@ describe("Accordion", () => {
|
|
|
18
16
|
expect(queryByText(container, "Content 3")).not.toBeInTheDocument();
|
|
19
17
|
|
|
20
18
|
// Clicking header 1 should reveal body 1
|
|
21
|
-
await
|
|
19
|
+
await userEvent.click(getByText(container, "Header 1"));
|
|
22
20
|
expect(queryByText(container, "Content 1")).toBeInTheDocument();
|
|
23
21
|
|
|
24
22
|
// Clicking header 2 should reveal body 1 and body 2
|
|
25
|
-
await
|
|
23
|
+
await userEvent.click(getByText(container, "Header 2"));
|
|
26
24
|
expect(queryByText(container, "Content 1")).toBeInTheDocument();
|
|
27
25
|
expect(queryByText(container, "Content 2")).toBeInTheDocument();
|
|
28
26
|
|
|
29
27
|
// Clicking header 2 again should hide body 2
|
|
30
|
-
await
|
|
28
|
+
await userEvent.click(getByText(container, "Header 2"));
|
|
31
29
|
expect(queryByText(container, "Content 1")).toBeInTheDocument();
|
|
32
30
|
expect(queryByText(container, "Content 2")).not.toBeInTheDocument();
|
|
33
31
|
});
|
|
@@ -7,8 +7,6 @@ import AccordionSingle from "./components/AccordionSingle.torp";
|
|
|
7
7
|
|
|
8
8
|
describe("Accordion", () => {
|
|
9
9
|
it("Single item toggle", async () => {
|
|
10
|
-
const user = userEvent.setup();
|
|
11
|
-
|
|
12
10
|
const container = document.createElement("div");
|
|
13
11
|
document.body.appendChild(container);
|
|
14
12
|
mount(container, AccordionSingle);
|
|
@@ -18,16 +16,16 @@ describe("Accordion", () => {
|
|
|
18
16
|
expect(queryByText(container, "Content 3")).not.toBeInTheDocument();
|
|
19
17
|
|
|
20
18
|
// Clicking header 1 should reveal body 1
|
|
21
|
-
await
|
|
19
|
+
await userEvent.click(getByText(container, "Header 1"));
|
|
22
20
|
expect(queryByText(container, "Content 1")).toBeInTheDocument();
|
|
23
21
|
|
|
24
22
|
// Clicking header 2 should hide body 1 and reveal body 2
|
|
25
|
-
await
|
|
23
|
+
await userEvent.click(getByText(container, "Header 2"));
|
|
26
24
|
expect(queryByText(container, "Content 1")).not.toBeInTheDocument();
|
|
27
25
|
expect(queryByText(container, "Content 2")).toBeInTheDocument();
|
|
28
26
|
|
|
29
27
|
// Clicking header 2 again should hide body 2
|
|
30
|
-
await
|
|
28
|
+
await userEvent.click(getByText(container, "Header 2"));
|
|
31
29
|
expect(queryByText(container, "Content 2")).not.toBeInTheDocument();
|
|
32
30
|
});
|
|
33
31
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { queryByText } from "@testing-library/dom";
|
|
2
|
+
import "@testing-library/jest-dom/vitest";
|
|
3
|
+
import { mount } from "@torpor/view";
|
|
4
|
+
import { assert, describe, expect, it } from "vitest";
|
|
5
|
+
import DialogAccessibility from "./components/DialogAccessibility.torp";
|
|
6
|
+
|
|
7
|
+
describe("Dialog", () => {
|
|
8
|
+
it("Accessibility -- alert", async () => {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
mount(container, DialogAccessibility, { alert: true });
|
|
12
|
+
|
|
13
|
+
// Tests from https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
|
|
14
|
+
// This is for a Dialog with its alert property set to true
|
|
15
|
+
|
|
16
|
+
// The element that contains all elements of the dialog, including the
|
|
17
|
+
// alert message and any dialog buttons, has role alertdialog
|
|
18
|
+
let dialog = queryByText(container, "Which color do you prefer?");
|
|
19
|
+
assert(dialog, "Dialog not found");
|
|
20
|
+
assert(dialog.parentElement, "Dialog parent not found");
|
|
21
|
+
dialog = dialog.parentElement;
|
|
22
|
+
expect(dialog).toHaveAttribute("role", "alertdialog");
|
|
23
|
+
|
|
24
|
+
// The element with role alertdialog has either:
|
|
25
|
+
// * A value for aria-labelledby that refers to the element containing
|
|
26
|
+
// the title of the dialog if the dialog has a visible label
|
|
27
|
+
// * A value for aria-label if the dialog does not have a visible
|
|
28
|
+
// label
|
|
29
|
+
const header = queryByText(container, "Color preference");
|
|
30
|
+
assert(header, "Header not found");
|
|
31
|
+
expect(dialog).toHaveAttribute("aria-labelledby", header.id);
|
|
32
|
+
|
|
33
|
+
// The element with role alertdialog has a value set for
|
|
34
|
+
// aria-describedby that refers to the element containing the alert
|
|
35
|
+
// message
|
|
36
|
+
const body = queryByText(container, "Which color do you prefer?");
|
|
37
|
+
assert(body, "Body not found");
|
|
38
|
+
expect(dialog).toHaveAttribute("aria-describedby", body.id);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { queryByText } from "@testing-library/dom";
|
|
2
|
+
import "@testing-library/jest-dom/vitest";
|
|
3
|
+
import { mount } from "@torpor/view";
|
|
4
|
+
import { assert, describe, expect, it } from "vitest";
|
|
5
|
+
import DialogAccessibility from "./components/DialogAccessibility.torp";
|
|
6
|
+
|
|
7
|
+
describe("Dialog", () => {
|
|
8
|
+
it("Accessibility -- modal", async () => {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
mount(container, DialogAccessibility, { modal: true, ariaAnnounce: false });
|
|
12
|
+
|
|
13
|
+
// Tests from https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/
|
|
14
|
+
// This is for a Dialog in a Popover with its modal property set to true
|
|
15
|
+
|
|
16
|
+
// The element that serves as the dialog container has a role of dialog
|
|
17
|
+
let dialog = queryByText(container, "Which color do you prefer?");
|
|
18
|
+
assert(dialog, "Dialog not found");
|
|
19
|
+
assert(dialog.parentElement, "Dialog parent not found");
|
|
20
|
+
dialog = dialog.parentElement;
|
|
21
|
+
expect(dialog).toHaveAttribute("role", "dialog");
|
|
22
|
+
|
|
23
|
+
// All elements required to operate the dialog are descendants of the
|
|
24
|
+
// element that has role dialog
|
|
25
|
+
|
|
26
|
+
// The dialog container element has aria-modal set to true
|
|
27
|
+
expect(dialog).toHaveAttribute("aria-modal", "true");
|
|
28
|
+
|
|
29
|
+
// The dialog has either:
|
|
30
|
+
// * A value set for the aria-labelledby property that refers to a
|
|
31
|
+
// visible dialog title
|
|
32
|
+
// * A label specified by aria-label
|
|
33
|
+
const header = queryByText(container, "Color preference");
|
|
34
|
+
assert(header, "Header not found");
|
|
35
|
+
expect(dialog).toHaveAttribute("aria-labelledby", header.id);
|
|
36
|
+
|
|
37
|
+
// Optionally, the aria-describedby property is set on the element with
|
|
38
|
+
// the dialog role to indicate which element or elements in the dialog
|
|
39
|
+
// contain content that describes the primary purpose or message of the
|
|
40
|
+
// dialog. Specifying descriptive elements enables screen readers to
|
|
41
|
+
// announce the description along with the dialog title and initially
|
|
42
|
+
// focused element when the dialog opens, which is typically helpful
|
|
43
|
+
// only when the descriptive content is simple and can easily be
|
|
44
|
+
// understood without structural information. It is advisable to omit
|
|
45
|
+
// specifying aria-describedby if the dialog content includes semantic
|
|
46
|
+
// structures, such as lists, tables, or multiple paragraphs, that need
|
|
47
|
+
// to be perceived in order to easily understand the content, i.e., if
|
|
48
|
+
// the content would be difficult to understand when announced as a
|
|
49
|
+
// single unbroken string
|
|
50
|
+
expect(dialog).not.toHaveAttribute("aria-describedby");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Popover, PopoverContentStatic } from "../../../src/Popover/index";
|
|
2
|
+
import { Dialog, DialogBody, DialogFooter, DialogButton } from "../../../src/Dialog/index";
|
|
3
|
+
|
|
4
|
+
export default function CustomDialog($props: any) {
|
|
5
|
+
@render {
|
|
6
|
+
<Popover visible callback={$props.callback}>
|
|
7
|
+
<PopoverContentStatic>
|
|
8
|
+
<Dialog>
|
|
9
|
+
<DialogBody>
|
|
10
|
+
<p>Which color do you prefer?</p>
|
|
11
|
+
</DialogBody>
|
|
12
|
+
<DialogFooter>
|
|
13
|
+
<DialogButton value="blue">Blue</DialogButton>
|
|
14
|
+
<DialogButton value="red">Red</DialogButton>
|
|
15
|
+
</DialogFooter>
|
|
16
|
+
</Dialog>
|
|
17
|
+
</PopoverContentStatic>
|
|
18
|
+
</Popover>
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Popover, PopoverContentStatic } from '../../../src/Popover/index';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogHeader,
|
|
5
|
+
DialogBody,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogButton
|
|
8
|
+
} from '../../../src/Dialog/index';
|
|
9
|
+
|
|
10
|
+
export default function DialogAccessibility($props: any) {
|
|
11
|
+
$props.modal ??= false;
|
|
12
|
+
$props.alert ??= false;
|
|
13
|
+
$props.ariaAnnounce ??= true;
|
|
14
|
+
|
|
15
|
+
let color = '';
|
|
16
|
+
|
|
17
|
+
@render {
|
|
18
|
+
<Popover visible modal={$props.modal}>
|
|
19
|
+
<PopoverContentStatic>
|
|
20
|
+
<Dialog alert={$props.alert}>
|
|
21
|
+
<DialogHeader>Color preference</DialogHeader>
|
|
22
|
+
<DialogBody ariaAnnounce={$props.ariaAnnounce}>Which color do you prefer?</DialogBody>
|
|
23
|
+
<DialogFooter>
|
|
24
|
+
<DialogButton value="blue" onclick={() => (color = 'blue')}>Blue</DialogButton>
|
|
25
|
+
<DialogButton value="red" onclick={() => (color = 'red')}>Red</DialogButton>
|
|
26
|
+
</DialogFooter>
|
|
27
|
+
</Dialog>
|
|
28
|
+
</PopoverContentStatic>
|
|
29
|
+
</Popover>
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { showPopoverPrompt } from "../../../src/Popover";
|
|
2
|
+
import CustomDialog from "./CustomDialog.torp";
|
|
3
|
+
|
|
4
|
+
export default function DialogFunction($props: any) {
|
|
5
|
+
let $state = $watch({ color: "" });
|
|
6
|
+
|
|
7
|
+
async function show() {
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
$state.color = await showPopoverPrompt(CustomDialog, null, $parent);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@render {
|
|
13
|
+
<div>
|
|
14
|
+
<button type="button" onclick={show}>Show the modal</button>
|
|
15
|
+
|
|
16
|
+
@if ($state.color) {
|
|
17
|
+
<p>Your favorite color is {$state.color}?!</p>
|
|
18
|
+
}
|
|
19
|
+
</div>
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Popover, PopoverContentStatic } from "../../../src/Popover/index";
|
|
2
|
+
import { Dialog, DialogBody, DialogFooter, DialogButton } from "../../../src/Dialog/index";
|
|
3
|
+
|
|
4
|
+
export default function DialogVariable($props: any) {
|
|
5
|
+
let $state = $watch({
|
|
6
|
+
color: "",
|
|
7
|
+
showPopover: false
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
@render {
|
|
11
|
+
<div>
|
|
12
|
+
<button type="button" onclick={(e) => ($state.showPopover = !$state.showPopover)}>
|
|
13
|
+
Show the modal
|
|
14
|
+
</button>
|
|
15
|
+
|
|
16
|
+
@if ($state.color) {
|
|
17
|
+
<p>Your favorite color is {$state.color}?!</p>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
<Popover visible={$state.showPopover}>
|
|
21
|
+
<PopoverContentStatic>
|
|
22
|
+
<Dialog>
|
|
23
|
+
<DialogBody>
|
|
24
|
+
<p>Which color do you prefer?</p>
|
|
25
|
+
</DialogBody>
|
|
26
|
+
<DialogFooter>
|
|
27
|
+
<DialogButton value="blue" onclick={() => ($state.color = "blue")}>Blue</DialogButton>
|
|
28
|
+
<DialogButton value="red" onclick={() => ($state.color = "red")}>Red</DialogButton>
|
|
29
|
+
</DialogFooter>
|
|
30
|
+
</Dialog>
|
|
31
|
+
</PopoverContentStatic>
|
|
32
|
+
</Popover>
|
|
33
|
+
</div>
|
|
34
|
+
}
|
|
35
|
+
}
|