Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/CollapsableExtLink.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CollapsableItem } from './CollapsableItem'
import { Collapsable } from './Collapsable'
import { AttributeSnapshot } from './utils'

export class CollapsableExtLink {
private readonly collapsable: Collapsable
Expand All @@ -10,6 +11,8 @@ export class CollapsableExtLink {
private listener: EventListener | undefined
private ariaPerRole!: 'aria-expanded' | 'aria-selected' // always set by `this.prepareDOM()` called from constructor

private originalAttributes = new AttributeSnapshot()

public constructor(
collapsable: Collapsable,
link: HTMLAnchorElement | HTMLButtonElement,
Expand All @@ -29,9 +32,11 @@ export class CollapsableExtLink {
}

private prepareDOM(): void {
this.originalAttributes.remember(this.extLink, 'aria-controls')
this.extLink.setAttribute('aria-controls', this.collapsableItem.boxElements.map((box) => box.id).join(' '))

if (this.extLink instanceof HTMLAnchorElement && !this.extLink.role) {
this.originalAttributes.remember(this.extLink, 'role')
this.extLink.role = 'button'
}

Expand All @@ -41,6 +46,7 @@ export class CollapsableExtLink {
this.ariaPerRole = 'aria-selected'
}

this.originalAttributes.remember(this.extLink, this.ariaPerRole)
this.extLink.setAttribute(this.ariaPerRole, String(this.collapsableItem.isExpanded))
}

Expand Down Expand Up @@ -72,9 +78,10 @@ export class CollapsableExtLink {
public destroy(): void {
if (this.listener) {
this.extLink.removeEventListener('click', this.listener)
this.extLink.removeAttribute('aria-controls')
this.extLink.removeAttribute(this.ariaPerRole)
this.extLink.removeAttribute('role')
this.originalAttributes.restore(this.extLink, 'aria-controls')
this.originalAttributes.restore(this.extLink, this.ariaPerRole)
this.originalAttributes.restore(this.extLink, 'role')
this.originalAttributes.clear()
}
}
}
25 changes: 20 additions & 5 deletions src/CollapsableItem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Collapsable, CollapsableEvent } from './Collapsable'
import { getUid } from './utils'
import { AttributeSnapshot, getUid } from './utils'

type CollapsableItemAction = 'expand' | 'collapse'
type CollapsableItemEvents =
Expand Down Expand Up @@ -40,6 +40,8 @@ export class CollapsableItem {

private listenersMap: ListenersMapItem[] = []

private originalAttributes = new AttributeSnapshot()

public constructor(collapsable: Collapsable, element: HTMLElement) {
this.collapsable = collapsable

Expand Down Expand Up @@ -85,6 +87,10 @@ export class CollapsableItem {
box.id = boxItemId

ariaControlsAttr.push(boxItemId)

// Snapshot before any modification (hidden/aria-hidden are set later in handleExpandCollapse).
this.originalAttributes.remember(box, 'hidden')
this.originalAttributes.remember(box, 'aria-hidden')
})

this.controlElements.forEach((control) => {
Expand All @@ -105,9 +111,15 @@ export class CollapsableItem {
}

interactiveElement.classList.add(options.classNames.interactiveElement)

this.originalAttributes.remember(interactiveElement, 'aria-controls')
interactiveElement.setAttribute('aria-controls', ariaControlsAttr.join(' '))

// aria-expanded is set later in handleExpandCollapse, snapshot it now.
this.originalAttributes.remember(interactiveElement, 'aria-expanded')

if (interactiveElement.tagName.toLowerCase() === 'a') {
this.originalAttributes.remember(interactiveElement, 'role')
interactiveElement.setAttribute('role', 'button')
}

Expand Down Expand Up @@ -423,18 +435,21 @@ export class CollapsableItem {
interactiveElement.parentElement.innerHTML = interactiveElement.innerHTML
} else {
interactiveElement.classList.remove(options.classNames.interactiveElement)
interactiveElement.removeAttribute('aria-controls')
interactiveElement.removeAttribute('aria-expanded')
this.originalAttributes.restore(interactiveElement, 'aria-controls')
this.originalAttributes.restore(interactiveElement, 'aria-expanded')
this.originalAttributes.restore(interactiveElement, 'role')
}
})

this.boxElements.forEach((box) => {
box.removeAttribute('aria-hidden')
box.removeAttribute('hidden')
this.originalAttributes.restore(box, 'aria-hidden')
this.originalAttributes.restore(box, 'hidden')
delete box.dataset.collapsableState
})

this.element.dispatchEvent(new CustomEvent('destroy.collapsable', { bubbles: true }))

this.originalAttributes.clear()
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.esm.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { deepMerge } from './utils'
export { deepMerge, AttributeSnapshot } from './utils'
export { Collapsable as default } from './Collapsable'
34 changes: 34 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,37 @@ export let caUid = 0
export function getUid() {
return 'ca-uid-' + caUid++
}

// Remembers original attribute values before collapsable modifies them, so they can be restored on destroy.
export class AttributeSnapshot {
private store = new Map<HTMLElement, Record<string, string | null>>()

public remember(element: HTMLElement, name: string): void {
let attrs = this.store.get(element)
if (!attrs) {
attrs = {}
this.store.set(element, attrs)
}
// Only the first (original) value is stored, repeated calls won't overwrite it.
if (!(name in attrs)) {
attrs[name] = element.getAttribute(name)
}
}

public restore(element: HTMLElement, name: string): void {
const attrs = this.store.get(element)
if (!attrs || !(name in attrs)) {
return
}
const value = attrs[name]
if (value === null) {
element.removeAttribute(name)
} else {
element.setAttribute(name, value)
}
}

public clear(): void {
this.store.clear()
}
}