@torpor/ui 0.0.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/LICENSE.md +5 -0
- package/README.md +16 -0
- package/dist/Accordion/Accordion.torp +208 -0
- package/dist/Accordion/AccordionContent.torp +36 -0
- package/dist/Accordion/AccordionHeader.torp +33 -0
- package/dist/Accordion/AccordionItem.torp +75 -0
- package/dist/Accordion/AccordionTrigger.torp +59 -0
- package/dist/Accordion/AccordionTypes.ts +32 -0
- package/dist/Accordion/index.cjs +53 -0
- package/dist/Accordion/index.cjs.map +1 -0
- package/dist/Accordion/index.js +22 -0
- package/dist/Accordion/index.js.map +1 -0
- package/dist/metafile-cjs.json +1 -0
- package/dist/metafile-esm.json +1 -0
- package/dist/motion.cjs +34 -0
- package/dist/motion.cjs.map +1 -0
- package/dist/motion.d.cts +5 -0
- package/dist/motion.d.ts +5 -0
- package/dist/motion.js +7 -0
- package/dist/motion.js.map +1 -0
- package/dist/utils/getId.ts +6 -0
- package/package.json +52 -0
- package/scripts/postbuild.ts +62 -0
- package/src/Accordion/Accordion.torp +208 -0
- package/src/Accordion/AccordionContent.torp +36 -0
- package/src/Accordion/AccordionHeader.torp +33 -0
- package/src/Accordion/AccordionItem.torp +75 -0
- package/src/Accordion/AccordionTrigger.torp +59 -0
- package/src/Accordion/AccordionTypes.ts +32 -0
- package/src/Accordion/index.ts +7 -0
- package/src/Accordion.ts +7 -0
- package/src/motion/fade.ts +2 -0
- package/src/motion.ts +3 -0
- package/src/utils/getId.ts +6 -0
- package/test/Accordion/accessibility.test.ts +62 -0
- package/test/Accordion/components/AccordionAccessibility.torp +44 -0
- package/test/Accordion/components/AccordionKeyboard.torp +32 -0
- package/test/Accordion/components/AccordionList.torp +23 -0
- package/test/Accordion/components/AccordionMultiple.torp +32 -0
- package/test/Accordion/components/AccordionSingle.torp +32 -0
- package/test/Accordion/for-items.test.ts +54 -0
- package/test/Accordion/keyboard.test.ts +67 -0
- package/test/Accordion/multiple-item-toggle.test.ts +34 -0
- package/test/Accordion/multiple-item-value.test.ts +17 -0
- package/test/Accordion/single-item-toggle.test.ts +33 -0
- package/test/Accordion/single-item-value.test.ts +17 -0
- package/tsconfig.json +4 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +12 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@torpor/ui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Accessible, unstyled components for use in your Torpor front-end",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./Accordion": {
|
|
11
|
+
"types": "./dist/Accordion/index.d.ts",
|
|
12
|
+
"import": "./dist/Accordion/index.js",
|
|
13
|
+
"require": "./dist/Accordion/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./motion": {
|
|
16
|
+
"types": "./dist/motion.d.ts",
|
|
17
|
+
"import": "./dist/motion.js",
|
|
18
|
+
"require": "./dist/motion.cjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@testing-library/dom": "^10.4.0",
|
|
29
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
30
|
+
"@testing-library/user-event": "^14.5.2",
|
|
31
|
+
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
|
32
|
+
"@types/jsdom": "^21.1.7",
|
|
33
|
+
"@types/node": "^22.10.1",
|
|
34
|
+
"chalk": "^5.3.0",
|
|
35
|
+
"esno": "^4.8.0",
|
|
36
|
+
"fast-glob": "^3.3.2",
|
|
37
|
+
"jsdom": "^25.0.1",
|
|
38
|
+
"tsup": "^8.3.5",
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"vitest": "^2.1.8"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@torpor/unplugin": "^0.0.1",
|
|
44
|
+
"@torpor/view": "^0.0.1"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup",
|
|
48
|
+
"build:fix": "esno scripts/postbuild.ts",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { existsSync, promises as fs, lstatSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// HACK: There is almost certainly a way to do this with standard TS/TSUP/etc
|
|
7
|
+
// but I can't figure it out
|
|
8
|
+
|
|
9
|
+
async function run() {
|
|
10
|
+
const distFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../dist");
|
|
11
|
+
|
|
12
|
+
// Loop through component folders e.g. Accordion
|
|
13
|
+
const componentFolders = (await fs.readdir(distFolder)).filter((f) =>
|
|
14
|
+
lstatSync(join(distFolder, f)).isDirectory(),
|
|
15
|
+
);
|
|
16
|
+
for (const component of componentFolders) {
|
|
17
|
+
console.log(chalk.cyan.inverse(" POST "), `Tidy ${component}`);
|
|
18
|
+
|
|
19
|
+
// Loop through files and change declarations to imports so they get
|
|
20
|
+
// processed by bundlers
|
|
21
|
+
let indexFiles = await fs.readdir(join(distFolder, component));
|
|
22
|
+
for (const file of indexFiles) {
|
|
23
|
+
let jsfile = join(distFolder, component, file);
|
|
24
|
+
let code = await fs.readFile(jsfile, "utf8");
|
|
25
|
+
code = code.replace(/var (.+?) = "\.\.\/(.+?)-(.+?).torp"/g, 'import $1 from "./$2.torp"');
|
|
26
|
+
await fs.writeFile(jsfile, code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Move all *.torp files for the component into this folder
|
|
30
|
+
const relatedFiles = (await fs.readdir(distFolder)).filter(
|
|
31
|
+
(f) => f.startsWith(component) && f !== component,
|
|
32
|
+
);
|
|
33
|
+
for (const file of relatedFiles) {
|
|
34
|
+
const newfile = file.substring(0, file.indexOf("-")) + ".torp";
|
|
35
|
+
await fs.rename(join(distFolder, file), join(distFolder, component, newfile));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Move all *.ts files into this folder
|
|
39
|
+
const srcFolder = resolve(distFolder, "..", "src", component);
|
|
40
|
+
const tsFiles = (await fs.readdir(srcFolder)).filter(
|
|
41
|
+
(f) => f.endsWith(".ts") && f !== "index.ts",
|
|
42
|
+
);
|
|
43
|
+
for (const file of tsFiles) {
|
|
44
|
+
await fs.copyFile(join(srcFolder, file), join(distFolder, component, file));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Move all utils/*.ts files into the dest folder
|
|
49
|
+
if (!existsSync(join(distFolder, "utils"))) {
|
|
50
|
+
await fs.mkdir(join(distFolder, "utils"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const utilsFolder = resolve(distFolder, "..", "src", "utils");
|
|
54
|
+
const utilsFiles = (await fs.readdir(utilsFolder)).filter(
|
|
55
|
+
(f) => f.endsWith(".ts") && f !== "index.ts",
|
|
56
|
+
);
|
|
57
|
+
for (const file of utilsFiles) {
|
|
58
|
+
await fs.copyFile(join(utilsFolder, file), join(distFolder, "utils", file));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
run();
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { AccordionContextName, type AccordionContext, type ItemState } from './AccordionTypes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A vertically stacked set of interactive headings that enable users to reveal
|
|
5
|
+
* or hide associated sections of content.
|
|
6
|
+
*
|
|
7
|
+
* See [the WAI ARIA guidelines for Accordions](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/).
|
|
8
|
+
*
|
|
9
|
+
* The structure of an Accordion is:
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* <Accordion>
|
|
13
|
+
* <AccordionItem>
|
|
14
|
+
* <AccordionHeader>
|
|
15
|
+
* <AccordionTrigger />
|
|
16
|
+
* </AccordionHeader>
|
|
17
|
+
* <AccordionContent />
|
|
18
|
+
* </AccordionItem>
|
|
19
|
+
* </Accordion>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @prop {'single' | 'multiple'} type -- Whether a single AccordionItem or multiple AccordionItems can be expanded at the same time
|
|
23
|
+
* @prop {string | string[]} value -- The value of the expanded AccordionItem, or an array of values if the type is 'multiple'
|
|
24
|
+
* @prop {boolean} disabled -- If set to true, the user cannot interact with any items in the Accordion
|
|
25
|
+
* @prop {(value: string | string[]) => void} [onchange] -- Raised when the expanded item(s) have changed
|
|
26
|
+
*/
|
|
27
|
+
export default function ($props: any) {
|
|
28
|
+
$props.type ||= 'single';
|
|
29
|
+
$props.value ||= $props.type === 'single' ? '' : [];
|
|
30
|
+
|
|
31
|
+
interface State {
|
|
32
|
+
value: string | string[];
|
|
33
|
+
itemStates: ItemState[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let $state: State = $watch({
|
|
37
|
+
value: $props.value,
|
|
38
|
+
itemStates: []
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// This is used to assign automatic indexes to items where needed
|
|
42
|
+
let nextIndex = 0;
|
|
43
|
+
|
|
44
|
+
// Set the context to pass down to items, with functions for each item to call
|
|
45
|
+
let context: AccordionContext = {
|
|
46
|
+
registerItem,
|
|
47
|
+
removeItem,
|
|
48
|
+
toggleItem,
|
|
49
|
+
handleHeaderKey
|
|
50
|
+
};
|
|
51
|
+
$context[AccordionContextName] = context;
|
|
52
|
+
|
|
53
|
+
$run(() => {
|
|
54
|
+
// HACK: This should probably be done with setters?
|
|
55
|
+
let newValue = $state.value;
|
|
56
|
+
|
|
57
|
+
// Get the value
|
|
58
|
+
switch ($props.type) {
|
|
59
|
+
case 'single': {
|
|
60
|
+
// Ensure the value passed in was a string (in case e.g. the user has set values to ints)
|
|
61
|
+
newValue = newValue?.toString();
|
|
62
|
+
for (let item of $state.itemStates) {
|
|
63
|
+
item.expanded = item.value === newValue;
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'multiple': {
|
|
68
|
+
// Ensure the value passed in is an array
|
|
69
|
+
if (!Array.isArray(newValue)) {
|
|
70
|
+
newValue = [newValue];
|
|
71
|
+
}
|
|
72
|
+
for (let item of $state.itemStates) {
|
|
73
|
+
item.expanded = newValue.indexOf(item.value) !== -1;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Set the value
|
|
80
|
+
$state.value = newValue;
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
$run(() => {
|
|
84
|
+
$state.itemStates.forEach((s) => s.parentDisabled = $props.disabled);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Dispatch the change event when the value changes
|
|
88
|
+
// TODO: Should this be a setter instead?
|
|
89
|
+
$run(() => {
|
|
90
|
+
if ($props.onchange) {
|
|
91
|
+
$props.onchange($state.value);
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function registerItem(itemState: ItemState) {
|
|
96
|
+
// If an index wasn't set for the item, set it automatically
|
|
97
|
+
if (itemState.index === -1) {
|
|
98
|
+
itemState.index = nextIndex++;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// If a value wasn't set for the item, set it to the index
|
|
102
|
+
if (!itemState.value) {
|
|
103
|
+
itemState.value = itemState.index.toString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If we have a value, set whether the item should be expanded
|
|
107
|
+
if ($state.value) {
|
|
108
|
+
switch ($props.type) {
|
|
109
|
+
case 'single': {
|
|
110
|
+
itemState.expanded = $state.value === itemState.value;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'multiple': {
|
|
114
|
+
itemState.expanded = $state.value.includes(itemState.value);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Set whether the Accordion itself is disabled
|
|
121
|
+
$state.parentDisabled = $props.disabled
|
|
122
|
+
|
|
123
|
+
// Add the item's state to the array
|
|
124
|
+
$state.itemStates.splice(itemState.index, 0, itemState);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// TODO: I think this should be done by value
|
|
128
|
+
function removeItem(itemIndex: number) {
|
|
129
|
+
// Remove the item and re-index
|
|
130
|
+
let index = $state.itemStates.findIndex((item) => item.index === itemIndex);
|
|
131
|
+
if (index !== -1) {
|
|
132
|
+
$state.itemStates.splice(itemIndex, 1);
|
|
133
|
+
}
|
|
134
|
+
$state.itemStates.forEach((item, i) => (item.index = i));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toggleItem(toggleValue: string) {
|
|
138
|
+
switch ($props.type) {
|
|
139
|
+
case 'single': {
|
|
140
|
+
for (let item of $state.itemStates) {
|
|
141
|
+
// The item is expanded if it has the supplied value (i.e. if it was clicked) and if it
|
|
142
|
+
// wasn't already expanded (i.e. we collapse it if it was expanded and clicked)
|
|
143
|
+
if (item.value === toggleValue) {
|
|
144
|
+
$state.value = item.expanded ? '' : item.value;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'multiple': {
|
|
151
|
+
let newValue = [];
|
|
152
|
+
for (let item of $state.itemStates) {
|
|
153
|
+
// The item is expanded if it has the supplied value (i.e. if it was clicked) or if it
|
|
154
|
+
// was already expanded
|
|
155
|
+
if (
|
|
156
|
+
(item.value === toggleValue && !item.expanded) ||
|
|
157
|
+
(item.value !== toggleValue && item.expanded)
|
|
158
|
+
) {
|
|
159
|
+
newValue.push(item.value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
$state.value = newValue.sort();
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleHeaderKey(headerIndex: number, e: KeyboardEvent) {
|
|
169
|
+
switch (e.key) {
|
|
170
|
+
case 'Up':
|
|
171
|
+
case 'ArrowUp': {
|
|
172
|
+
if (headerIndex > 0) {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
$state.itemStates[headerIndex - 1].setFocused();
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'Down':
|
|
179
|
+
case 'ArrowDown': {
|
|
180
|
+
if (headerIndex < $state.itemStates.length - 1) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
$state.itemStates[headerIndex + 1].setFocused();
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'Home': {
|
|
187
|
+
if (headerIndex > 0) {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
$state.itemStates[0].setFocused();
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'End': {
|
|
194
|
+
if (headerIndex < $state.itemStates.length - 1) {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
$state.itemStates[$state.itemStates.length - 1].setFocused();
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@render {
|
|
204
|
+
<div class="torp-accordion">
|
|
205
|
+
<:slot />
|
|
206
|
+
</div>
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import getId from '../utils/getId';
|
|
2
|
+
import { AccordionItemContextName, type AccordionItemContext } from './AccordionTypes';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Content for an AccordionItem, which can be revealed or hidden by an associated AccordionTrigger.
|
|
6
|
+
*
|
|
7
|
+
* @prop {string} [id] -- An ID for the AccordionContent's div container
|
|
8
|
+
* @prop {boolean} [ariaRegion] -- Whether this content should act as a region. Set this when the content contains heading elements or a nested Accordion
|
|
9
|
+
*/
|
|
10
|
+
export default function AccordionContent($props: any) {
|
|
11
|
+
$props.id ||= getId();
|
|
12
|
+
|
|
13
|
+
const context = $context[AccordionItemContextName] as AccordionItemContext;
|
|
14
|
+
if (!context) {
|
|
15
|
+
throw new Error('AccordionContent must be contained within an AccordionItem');
|
|
16
|
+
}
|
|
17
|
+
let $state = $context[AccordionItemContextName].state
|
|
18
|
+
|
|
19
|
+
// Set the content ID so that it can be used with aria-controls in AccordionTrigger
|
|
20
|
+
$state.contentId = $props.id;
|
|
21
|
+
|
|
22
|
+
@render {
|
|
23
|
+
<div
|
|
24
|
+
id={$props.id}
|
|
25
|
+
class='torp-accordion-content'
|
|
26
|
+
role={$props.ariaRegion ? 'region' : undefined}
|
|
27
|
+
aria-labelledby={$state.headerId}
|
|
28
|
+
data-state={$state.dataState}
|
|
29
|
+
data-disabled={$state.dataDisabled}
|
|
30
|
+
>
|
|
31
|
+
@if ($state.expanded) {
|
|
32
|
+
<:slot />
|
|
33
|
+
}
|
|
34
|
+
</div>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AccordionItemContextName, type AccordionItemContext } from './AccordionTypes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The header for an AccordionItem, which contains the AccordionTrigger.
|
|
5
|
+
*
|
|
6
|
+
* The structure of an AccordionHeader is:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* <AccordionHeader>
|
|
10
|
+
* <AccordionTrigger />
|
|
11
|
+
* </AccordionHeader>
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @prop {'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'} as -- The type of HTML header to use
|
|
15
|
+
*/
|
|
16
|
+
export default function ($props: any) {
|
|
17
|
+
const context = $context[AccordionItemContextName] as AccordionItemContext;
|
|
18
|
+
if (!context) {
|
|
19
|
+
throw new Error('AccordionHeader must be contained within an AccordionItem');
|
|
20
|
+
}
|
|
21
|
+
let $state = $context[AccordionItemContextName].state
|
|
22
|
+
|
|
23
|
+
@render {
|
|
24
|
+
@// TODO: $props.as
|
|
25
|
+
<h4
|
|
26
|
+
class='torp-accordion-header'
|
|
27
|
+
data-state={$state.dataState}
|
|
28
|
+
data-disabled={$state.dataDisabled}
|
|
29
|
+
>
|
|
30
|
+
<:slot />
|
|
31
|
+
</h4>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AccordionContextName, AccordionItemContextName, type AccordionContext, type AccordionItemContext } from './AccordionTypes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An item in an Accordion, which must consist of an AccordionTrigger and an AccordionContent.
|
|
5
|
+
*
|
|
6
|
+
* The structure of an AccordionItem is:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* <AccordionItem>
|
|
10
|
+
* <AccordionHeader>
|
|
11
|
+
* <AccordionTrigger />
|
|
12
|
+
* </AccordionHeader>
|
|
13
|
+
* <AccordionContent />
|
|
14
|
+
* </AccordionItem>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @prop {number} [index] -- The index of this item in the Accordion. Set this if you will be inserting, removing or sorting items. If you don't set this, it will be set automatically
|
|
18
|
+
* @prop {string} [value] -- A value for the item. Set this if you need more control over which item(s) are expanded. If you don't set this, the value will be set to the item's initial index
|
|
19
|
+
* @prop {boolean} [disabled] -- If set to true, the user cannot interact with this AccordionItem
|
|
20
|
+
* @prop {(boolean) => void} [ontoggle] -- Raised when this item is opened or closed
|
|
21
|
+
*/
|
|
22
|
+
export default function AccordionItem($props: any) {
|
|
23
|
+
let $state: ItemState = $watch({
|
|
24
|
+
index: -1,
|
|
25
|
+
value: "",
|
|
26
|
+
expanded: false,
|
|
27
|
+
disabled: $props.disabled,
|
|
28
|
+
parentDisabled: false,
|
|
29
|
+
get dataState() {
|
|
30
|
+
return this.expanded ? 'open' : 'closed';
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Get stuff out of the AccordionContext
|
|
35
|
+
const context = $context[AccordionContextName] as AccordionContext;
|
|
36
|
+
if (!context) {
|
|
37
|
+
throw new Error('AccordionItem must be contained within an Accordion');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Put stuff into an AccordionItemContext
|
|
41
|
+
const itemContext: AccordionItemContext = {
|
|
42
|
+
toggleItem: context.toggleItem,
|
|
43
|
+
handleHeaderKey: context.handleHeaderKey,
|
|
44
|
+
state: $state,
|
|
45
|
+
};
|
|
46
|
+
$context[AccordionItemContextName] = itemContext;
|
|
47
|
+
|
|
48
|
+
// Register this item with the parent Accordion, which will handle toggling expanded for all items
|
|
49
|
+
context.registerItem($state);
|
|
50
|
+
|
|
51
|
+
// Remove the item from the parent Accordion when this item is destroyed
|
|
52
|
+
$run(() => {
|
|
53
|
+
return () => {
|
|
54
|
+
context.removeItem($state.index);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Dispatch the toggle event when the value of expanded changes
|
|
59
|
+
// TODO: Should this be a setter instead?
|
|
60
|
+
$run(() => {
|
|
61
|
+
if ($props.ontoggle) {
|
|
62
|
+
$props.ontoggle($state.expanded);
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
@render {
|
|
67
|
+
<div
|
|
68
|
+
class='torp-accordion-item'
|
|
69
|
+
data-state={$state.dataState}
|
|
70
|
+
data-disabled={$state.dataDisabled}
|
|
71
|
+
>
|
|
72
|
+
<:slot />
|
|
73
|
+
</div>
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import getId from '../utils/getId';
|
|
2
|
+
import { AccordionItemContextName, type AccordionItemContext } from './AccordionTypes';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A header button for an AccordionItem, which can be used to reveal or hide an associated AccordionContent.
|
|
6
|
+
*/
|
|
7
|
+
export default function AccordionTrigger() {
|
|
8
|
+
let button: HTMLButtonElement;
|
|
9
|
+
|
|
10
|
+
$props.id ||= getId();
|
|
11
|
+
|
|
12
|
+
// Get stuff out of the AccordionItemContext
|
|
13
|
+
const context = $context[AccordionItemContextName] as AccordionItemContext;
|
|
14
|
+
if (!context) {
|
|
15
|
+
throw new Error('AccordionTrigger must be contained within an AccordionItem');
|
|
16
|
+
}
|
|
17
|
+
let $state = $context[AccordionItemContextName].state;
|
|
18
|
+
|
|
19
|
+
// Set the header ID so that it can be used with aria-labelledby in AccordionContent
|
|
20
|
+
$state.headerId = $props.id;
|
|
21
|
+
|
|
22
|
+
// Let the item know how to set the button's focus
|
|
23
|
+
$state.setFocused = setFocused;
|
|
24
|
+
|
|
25
|
+
// This function is called by the parent Accordion to focus this item's button
|
|
26
|
+
function setFocused() {
|
|
27
|
+
button.focus();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleClick() {
|
|
31
|
+
// Pass the value to the toggleItem method in the parent Accordion which will handle toggling
|
|
32
|
+
// the value of expanded for all items (e.g. to close other items if necessary)
|
|
33
|
+
context.toggleItem($state.value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleKey(e: KeyboardEvent) {
|
|
37
|
+
// Pass the key press up to the parent Accordion which will handle setting the focused button
|
|
38
|
+
context.handleHeaderKey($state.index, e);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@render {
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
id={$props.id}
|
|
45
|
+
class='torp-accordion-button'
|
|
46
|
+
disabled={$state.disabled || $state.parentDisabled}
|
|
47
|
+
aria-expanded={$state.expanded.toString()}
|
|
48
|
+
aria-disabled={$state.disabled}
|
|
49
|
+
aria-controls={$state.contentId}
|
|
50
|
+
data-state={$state.dataState}
|
|
51
|
+
data-disabled={$state.disabled}
|
|
52
|
+
:self={button}
|
|
53
|
+
onclick={handleClick}
|
|
54
|
+
onkeydown={handleKey}
|
|
55
|
+
>
|
|
56
|
+
<:slot />
|
|
57
|
+
</button>
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const AccordionContextName = "Accordion";
|
|
2
|
+
export const AccordionItemContextName = "AccordionItem";
|
|
3
|
+
|
|
4
|
+
export interface AccordionContext {
|
|
5
|
+
/** Called from an AccordionItem when it is added */
|
|
6
|
+
registerItem: (state: ItemState, setFocused: () => void) => void;
|
|
7
|
+
/** Called from an AccordionItem when it is removed */
|
|
8
|
+
removeItem: (index: number) => void;
|
|
9
|
+
/** Called from an AccordionItem when it is toggled */
|
|
10
|
+
toggleItem: (value: string) => void;
|
|
11
|
+
/** Called from an AccordionHeader when it receives a keyboard event */
|
|
12
|
+
handleHeaderKey: (index: number, e: KeyboardEvent) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AccordionItemContext {
|
|
16
|
+
/** Called when an AccordionItem is toggled */
|
|
17
|
+
toggleItem: (value: string) => void;
|
|
18
|
+
/** Called when an AccordionHeader receives a keyboard event */
|
|
19
|
+
handleHeaderKey: (index: number, e: KeyboardEvent) => void;
|
|
20
|
+
/** The reactive state for an AccordionItem */
|
|
21
|
+
state: ItemState;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ItemState {
|
|
25
|
+
index: number;
|
|
26
|
+
value: string;
|
|
27
|
+
expanded: boolean;
|
|
28
|
+
disabled: boolean;
|
|
29
|
+
parentDisabled: boolean;
|
|
30
|
+
/** Called in the Accordion when a header needs to be focused */
|
|
31
|
+
setFocused: () => void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Accordion from "./Accordion.torp";
|
|
2
|
+
import AccordionContent from "./AccordionContent.torp";
|
|
3
|
+
import AccordionHeader from "./AccordionHeader.torp";
|
|
4
|
+
import AccordionItem from "./AccordionItem.torp";
|
|
5
|
+
import AccordionTrigger from "./AccordionTrigger.torp";
|
|
6
|
+
|
|
7
|
+
export { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent };
|
package/src/Accordion.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Accordion from "./Accordion/Accordion.torp";
|
|
2
|
+
import AccordionContent from "./Accordion/AccordionContent.torp";
|
|
3
|
+
import AccordionHeader from "./Accordion/AccordionHeader.torp";
|
|
4
|
+
import AccordionItem from "./Accordion/AccordionItem.torp";
|
|
5
|
+
import AccordionTrigger from "./Accordion/AccordionTrigger.torp";
|
|
6
|
+
|
|
7
|
+
export { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent };
|
package/src/motion.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { queryByText } from "@testing-library/dom";
|
|
2
|
+
import "@testing-library/jest-dom/vitest";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { mount } from "@torpor/view";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import AccordionAccessibility from "./components/AccordionAccessibility.torp";
|
|
7
|
+
|
|
8
|
+
describe("Accordion", () => {
|
|
9
|
+
it("Accessibility", async () => {
|
|
10
|
+
const user = userEvent.setup();
|
|
11
|
+
|
|
12
|
+
const container = document.createElement("div");
|
|
13
|
+
document.body.appendChild(container);
|
|
14
|
+
mount(container, AccordionAccessibility, { value: ["1", "3"] });
|
|
15
|
+
|
|
16
|
+
// Tests from https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
|
|
17
|
+
|
|
18
|
+
// The title of each accordion header is contained in an element with role button
|
|
19
|
+
expect(queryByText(container, "Role Header")?.tagName.toLowerCase()).toMatch("button");
|
|
20
|
+
|
|
21
|
+
// Each accordion header button is wrapped in an element with role heading that has a value set
|
|
22
|
+
// for aria-level that is appropriate for the information architecture of the page
|
|
23
|
+
// * If the native host language has an element with an implicit heading and aria-level, such
|
|
24
|
+
// as an HTML heading tag, a native host language element may be used
|
|
25
|
+
// * The button element is the only element inside the heading element. That is, if there are
|
|
26
|
+
// other visually persistent elements, they are not included inside the heading element
|
|
27
|
+
// NOTE: This is kind of up to the user -- should we check this at runtime?
|
|
28
|
+
expect(queryByText(container, "Role Header")?.parentElement?.tagName.toLowerCase()).toMatch(
|
|
29
|
+
"h4",
|
|
30
|
+
);
|
|
31
|
+
expect(
|
|
32
|
+
queryByText(container, "Role Header")?.parentElement?.childElementCount.toString(),
|
|
33
|
+
).toMatch("1");
|
|
34
|
+
|
|
35
|
+
// If the accordion panel associated with an accordion header is visible, the header button
|
|
36
|
+
// element has aria-expanded set to true. If the panel is not visible, aria-expanded is set
|
|
37
|
+
// to false
|
|
38
|
+
expect(queryByText(container, "Open Header")).toHaveAttribute("aria-expanded", "true");
|
|
39
|
+
expect(queryByText(container, "Closed Header")).toHaveAttribute("aria-expanded", "false");
|
|
40
|
+
|
|
41
|
+
// The accordion header button element has aria-controls set to the ID of the element containing
|
|
42
|
+
// the accordion panel content
|
|
43
|
+
const headerId = queryByText(container, "Open Header")?.id;
|
|
44
|
+
const contentId = queryByText(container, "Open Content")?.id;
|
|
45
|
+
expect(queryByText(container, "Open Header")).toHaveAttribute("aria-controls", contentId);
|
|
46
|
+
|
|
47
|
+
// If the accordion panel associated with an accordion header is visible, and if the accordion
|
|
48
|
+
// does not permit the panel to be collapsed, the header button element has aria-disabled set
|
|
49
|
+
// to true
|
|
50
|
+
expect(queryByText(container, "Disabled Header")).toHaveAttribute("aria-disabled", "true");
|
|
51
|
+
|
|
52
|
+
// Optionally, each element that serves as a container for panel content has role region and
|
|
53
|
+
// aria-labelledby with a value that refers to the button that controls display of the panel
|
|
54
|
+
// * Avoid using the region role in circumstances that create landmark region proliferation,
|
|
55
|
+
// e.g., in an accordion that contains more than approximately 6 panels that can be expanded
|
|
56
|
+
// at the same time
|
|
57
|
+
// * Role region is especially helpful to the perception of structure by screen reader users
|
|
58
|
+
// when panels contain heading elements or a nested accordion
|
|
59
|
+
expect(queryByText(container, "Open Content")).toHaveAttribute("aria-labelledby", headerId);
|
|
60
|
+
expect(queryByText(container, "Region Content")).toHaveAttribute("role", "region");
|
|
61
|
+
});
|
|
62
|
+
});
|