aria-voyager
Advanced tools
Comparing version
@@ -8,2 +8,3 @@ interface EmitterOptions<T> { | ||
itemActivated(item?: Item): void; | ||
dispose?(): void; | ||
} | ||
@@ -32,3 +33,3 @@ | ||
setControl(control: Control): void; | ||
teardown?(): void; | ||
dispose?(): void; | ||
} | ||
@@ -45,5 +46,7 @@ | ||
} | ||
type Orientation = 'horizontal' | 'vertical'; | ||
interface Options { | ||
multiple: boolean; | ||
disabled: boolean; | ||
orientation: Orientation; | ||
} | ||
@@ -73,2 +76,3 @@ type Item = HTMLElement; | ||
protected registerNavigationPatterns(patterns: NavigationPattern[]): void; | ||
dispose(): void; | ||
private handleEvent; | ||
@@ -138,2 +142,33 @@ readOptions(): void; | ||
interface SelectionBehavior { | ||
/** | ||
* Selection behavior: | ||
* | ||
* - `automatic`: active item becomes selected item | ||
* - `manual`: user must select manually with spacebar | ||
* | ||
* @defaultValue `automatic` | ||
*/ | ||
singleSelection?: 'automatic' | 'manual'; | ||
} | ||
type TablistBehavior = SelectionBehavior; | ||
interface TablistOptions { | ||
updater?: UpdateStrategy; | ||
emitter?: EmitStrategy; | ||
behavior?: TablistBehavior; | ||
} | ||
declare class Tablist extends Control { | ||
#private; | ||
protected focusStrategy: RovingTabindexStrategy; | ||
get selection(): HTMLElement[]; | ||
get activeItem(): HTMLElement | undefined; | ||
get prevActiveItem(): HTMLElement | undefined; | ||
constructor(element: HTMLElement, options?: TablistOptions); | ||
readItems(): void; | ||
readSelection(): void; | ||
readOptions(): void; | ||
ensureSelection(): void; | ||
} | ||
declare class IndexEmitStrategy implements EmitStrategy { | ||
@@ -145,2 +180,3 @@ private control; | ||
itemActivated(item: Item): void | undefined; | ||
dispose(): void; | ||
} | ||
@@ -154,2 +190,3 @@ | ||
itemActivated(item: Item): void | undefined; | ||
dispose(): void; | ||
} | ||
@@ -163,3 +200,3 @@ | ||
setControl(control: Control): void; | ||
teardown(): void; | ||
dispose(): void; | ||
} | ||
@@ -174,4 +211,5 @@ | ||
updateOptions(): void; | ||
dispose(): void; | ||
} | ||
export { Control, DomObserverUpdateStrategy, type EmitStrategy, IndexEmitStrategy, ItemEmitStrategy, Listbox, Menu, ReactiveUpdateStrategy, type UpdateStrategy }; | ||
export { Control, DomObserverUpdateStrategy, type EmitStrategy, IndexEmitStrategy, ItemEmitStrategy, Listbox, Menu, type Orientation, ReactiveUpdateStrategy, Tablist, type TablistBehavior, type TablistOptions, type UpdateStrategy }; |
@@ -25,3 +25,3 @@ // src/controls/-utils.ts | ||
} | ||
const changedItems = changes.every((change) => change.type === "childList"); | ||
const changedItems = changes.some((change) => change.type === "childList"); | ||
const itemAttributes = ["aria-disabled"]; | ||
@@ -35,3 +35,5 @@ const changedItemAttributes = changes.some( | ||
const optionAttributes = [...this.control.optionAttributes, "aria-disabled"]; | ||
const changedOptions = changes.length === 1 && changes[0].type === "attributes" && optionAttributes.includes(changes[0].attributeName); | ||
const changedOptions = changes.some( | ||
(change) => change.target === this.control.element && change.type === "attributes" && optionAttributes.includes(change.attributeName) | ||
); | ||
if (changedOptions) { | ||
@@ -62,3 +64,4 @@ this.control.readOptions(); | ||
} | ||
teardown() { | ||
dispose() { | ||
this.control = void 0; | ||
this.observer.disconnect(); | ||
@@ -106,3 +109,4 @@ } | ||
multiple: false, | ||
disabled: false | ||
disabled: false, | ||
orientation: "horizontal" | ||
}; | ||
@@ -120,6 +124,7 @@ navigationPatterns = []; | ||
setEmitStrategy(emitter) { | ||
this.emitter?.dispose?.(); | ||
this.emitter = emitter; | ||
} | ||
setUpdateStrategy(updater) { | ||
this.updater.teardown?.(); | ||
this.updater.dispose?.(); | ||
this.updater = updater; | ||
@@ -134,2 +139,10 @@ } | ||
} | ||
dispose() { | ||
this.updater.dispose?.(); | ||
this.emitter?.dispose?.(); | ||
const eventNames = new Set(this.navigationPatterns.map((p) => p.eventListeners ?? []).flat()); | ||
for (const eventName of eventNames) { | ||
this.element.removeEventListener(eventName, this.handleEvent.bind(this)); | ||
} | ||
} | ||
handleEvent(event) { | ||
@@ -148,2 +161,3 @@ if (this.options.disabled) { | ||
this.options.disabled = this.element.hasAttribute("aria-disabled") && this.element.getAttribute("aria-disabled") === "true" || false; | ||
this.options.orientation = this.element.hasAttribute("aria-orientation") ? this.element.getAttribute("aria-orientation") : "horizontal"; | ||
} | ||
@@ -290,3 +304,3 @@ readItems() { | ||
const { event } = bag; | ||
const item = asItemOf(event.target, this.control); | ||
const item = event.composedPath().find((elem) => asItemOf(elem, this.control)); | ||
return { | ||
@@ -391,5 +405,12 @@ ...bag, | ||
import isEqual from "lodash.isequal"; | ||
var DEFAULT_BEHAVIOR = { | ||
singleSelection: "automatic" | ||
}; | ||
var SelectionStrategy = class { | ||
constructor(control) { | ||
constructor(control, behavior) { | ||
this.control = control; | ||
this.behavior = { | ||
...DEFAULT_BEHAVIOR, | ||
...behavior ?? {} | ||
}; | ||
this.readSelection(); | ||
@@ -403,2 +424,3 @@ } | ||
shiftItem; | ||
behavior; | ||
matches(event) { | ||
@@ -419,3 +441,3 @@ return this.control.items.length > 0 && this.eventListeners.includes(event.type); | ||
} else if (event instanceof KeyboardEvent) { | ||
this.handleKeyboard(bag); | ||
this.handleKeyboard(event, bag.item); | ||
} else if (event.type === "change") { | ||
@@ -459,4 +481,3 @@ this.handleChange(); | ||
} | ||
handleKeyboard(bag) { | ||
const { event, item } = bag; | ||
handleKeyboard(event, item) { | ||
if (event.type === "keydown") { | ||
@@ -466,2 +487,3 @@ if (item) { | ||
} | ||
this.handleKeys(event); | ||
this.handleKeyCombinations(event); | ||
@@ -478,5 +500,13 @@ } else if (event.type === "keyup" && !event.shiftKey) { | ||
} else { | ||
this.selectSingle(item); | ||
if (this.behavior.singleSelection === "automatic" || // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
this.behavior.singleSelection === "manual" && event.key === " ") { | ||
this.selectSingle(item); | ||
} | ||
} | ||
} | ||
handleKeys(event) { | ||
if (this.behavior.singleSelection === "manual" && event.key === " " && this.control.activeItem) { | ||
this.selectSingle(this.control.activeItem); | ||
} | ||
} | ||
/** | ||
@@ -852,2 +882,65 @@ * Handles special keyboard control cases, such as handling the spacebar key | ||
// src/controls/tablist.ts | ||
var Tablist = class extends Control { | ||
#selectionStrategy; | ||
focusStrategy = new RovingTabindexStrategy(this); | ||
#nextNavigation = new NextNavigation(this, "ArrowRight"); | ||
#prevNavigation = new PreviousNavigation(this, "ArrowLeft"); | ||
get selection() { | ||
return this.#selectionStrategy.selection; | ||
} | ||
get activeItem() { | ||
return this.focusStrategy.activeItem; | ||
} | ||
get prevActiveItem() { | ||
return this.focusStrategy.prevActiveItem; | ||
} | ||
constructor(element, options) { | ||
super(element, { | ||
capabilities: { | ||
singleSelection: true, | ||
multiSelection: false | ||
}, | ||
optionAttributes: ["aria-orientation"], | ||
...options | ||
}); | ||
this.#selectionStrategy = new SelectionStrategy(this, options?.behavior ?? {}); | ||
this.registerNavigationPatterns([ | ||
this.#nextNavigation, | ||
this.#prevNavigation, | ||
new HomeNavigation(this), | ||
new EndNavigation(this), | ||
new PointerNavigation(this), | ||
this.focusStrategy, | ||
this.#selectionStrategy | ||
]); | ||
element.role = "tablist"; | ||
this.readOptions(); | ||
this.readItems(); | ||
} | ||
readItems() { | ||
this.items = [...this.element.querySelectorAll('[role="tab"]')]; | ||
this.#selectionStrategy.select( | ||
this.selection.filter((selection) => this.items.includes(selection)) | ||
); | ||
this.focusStrategy.updateItems(); | ||
this.ensureSelection(); | ||
} | ||
readSelection() { | ||
this.#selectionStrategy.readSelection(); | ||
} | ||
readOptions() { | ||
super.readOptions(); | ||
this.#nextNavigation.keyOrKeys = this.options.orientation === "horizontal" ? "ArrowRight" : "ArrowDown"; | ||
this.#prevNavigation.keyOrKeys = this.options.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp"; | ||
this.focusStrategy.updateItems(); | ||
} | ||
ensureSelection() { | ||
if (this.selection.length === 0 && this.items.length > 0) { | ||
this.focusStrategy.activateItem(this.items[0]); | ||
this.#selectionStrategy.select([this.items[0]]); | ||
} | ||
} | ||
}; | ||
// src/emit-strategies/index-emit-strategy.ts | ||
@@ -858,2 +951,3 @@ var IndexEmitStrategy = class { | ||
this.options = options; | ||
this.control = control; | ||
this.control.setEmitStrategy(this); | ||
@@ -869,2 +963,5 @@ } | ||
} | ||
dispose() { | ||
this.control = void 0; | ||
} | ||
}; | ||
@@ -877,2 +974,3 @@ | ||
this.options = options; | ||
this.control = control; | ||
this.control.setEmitStrategy(this); | ||
@@ -886,2 +984,5 @@ } | ||
} | ||
dispose() { | ||
this.control = void 0; | ||
} | ||
}; | ||
@@ -906,2 +1007,5 @@ | ||
} | ||
dispose() { | ||
this.control = void 0; | ||
} | ||
}; | ||
@@ -915,3 +1019,4 @@ export { | ||
Menu, | ||
ReactiveUpdateStrategy | ||
ReactiveUpdateStrategy, | ||
Tablist | ||
}; |
{ | ||
"name": "aria-voyager", | ||
"version": "0.0.4", | ||
"version": "0.1.0", | ||
"description": "A framework agnostic / universal package that implements navigation patterns for various aria roles and features", | ||
@@ -29,6 +29,6 @@ "author": "gossi", | ||
"@gossi/config-prettier": "0.9.1", | ||
"@hokulea/core": "^0.1.2", | ||
"@hokulea/core": "^0.2.0", | ||
"@hokulea/theme-moana": "^0.0.3", | ||
"@swc/cli": "0.4.0", | ||
"@swc/core": "1.7.40", | ||
"@swc/cli": "0.5.2", | ||
"@swc/core": "1.10.0", | ||
"@testing-library/dom": "10.4.0", | ||
@@ -39,13 +39,14 @@ "@types/css-modules": "1.0.5", | ||
"@types/uuid": "10.0.0", | ||
"@vitest/browser": "^2.1.3", | ||
"@vitest/coverage-istanbul": "^2.1.3", | ||
"@vitest/ui": "^2.1.3", | ||
"concurrently": "9.0.1", | ||
"@vitest/browser": "^2.1.8", | ||
"@vitest/coverage-istanbul": "^2.1.8", | ||
"@vitest/ui": "^2.1.8", | ||
"concurrently": "9.1.0", | ||
"eslint": "8.57.1", | ||
"prettier": "3.3.3", | ||
"playwright": "^1.49.0", | ||
"prettier": "3.4.2", | ||
"tsup": "8.3.5", | ||
"typescript": "5.6.3", | ||
"vite": "5.4.10", | ||
"vitest": "^2.1.3", | ||
"webdriverio": "^9.2.1" | ||
"typescript": "5.7.2", | ||
"vite": "^6.0.3", | ||
"vitest": "^2.1.8", | ||
"webdriverio": "^9.4.1" | ||
}, | ||
@@ -72,5 +73,5 @@ "engines": { | ||
"lint:types": "tsc --noEmit", | ||
"test": "vitest run --browser.headless --browser.provider=webdriverio", | ||
"test": "vitest run --browser.headless --browser.provider=playwright", | ||
"test:ui": "vitest" | ||
} | ||
} |
@@ -107,2 +107,53 @@ # aria-voyager | ||
#### `Tablist` | ||
Bring your own markup in at first, here is an example markup for a list: | ||
```html | ||
<div> | ||
<ul role="tablist"> | ||
<li role="tab" id="tab-1" aria-controls="panel-1">Tab 1</li> | ||
<li role="tab" id="tab-2" aria-controls="panel-2">Tab 2</li> | ||
<li role="tab" id="tab-3" aria-controls="panel-3">Tab 3</li> | ||
</ul> | ||
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1"> | ||
Contents Panel 1 | ||
</div> | ||
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2"> | ||
Contents Panel 2 | ||
</div> | ||
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3"> | ||
Contents Panel 3 | ||
</div> | ||
<div> | ||
``` | ||
To make it interactive, create a new `Tablist` instance pointing it at your `tablist` element. | ||
```ts | ||
import { Tablist } from 'aria-voyager'; | ||
const tablistElement = document.querySelector('[role="tablist"]'); | ||
new Tablist(tablistElement); | ||
``` | ||
That is already enough to start making your listbox interactive. It will read the options from the provided HTML. | ||
`Tablist` accepts options as second parameter: | ||
```ts | ||
import type { EmitStrategy, UpdateStrategy, TablistBehavior } from 'aria-voyager'; | ||
interface TablistOptions { | ||
updater?: UpdateStrategy; | ||
emitter?: EmitStrategy; | ||
behavior?: TablistBehavior; | ||
} | ||
``` | ||
See [updater](#updater) and [emitter](#emitter). | ||
### Strategies | ||
@@ -109,0 +160,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
82021
14.29%2199
12.54%196
35.17%23
4.55%