@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.
Files changed (49) hide show
  1. package/LICENSE.md +5 -0
  2. package/README.md +16 -0
  3. package/dist/Accordion/Accordion.torp +208 -0
  4. package/dist/Accordion/AccordionContent.torp +36 -0
  5. package/dist/Accordion/AccordionHeader.torp +33 -0
  6. package/dist/Accordion/AccordionItem.torp +75 -0
  7. package/dist/Accordion/AccordionTrigger.torp +59 -0
  8. package/dist/Accordion/AccordionTypes.ts +32 -0
  9. package/dist/Accordion/index.cjs +53 -0
  10. package/dist/Accordion/index.cjs.map +1 -0
  11. package/dist/Accordion/index.js +22 -0
  12. package/dist/Accordion/index.js.map +1 -0
  13. package/dist/metafile-cjs.json +1 -0
  14. package/dist/metafile-esm.json +1 -0
  15. package/dist/motion.cjs +34 -0
  16. package/dist/motion.cjs.map +1 -0
  17. package/dist/motion.d.cts +5 -0
  18. package/dist/motion.d.ts +5 -0
  19. package/dist/motion.js +7 -0
  20. package/dist/motion.js.map +1 -0
  21. package/dist/utils/getId.ts +6 -0
  22. package/package.json +52 -0
  23. package/scripts/postbuild.ts +62 -0
  24. package/src/Accordion/Accordion.torp +208 -0
  25. package/src/Accordion/AccordionContent.torp +36 -0
  26. package/src/Accordion/AccordionHeader.torp +33 -0
  27. package/src/Accordion/AccordionItem.torp +75 -0
  28. package/src/Accordion/AccordionTrigger.torp +59 -0
  29. package/src/Accordion/AccordionTypes.ts +32 -0
  30. package/src/Accordion/index.ts +7 -0
  31. package/src/Accordion.ts +7 -0
  32. package/src/motion/fade.ts +2 -0
  33. package/src/motion.ts +3 -0
  34. package/src/utils/getId.ts +6 -0
  35. package/test/Accordion/accessibility.test.ts +62 -0
  36. package/test/Accordion/components/AccordionAccessibility.torp +44 -0
  37. package/test/Accordion/components/AccordionKeyboard.torp +32 -0
  38. package/test/Accordion/components/AccordionList.torp +23 -0
  39. package/test/Accordion/components/AccordionMultiple.torp +32 -0
  40. package/test/Accordion/components/AccordionSingle.torp +32 -0
  41. package/test/Accordion/for-items.test.ts +54 -0
  42. package/test/Accordion/keyboard.test.ts +67 -0
  43. package/test/Accordion/multiple-item-toggle.test.ts +34 -0
  44. package/test/Accordion/multiple-item-value.test.ts +17 -0
  45. package/test/Accordion/single-item-toggle.test.ts +33 -0
  46. package/test/Accordion/single-item-value.test.ts +17 -0
  47. package/tsconfig.json +4 -0
  48. package/tsup.config.ts +14 -0
  49. 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 };
@@ -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 };
@@ -0,0 +1,2 @@
1
+ const animationFrames = [{ opacity: 0 }, { opacity: 1 }];
2
+ export default animationFrames;
package/src/motion.ts ADDED
@@ -0,0 +1,3 @@
1
+ import fade from "./motion/fade";
2
+
3
+ export { fade };
@@ -0,0 +1,6 @@
1
+ let nextId = 0;
2
+
3
+ /** Returns a unique ID for use by components that need one, e.g. for accessibility */
4
+ export default function getId(): string {
5
+ return `torp-id-${nextId++}`;
6
+ }
@@ -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
+ });