Skip to content

Commit edf3e3f

Browse files
authored
feat: Restore original attributes on destroy (#39)
- Add shared AttributeSnapshot utility (remember/restore/clear) in utils.ts, exported from the ESM build alongside deepMerge - destroy() now restores role, hidden, aria-hidden, aria-controls and aria-expanded/aria-selected to their original state instead of unconditionally removing them - Fix: destroy no longer removes author-defined role/aria-controls/aria-expanded that collapsable did not add - Generated ids and rewritten href (#->#id) are intentionally kept
1 parent aceb051 commit edf3e3f

4 files changed

Lines changed: 65 additions & 9 deletions

File tree

src/CollapsableExtLink.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CollapsableItem } from './CollapsableItem'
22
import { Collapsable } from './Collapsable'
3+
import { AttributeSnapshot } from './utils'
34

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

14+
private originalAttributes = new AttributeSnapshot()
15+
1316
public constructor(
1417
collapsable: Collapsable,
1518
link: HTMLAnchorElement | HTMLButtonElement,
@@ -29,9 +32,11 @@ export class CollapsableExtLink {
2932
}
3033

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

3438
if (this.extLink instanceof HTMLAnchorElement && !this.extLink.role) {
39+
this.originalAttributes.remember(this.extLink, 'role')
3540
this.extLink.role = 'button'
3641
}
3742

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

49+
this.originalAttributes.remember(this.extLink, this.ariaPerRole)
4450
this.extLink.setAttribute(this.ariaPerRole, String(this.collapsableItem.isExpanded))
4551
}
4652

@@ -72,9 +78,10 @@ export class CollapsableExtLink {
7278
public destroy(): void {
7379
if (this.listener) {
7480
this.extLink.removeEventListener('click', this.listener)
75-
this.extLink.removeAttribute('aria-controls')
76-
this.extLink.removeAttribute(this.ariaPerRole)
77-
this.extLink.removeAttribute('role')
81+
this.originalAttributes.restore(this.extLink, 'aria-controls')
82+
this.originalAttributes.restore(this.extLink, this.ariaPerRole)
83+
this.originalAttributes.restore(this.extLink, 'role')
84+
this.originalAttributes.clear()
7885
}
7986
}
8087
}

src/CollapsableItem.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Collapsable, CollapsableEvent } from './Collapsable'
2-
import { getUid } from './utils'
2+
import { AttributeSnapshot, getUid } from './utils'
33

44
type CollapsableItemAction = 'expand' | 'collapse'
55
type CollapsableItemEvents =
@@ -40,6 +40,8 @@ export class CollapsableItem {
4040

4141
private listenersMap: ListenersMapItem[] = []
4242

43+
private originalAttributes = new AttributeSnapshot()
44+
4345
public constructor(collapsable: Collapsable, element: HTMLElement) {
4446
this.collapsable = collapsable
4547

@@ -85,6 +87,10 @@ export class CollapsableItem {
8587
box.id = boxItemId
8688

8789
ariaControlsAttr.push(boxItemId)
90+
91+
// Snapshot before any modification (hidden/aria-hidden are set later in handleExpandCollapse).
92+
this.originalAttributes.remember(box, 'hidden')
93+
this.originalAttributes.remember(box, 'aria-hidden')
8894
})
8995

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

107113
interactiveElement.classList.add(options.classNames.interactiveElement)
114+
115+
this.originalAttributes.remember(interactiveElement, 'aria-controls')
108116
interactiveElement.setAttribute('aria-controls', ariaControlsAttr.join(' '))
109117

118+
// aria-expanded is set later in handleExpandCollapse, snapshot it now.
119+
this.originalAttributes.remember(interactiveElement, 'aria-expanded')
120+
110121
if (interactiveElement.tagName.toLowerCase() === 'a') {
122+
this.originalAttributes.remember(interactiveElement, 'role')
111123
interactiveElement.setAttribute('role', 'button')
112124
}
113125

@@ -423,18 +435,21 @@ export class CollapsableItem {
423435
interactiveElement.parentElement.innerHTML = interactiveElement.innerHTML
424436
} else {
425437
interactiveElement.classList.remove(options.classNames.interactiveElement)
426-
interactiveElement.removeAttribute('aria-controls')
427-
interactiveElement.removeAttribute('aria-expanded')
438+
this.originalAttributes.restore(interactiveElement, 'aria-controls')
439+
this.originalAttributes.restore(interactiveElement, 'aria-expanded')
440+
this.originalAttributes.restore(interactiveElement, 'role')
428441
}
429442
})
430443

431444
this.boxElements.forEach((box) => {
432-
box.removeAttribute('aria-hidden')
433-
box.removeAttribute('hidden')
445+
this.originalAttributes.restore(box, 'aria-hidden')
446+
this.originalAttributes.restore(box, 'hidden')
434447
delete box.dataset.collapsableState
435448
})
436449

437450
this.element.dispatchEvent(new CustomEvent('destroy.collapsable', { bubbles: true }))
451+
452+
this.originalAttributes.clear()
438453
}
439454
}
440455

src/index.esm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { deepMerge } from './utils'
1+
export { deepMerge, AttributeSnapshot } from './utils'
22
export { Collapsable as default } from './Collapsable'

src/utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,37 @@ export let caUid = 0
3636
export function getUid() {
3737
return 'ca-uid-' + caUid++
3838
}
39+
40+
// Remembers original attribute values before collapsable modifies them, so they can be restored on destroy.
41+
export class AttributeSnapshot {
42+
private store = new Map<HTMLElement, Record<string, string | null>>()
43+
44+
public remember(element: HTMLElement, name: string): void {
45+
let attrs = this.store.get(element)
46+
if (!attrs) {
47+
attrs = {}
48+
this.store.set(element, attrs)
49+
}
50+
// Only the first (original) value is stored, repeated calls won't overwrite it.
51+
if (!(name in attrs)) {
52+
attrs[name] = element.getAttribute(name)
53+
}
54+
}
55+
56+
public restore(element: HTMLElement, name: string): void {
57+
const attrs = this.store.get(element)
58+
if (!attrs || !(name in attrs)) {
59+
return
60+
}
61+
const value = attrs[name]
62+
if (value === null) {
63+
element.removeAttribute(name)
64+
} else {
65+
element.setAttribute(name, value)
66+
}
67+
}
68+
69+
public clear(): void {
70+
this.store.clear()
71+
}
72+
}

0 commit comments

Comments
 (0)