@torpor/ui 0.0.17 → 0.1.1
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 +41 -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/Offscreen/Offscreen.torp +93 -0
- package/dist/Offscreen/index.cjs +33 -0
- package/dist/Offscreen/index.cjs.map +1 -0
- package/dist/Offscreen/index.js +6 -0
- package/dist/Offscreen/index.js.map +1 -0
- package/dist/Popover/Popover.torp +105 -0
- package/dist/Popover/PopoverContent.torp +78 -0
- package/dist/Popover/PopoverContentAnchored.torp +107 -0
- package/dist/Popover/PopoverContentContextual.torp +99 -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 +38 -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 +64 -62
- 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 +26 -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/Offscreen/Offscreen.torp +93 -0
- package/src/Offscreen/index.ts +3 -0
- package/src/Popover/Popover.torp +105 -0
- package/src/Popover/PopoverContent.torp +78 -0
- package/src/Popover/PopoverContentAnchored.torp +107 -0
- package/src/Popover/PopoverContentContextual.torp +99 -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 +38 -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 +64 -62
- 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 +10 -11
- package/tsup.config.ts +9 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AccordionContextName, AccordionItemContextName, type AccordionContext, type AccordionItemContext } from
|
|
1
|
+
import { AccordionContextName, AccordionItemContextName, type AccordionContext, type AccordionItemContext } from "./AccordionTypes";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* An item in an Accordion, which must consist of an AccordionTrigger and an AccordionContent.
|
|
@@ -27,14 +27,14 @@ export default function AccordionItem($props: any) {
|
|
|
27
27
|
disabled: $props.disabled,
|
|
28
28
|
parentDisabled: false,
|
|
29
29
|
get dataState() {
|
|
30
|
-
return this.expanded ?
|
|
30
|
+
return this.expanded ? "open" : "closed";
|
|
31
31
|
},
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
// Get stuff out of the AccordionContext
|
|
35
35
|
const context = $context[AccordionContextName] as AccordionContext;
|
|
36
36
|
if (!context) {
|
|
37
|
-
throw new Error(
|
|
37
|
+
throw new Error("AccordionItem must be contained within an Accordion");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Put stuff into an AccordionItemContext
|
|
@@ -56,16 +56,21 @@ export default function AccordionItem($props: any) {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// Dispatch the toggle event when the value of expanded changes
|
|
59
|
-
|
|
59
|
+
let firstToggle = true;
|
|
60
60
|
$run(() => {
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const ev = $props.ontoggle;
|
|
62
|
+
const expanded = $state.expanded;
|
|
63
|
+
if (firstToggle) {
|
|
64
|
+
firstToggle = false;
|
|
65
|
+
} else if (ev) {
|
|
66
|
+
ev(expanded);
|
|
63
67
|
}
|
|
64
|
-
})
|
|
68
|
+
});
|
|
65
69
|
|
|
66
70
|
@render {
|
|
67
71
|
<div
|
|
68
|
-
|
|
72
|
+
id={$props.id}
|
|
73
|
+
class={`torp-accordion-item ${$props.class ?? ""}`.trim()}
|
|
69
74
|
data-state={$state.dataState}
|
|
70
75
|
data-disabled={$state.dataDisabled}
|
|
71
76
|
>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import getId from
|
|
2
|
-
import { AccordionItemContextName, type AccordionItemContext } from
|
|
1
|
+
import getId from "../utils/getId";
|
|
2
|
+
import { AccordionItemContextName, type AccordionItemContext } from "./AccordionTypes";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A header button for an AccordionItem, which can be used to reveal or hide an associated AccordionContent.
|
|
@@ -12,7 +12,7 @@ export default function AccordionTrigger() {
|
|
|
12
12
|
// Get stuff out of the AccordionItemContext
|
|
13
13
|
const context = $context[AccordionItemContextName] as AccordionItemContext;
|
|
14
14
|
if (!context) {
|
|
15
|
-
throw new Error(
|
|
15
|
+
throw new Error("AccordionTrigger must be contained within an AccordionItem");
|
|
16
16
|
}
|
|
17
17
|
let $state = $context[AccordionItemContextName].state;
|
|
18
18
|
|
|
@@ -42,14 +42,14 @@ export default function AccordionTrigger() {
|
|
|
42
42
|
<button
|
|
43
43
|
type="button"
|
|
44
44
|
id={$props.id}
|
|
45
|
-
class=
|
|
45
|
+
class={`torp-accordion-trigger ${$props.class ?? ""}`.trim()}
|
|
46
46
|
disabled={$state.disabled || $state.parentDisabled}
|
|
47
47
|
aria-expanded={$state.expanded.toString()}
|
|
48
48
|
aria-disabled={$state.disabled}
|
|
49
49
|
aria-controls={$state.contentId}
|
|
50
50
|
data-state={$state.dataState}
|
|
51
51
|
data-disabled={$state.disabled}
|
|
52
|
-
|
|
52
|
+
&ref={button}
|
|
53
53
|
onclick={handleClick}
|
|
54
54
|
onkeydown={handleKey}
|
|
55
55
|
>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import getId from "../utils/getId";
|
|
2
|
+
import { addDocumentEvent, removeDocumentEvent } from "../utils/documentEvents";
|
|
3
|
+
import { trapFocus, type TrapFocusOptions } from "../mount/trapFocus";
|
|
4
|
+
import { PopoverContextName, type PopoverContext } from "../Popover/PopoverTypes";
|
|
5
|
+
import { DialogContextName, type DialogContext, type DialogState } from "./DialogTypes";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Content for a window displayed in a Popover.
|
|
9
|
+
*
|
|
10
|
+
* See [the WAI ARIA guidelines for Dialogs](https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/).
|
|
11
|
+
*
|
|
12
|
+
* The structure of a Dialog is:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* <Dialog>
|
|
16
|
+
* <DialogHeader /> (optional)
|
|
17
|
+
* <DialogBody /> (optional)
|
|
18
|
+
* <DialogFooter> (optional)
|
|
19
|
+
* <DialogButton /> (optional)
|
|
20
|
+
* </DialogFooter>
|
|
21
|
+
* </Dialog>
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @prop {string | undefined} ariaLabel -- An ARIA label for describing the popover to screen readers
|
|
25
|
+
* @prop {boolean} alert -- Whether the dialog should alert the user via the screen reader
|
|
26
|
+
*/
|
|
27
|
+
export default function Dialog($props: any) {
|
|
28
|
+
$props.id ??= getId();
|
|
29
|
+
$props.dialog ??= false;
|
|
30
|
+
|
|
31
|
+
let div: HTMLDivElement;
|
|
32
|
+
|
|
33
|
+
let trapFocusOptions: TrapFocusOptions = {
|
|
34
|
+
focusableElements: []
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Get stuff out of the PopoverContext
|
|
38
|
+
const popoverContext = $context[PopoverContextName] as PopoverContext;
|
|
39
|
+
if (!popoverContext) {
|
|
40
|
+
throw new Error("Dialog must be contained within a Popover");
|
|
41
|
+
}
|
|
42
|
+
const { handleButton, modal } = popoverContext;
|
|
43
|
+
const visible = popoverContext.state.visible;
|
|
44
|
+
|
|
45
|
+
popoverContext.handleClickOutside = handleClickOutside;
|
|
46
|
+
// Set the focusFirstElement method in the PopoverContext so that it can be
|
|
47
|
+
// called after everything has been shown
|
|
48
|
+
popoverContext.focusFirstElement = focusFirstElement;
|
|
49
|
+
|
|
50
|
+
// Set the IDs for using in the Dialog's ARIA attributes
|
|
51
|
+
let $state: DialogState = $watch({
|
|
52
|
+
dialogId: $props.id,
|
|
53
|
+
headerId: "",
|
|
54
|
+
bodyId: "",
|
|
55
|
+
visible
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Set the context to pass down to items
|
|
59
|
+
let context: DialogContext = {
|
|
60
|
+
handleButton,
|
|
61
|
+
state: $state,
|
|
62
|
+
anchorElement: popoverContext.anchorElement
|
|
63
|
+
};
|
|
64
|
+
$context[DialogContextName] = context;
|
|
65
|
+
|
|
66
|
+
let closeOnClick = false;
|
|
67
|
+
|
|
68
|
+
$mount(() => {
|
|
69
|
+
if ($state.visible) {
|
|
70
|
+
// HACK: Because this Dialog could be shown with a click, we need to
|
|
71
|
+
// wait for another mousedown before we close on click. Otherwise
|
|
72
|
+
// the click immediately bubbles to the document. I guess we could
|
|
73
|
+
// require the user to preventDefault after calling showPrompt etc,
|
|
74
|
+
// but that seems very error-prone?
|
|
75
|
+
closeOnClick = false;
|
|
76
|
+
addDocumentEvent("mousedown", handleDocumentMouseDown);
|
|
77
|
+
addDocumentEvent("click", handleDocumentClick);
|
|
78
|
+
} else {
|
|
79
|
+
removeDocumentEvent("mousedown", handleDocumentMouseDown);
|
|
80
|
+
removeDocumentEvent("click", handleDocumentClick);
|
|
81
|
+
// Focus the anchor element per WAI guidelines
|
|
82
|
+
if (context.anchorElement?.focus) {
|
|
83
|
+
context.anchorElement.focus();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function focusFirstElement() {
|
|
89
|
+
if (trapFocusOptions.focusableElements.length) {
|
|
90
|
+
trapFocusOptions.focusableElements[0].focus();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleDocumentMouseDown() {
|
|
95
|
+
closeOnClick = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleDocumentClick(e: MouseEvent) {
|
|
99
|
+
if (
|
|
100
|
+
closeOnClick &&
|
|
101
|
+
div &&
|
|
102
|
+
!div.contains(e.target as HTMLElement) &&
|
|
103
|
+
(!context.anchorElement || !context.anchorElement.contains(e.target as HTMLElement))
|
|
104
|
+
) {
|
|
105
|
+
handleClickOutside(e);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleClickOutside(e: MouseEvent) {
|
|
110
|
+
// Don't allow the user to click outside -- they must press a button or
|
|
111
|
+
// the Esc key
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
if (!modal) {
|
|
114
|
+
popoverContext.state.visible = false;
|
|
115
|
+
context.state.visible = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleKey(e: KeyboardEvent) {
|
|
120
|
+
switch (e.key) {
|
|
121
|
+
case "Esc":
|
|
122
|
+
case "Escape": {
|
|
123
|
+
if (context.cancelButton) {
|
|
124
|
+
context.cancelButton.click();
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "Enter": {
|
|
130
|
+
if (context.confirmButton) {
|
|
131
|
+
context.confirmButton.click();
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@render {
|
|
140
|
+
<div
|
|
141
|
+
id={$props.id}
|
|
142
|
+
class={`torp-dialog ${$props.class ?? ""}`.trim()}
|
|
143
|
+
tabindex="0"
|
|
144
|
+
role={$props.alert ? "alertdialog" : "dialog"}
|
|
145
|
+
aria-modal={modal}
|
|
146
|
+
aria-label={$props.ariaLabel}
|
|
147
|
+
aria-labelledby={!$props.ariaLabel && $state.headerId ? $state.headerId : undefined}
|
|
148
|
+
aria-describedby={$state.bodyId ? $state.bodyId : undefined}
|
|
149
|
+
onkeydown={handleKey}
|
|
150
|
+
:onmount={(el) => trapFocus(el, trapFocusOptions)}
|
|
151
|
+
&ref={div}
|
|
152
|
+
>
|
|
153
|
+
<:slot />
|
|
154
|
+
</div>
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import getId from "../utils/getId";
|
|
2
|
+
import { DialogContextName, type DialogContext } from "./DialogTypes";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The body information for a Dialog. Content in the body will be announced by
|
|
6
|
+
* screen readers when the Dialog is opened, unless ariaAnnounce is false.
|
|
7
|
+
*
|
|
8
|
+
* @prop {boolean} ariaAnnounce -- Whether the body content will be announced by screen readers when the Dialog is opened
|
|
9
|
+
*/
|
|
10
|
+
export default function DialogBody($props: any) {
|
|
11
|
+
$props.id ??= getId();
|
|
12
|
+
$props.ariaAnnounce ??= true;
|
|
13
|
+
|
|
14
|
+
// Get stuff out of the DialogContext
|
|
15
|
+
const context = $context[DialogContextName] as DialogContext;
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("DialogBody must be contained within a Dialog");
|
|
18
|
+
}
|
|
19
|
+
let $state = context.state;
|
|
20
|
+
|
|
21
|
+
// Set the body id for using in the Dialog's ARIA attributes
|
|
22
|
+
if ($props.ariaAnnounce) {
|
|
23
|
+
$state.bodyId = $props.id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@render {
|
|
27
|
+
<div
|
|
28
|
+
id={$props.id}
|
|
29
|
+
class={`torp-dialog-body ${$props.class ?? ""}`.trim()}
|
|
30
|
+
>
|
|
31
|
+
<:slot />
|
|
32
|
+
</div>
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { DialogContextName, type DialogContext } from "./DialogTypes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A button that is placed in a DialogFooter and signals a response by a user.
|
|
5
|
+
*
|
|
6
|
+
* @prop {any | undefined} value -- The value that should be returned when this button is pressed and the Dialog was invoked by calling showContextual, showPrompt or showPopover
|
|
7
|
+
* @prop {"confirm" | "cancel" | undefined} type -- "confirm" if the button should respond to the Enter key or "cancel" if the button should respond to the Escape key
|
|
8
|
+
* @prop {boolean} disabled -- Whether this button is disabled
|
|
9
|
+
*/
|
|
10
|
+
export default function DialogButton($props: any) {
|
|
11
|
+
let button: HTMLButtonElement;
|
|
12
|
+
|
|
13
|
+
// Get stuff out of the DialogContext
|
|
14
|
+
const context = $context[DialogContextName] as DialogContext;
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error("DialogButton must be contained within a Dialog");
|
|
17
|
+
}
|
|
18
|
+
const handleButton = context.handleButton;
|
|
19
|
+
|
|
20
|
+
$mount(() => {
|
|
21
|
+
// Store the confirm or cancel button in the DialogContext
|
|
22
|
+
if ($props.type === "confirm") {
|
|
23
|
+
context.confirmButton = button;
|
|
24
|
+
} else if ($props.type === "cancel") {
|
|
25
|
+
context.cancelButton = button;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function handleClick(e) {
|
|
30
|
+
if ($props.onclick) {
|
|
31
|
+
$props.onclick(e)
|
|
32
|
+
}
|
|
33
|
+
handleButton($props.type, $props.value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@render {
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
id={$props.id}
|
|
40
|
+
class={`torp-dialog-button ${$props.class ?? ""}`.trim()}
|
|
41
|
+
disabled={$props.disabled}
|
|
42
|
+
onclick={handleClick}
|
|
43
|
+
&ref={button}
|
|
44
|
+
>
|
|
45
|
+
<:slot />
|
|
46
|
+
</button>
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The footer for a Dialog, which may contain one or more DialogButtons.
|
|
3
|
+
*/
|
|
4
|
+
export default function DialogFooter($props: any) {
|
|
5
|
+
@render {
|
|
6
|
+
<div
|
|
7
|
+
id={$props.id}
|
|
8
|
+
class={`torp-dialog-footer ${$props.class ?? ""}`.trim()}
|
|
9
|
+
>
|
|
10
|
+
<:slot />
|
|
11
|
+
</div>
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import getId from "../utils/getId";
|
|
2
|
+
import { DialogContextName, type DialogContext } from "./DialogTypes";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The header for a Dialog. Content in the header will be announced by screen
|
|
6
|
+
* readers when the Dialog is opened, unless ariaAnnounce is false.
|
|
7
|
+
*
|
|
8
|
+
* @prop {boolean} ariaAnnounce -- Whether the header will be announced by screen readers when the Dialog is opened
|
|
9
|
+
*/
|
|
10
|
+
export default function DialogHeader($props: any) {
|
|
11
|
+
$props.id ??= getId();
|
|
12
|
+
$props.ariaAnnounce ??= true;
|
|
13
|
+
|
|
14
|
+
// Get stuff out of the DialogContext
|
|
15
|
+
const context = $context[DialogContextName] as DialogContext;
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("DialogHeader must be contained within a Dialog");
|
|
18
|
+
}
|
|
19
|
+
let $state = context.state;
|
|
20
|
+
|
|
21
|
+
// Set the header id for using in the Dialog's ARIA attributes
|
|
22
|
+
if ($props.ariaAnnounce) {
|
|
23
|
+
$state.headerId = $props.id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@render {
|
|
27
|
+
<div
|
|
28
|
+
id={$props.id}
|
|
29
|
+
class={`torp-dialog-footer ${$props.class ?? ""}`.trim()}
|
|
30
|
+
>
|
|
31
|
+
<:slot />
|
|
32
|
+
</div>
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const DialogContextName = "Dialog";
|
|
2
|
+
|
|
3
|
+
export interface DialogContext {
|
|
4
|
+
handleButton: (type: "confirm" | "cancel" | undefined, value?: any) => void;
|
|
5
|
+
state: DialogState;
|
|
6
|
+
anchorElement?: HTMLElement;
|
|
7
|
+
confirmButton?: HTMLButtonElement;
|
|
8
|
+
cancelButton?: HTMLButtonElement;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DialogState {
|
|
12
|
+
dialogId: string;
|
|
13
|
+
headerId: string;
|
|
14
|
+
bodyId: string;
|
|
15
|
+
visible: boolean;
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Dialog from "./Dialog.torp";
|
|
2
|
+
import DialogBody from "./DialogBody.torp";
|
|
3
|
+
import DialogButton from "./DialogButton.torp";
|
|
4
|
+
import DialogFooter from "./DialogFooter.torp";
|
|
5
|
+
import DialogHeader from "./DialogHeader.torp";
|
|
6
|
+
|
|
7
|
+
export { Dialog, DialogHeader, DialogBody, DialogFooter, DialogButton };
|
package/src/Dialog.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Dialog from "./Dialog/Dialog.torp";
|
|
2
|
+
import DialogBody from "./Dialog/DialogBody.torp";
|
|
3
|
+
import DialogButton from "./Dialog/DialogButton.torp";
|
|
4
|
+
import DialogFooter from "./Dialog/DialogFooter.torp";
|
|
5
|
+
import DialogHeader from "./Dialog/DialogHeader.torp";
|
|
6
|
+
|
|
7
|
+
export { Dialog, DialogHeader, DialogBody, DialogFooter, DialogButton };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NavMenuContextName,
|
|
3
|
+
type NavMenuContext,
|
|
4
|
+
type ItemState,
|
|
5
|
+
NavMenuPopoutContextName,
|
|
6
|
+
type NavMenuPopoutContext
|
|
7
|
+
} from "./NavMenuTypes";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A list of links for navigating a website.
|
|
11
|
+
*
|
|
12
|
+
* See [the WAI ARIA guidelines for Menus](https://www.w3.org/WAI/ARIA/apg/patterns/menu/).
|
|
13
|
+
*
|
|
14
|
+
* The structure of a NavMenu is:
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* <NavMenu>
|
|
18
|
+
* <NavMenuLink />
|
|
19
|
+
* <NavMenuSeparator /> (optional)
|
|
20
|
+
* <NavMenuPopout /> (optional)
|
|
21
|
+
* ...
|
|
22
|
+
* </NavMenu>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @prop {string | undefined} ariaLabel -- An ARIA label for describing the menu to screen readers
|
|
26
|
+
* @prop {"horizontal" | "vertical"} orientation -- The orientation of the menu, which affects keyboard navigation
|
|
27
|
+
*/
|
|
28
|
+
export default function NavMenu($props: any) {
|
|
29
|
+
$props.orientation ||= "horizontal";
|
|
30
|
+
|
|
31
|
+
// A collection containing the state and some functions for each item
|
|
32
|
+
let itemStates: ItemState[] = [];
|
|
33
|
+
let focusIndex = -1;
|
|
34
|
+
|
|
35
|
+
// Check whether this is a menu in a popout
|
|
36
|
+
const popoutContext = $context[NavMenuPopoutContextName] as NavMenuPopoutContext;
|
|
37
|
+
const inPopout = !!popoutContext;
|
|
38
|
+
let popoutState = popoutContext?.state;
|
|
39
|
+
|
|
40
|
+
// Set the focusFirstElement method in the PopoverContext(s) so that it can be called after everything has been shown
|
|
41
|
+
if (popoutContext) {
|
|
42
|
+
popoutContext.focusFirstElement = focusFirstElement;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Set the context to pass down to items
|
|
46
|
+
let context: NavMenuContext = {
|
|
47
|
+
handleItemFocus,
|
|
48
|
+
handleItemKey,
|
|
49
|
+
registerItem
|
|
50
|
+
};
|
|
51
|
+
$context[NavMenuContextName] = context;
|
|
52
|
+
|
|
53
|
+
// If the NavMenu is not in a popout, focus its first item. Otherwise the popout will call focusFirstElement, below
|
|
54
|
+
function handleFocus(e: FocusEvent & { currentTarget: EventTarget & HTMLElement }) {
|
|
55
|
+
if (!popoutContext && itemStates.length) {
|
|
56
|
+
itemStates[0].setFocused();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function focusFirstElement() {
|
|
61
|
+
if (itemStates.length) {
|
|
62
|
+
itemStates[0].setFocused();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function registerItem(setFocused: () => void) {
|
|
67
|
+
// Add the item's state to the array and return its index
|
|
68
|
+
const newItemState: ItemState = {
|
|
69
|
+
setFocused
|
|
70
|
+
};
|
|
71
|
+
itemStates.push(newItemState);
|
|
72
|
+
itemStates = itemStates;
|
|
73
|
+
return { index: itemStates.length - 1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleItemFocus(index: number) {
|
|
77
|
+
focusIndex = index;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handleItemKey(e: KeyboardEvent) {
|
|
81
|
+
switch (e.key) {
|
|
82
|
+
case "Esc":
|
|
83
|
+
case "Escape": {
|
|
84
|
+
if (inPopout) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
//setPopoutVisible(false);
|
|
87
|
+
popoutState.visible = false;
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "Right":
|
|
92
|
+
case "ArrowRight": {
|
|
93
|
+
if (orientation === "horizontal") {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
focusItem("next");
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "Left":
|
|
100
|
+
case "ArrowLeft": {
|
|
101
|
+
if (orientation === "horizontal") {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
focusItem("previous");
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "Down":
|
|
108
|
+
case "ArrowDown": {
|
|
109
|
+
if (orientation === "vertical") {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
focusItem("next");
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "Up":
|
|
116
|
+
case "ArrowUp": {
|
|
117
|
+
if (orientation === "vertical") {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
focusItem("previous");
|
|
120
|
+
} else {
|
|
121
|
+
// TODO: This should be based on what side the menu shows on
|
|
122
|
+
// And on the orientation
|
|
123
|
+
if (inPopout) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
popoutState.visible = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case "Home": {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
focusItem("start");
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "End": {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
focusItem("end");
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function focusItem(target: "start" | "previous" | "next" | "end") {
|
|
144
|
+
switch (target) {
|
|
145
|
+
case "start": {
|
|
146
|
+
focusIndex = 0;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "previous": {
|
|
150
|
+
focusIndex = Math.max(0, focusIndex - 1);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "next": {
|
|
154
|
+
focusIndex = Math.min(itemStates.length - 1, focusIndex + 1);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "end": {
|
|
158
|
+
focusIndex = itemStates.length - 1;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
itemStates[focusIndex]?.setFocused();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@render {
|
|
166
|
+
<nav
|
|
167
|
+
id={$props.id}
|
|
168
|
+
class={`torp-nav-menu ${$props.class ?? ""}`.trim()}
|
|
169
|
+
tabindex="0"
|
|
170
|
+
role="menubar"
|
|
171
|
+
aria-label={$props.ariaLabel}
|
|
172
|
+
aria-orientation={$props.orientation}
|
|
173
|
+
onfocus={handleFocus}
|
|
174
|
+
>
|
|
175
|
+
<:slot />
|
|
176
|
+
</nav>
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NavMenuContextName, type NavMenuContext } from "./NavMenuTypes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A button that is placed in a NavMenu to carry out a non-navigation action such as logging out.
|
|
5
|
+
*
|
|
6
|
+
* @prop {boolean} disabled -- Whether this button is disabled
|
|
7
|
+
*/
|
|
8
|
+
export default function NavMenuButton($props: any) {
|
|
9
|
+
/** Set when the item is disabled */
|
|
10
|
+
let dataDisabled = $props.disabled || undefined;
|
|
11
|
+
|
|
12
|
+
let button: HTMLButtonElement;
|
|
13
|
+
|
|
14
|
+
// Get stuff out of the NavMenuContext
|
|
15
|
+
const menuContext = $context[NavMenuContextName] as NavMenuContext;
|
|
16
|
+
if (!menuContext) {
|
|
17
|
+
throw new Error("NavMenuButton must be contained within a NavMenu");
|
|
18
|
+
}
|
|
19
|
+
const { registerItem, handleItemKey, handleItemFocus } = menuContext;
|
|
20
|
+
|
|
21
|
+
// Register this item with the parent NavMenu and store the index
|
|
22
|
+
const { index } = registerItem(setFocused);
|
|
23
|
+
|
|
24
|
+
function setFocused() {
|
|
25
|
+
button.focus();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@render {
|
|
29
|
+
<button
|
|
30
|
+
id={$props.id}
|
|
31
|
+
class={`torp-nav-menu-button ${$props.class ?? ""}`.trim()}
|
|
32
|
+
type="button"
|
|
33
|
+
{disabled}
|
|
34
|
+
role="menuitem"
|
|
35
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
36
|
+
data-disabled={dataDisabled}
|
|
37
|
+
onkeydown
|
|
38
|
+
onkeydown={handleItemKey}
|
|
39
|
+
onclick
|
|
40
|
+
onfocus={() => handleItemFocus(index)}
|
|
41
|
+
&ref={button}
|
|
42
|
+
>
|
|
43
|
+
<:slot />
|
|
44
|
+
</button>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NavMenuContextName, type NavMenuContext } from "./NavMenuTypes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An element of any type that can be placed in a NavMenu.
|
|
5
|
+
*
|
|
6
|
+
* @prop {boolean} disabled -- Whether this button is disabled
|
|
7
|
+
* @prop {HTMLElement} element -- An element that can be focused via keyboard navigation
|
|
8
|
+
*/
|
|
9
|
+
export default function NavMenuElement($props: any) {
|
|
10
|
+
/** Set when the item is disabled */
|
|
11
|
+
let dataDisabled = $props.disabled || undefined;
|
|
12
|
+
|
|
13
|
+
// Get stuff out of the NavMenuContext
|
|
14
|
+
const menuContext = $context[NavMenuContextName] as NavMenuContext;
|
|
15
|
+
if (!menuContext) {
|
|
16
|
+
throw new Error("NavMenuElement must be contained within a NavMenu");
|
|
17
|
+
}
|
|
18
|
+
const { registerItem, handleItemKey, handleItemFocus } = menuContext;
|
|
19
|
+
|
|
20
|
+
// Register this item with the parent NavMenu and store the index
|
|
21
|
+
const { index } = registerItem(setFocused);
|
|
22
|
+
|
|
23
|
+
function setFocused() {
|
|
24
|
+
element.focus();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@render {
|
|
28
|
+
<div
|
|
29
|
+
id={$props.id}
|
|
30
|
+
class={`torp-nav-menu-div ${$props.class ?? ""}`.trim()}
|
|
31
|
+
type="div"
|
|
32
|
+
role="menuitem"
|
|
33
|
+
aria-disabled={$props.disabled ? "true" : undefined}
|
|
34
|
+
data-disabled={dataDisabled}
|
|
35
|
+
onkeydown
|
|
36
|
+
onkeydown={handleItemKey}
|
|
37
|
+
onclick
|
|
38
|
+
onfocus={() => handleItemFocus(index)}
|
|
39
|
+
>
|
|
40
|
+
<:slot />
|
|
41
|
+
</div>
|
|
42
|
+
}
|
|
43
|
+
}
|