Skip to content

Commit 82da44c

Browse files
authored
feat(jump-links)!: reimplement in line with PFv4 (#2283)
* feat(jump-links)!: initial commit 1:1 * feat(core): add ARIAMixin props to InternalsController * fix: add some PFv4 styles * test: fix test typings * chore: fix ts build * fix: styles mostly complete * feat: scroll spy * fix: refactor, fix, improve * refactor: controllers, obvs * feat: added root node escape hatch * docs: add changesets
1 parent db95a1a commit 82da44c

28 files changed

+1229
-3760
lines changed

.changeset/four-schools-teach.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@patternfly/pfe-jump-links": major
3+
---
4+
5+
Reimplemented `<pfe-jump-links>` to align with [PatternFly
6+
v4](https://patternfly.org/components/jump-links).
7+
8+
See the [docs](https://patternflyelements.org/components/jump-links) for more
9+
info.

.changeset/unlucky-doors-cheer.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@patternfly/pfe-core": minor
3+
---
4+
5+
✨ Added `ScrollSpyController`
6+
✨ Added `RovingTabindexController`
7+
8+
- `ScrollSpyController` sets an attribute (`active` by default) on one of it's
9+
children when that child's `href` attribute is to a hash reference to an IDd
10+
heading on the page.
11+
- `RovingTabindexController` implements roving tabindex, as described in WAI-ARIA practices.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
2+
3+
const isFocusableElement = (el: Element): el is HTMLElement =>
4+
!!el &&
5+
!el.hasAttribute('disabled') &&
6+
!el.ariaHidden &&
7+
!el.hasAttribute('hidden');
8+
9+
/**
10+
* Implements roving tabindex, as described in WAI-ARIA practices, [Managing Focus Within
11+
* Components Using a Roving
12+
* tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
13+
*/
14+
export class RovingTabindexController implements ReactiveController {
15+
/** active focusable element */
16+
#activeItem?: HTMLElement;
17+
18+
/** array of all focusable elements */
19+
#items: HTMLElement[] = [];
20+
21+
/**
22+
* finds focusable items from a group of items
23+
*/
24+
get #focusableItems(): HTMLElement[] {
25+
return this.#items.filter(isFocusableElement);
26+
}
27+
28+
/**
29+
* index of active item in array of focusable items
30+
*/
31+
get #activeIndex(): number {
32+
return !!this.#focusableItems && !!this.activeItem ? this.#focusableItems.indexOf(this.activeItem) : -1;
33+
}
34+
35+
/**
36+
* index of active item in array of items
37+
*/
38+
get #itemIndex(): number {
39+
return this.activeItem ? this.#items.indexOf(this.activeItem) : -1;
40+
}
41+
42+
/**
43+
* active item of array of items
44+
*/
45+
get activeItem(): HTMLElement | undefined {
46+
return this.#activeItem;
47+
}
48+
49+
/**
50+
* first item in array of focusable items
51+
*/
52+
get firstItem(): HTMLElement | undefined {
53+
return this.#focusableItems[0];
54+
}
55+
56+
/**
57+
* last item in array of focusable items
58+
*/
59+
get lastItem(): HTMLElement | undefined {
60+
return this.#focusableItems.at(-1);
61+
}
62+
63+
/**
64+
* next item after active item in array of focusable items
65+
*/
66+
get nextItem(): HTMLElement | undefined {
67+
return (
68+
this.#activeIndex < this.#focusableItems.length - 1 ? this.#focusableItems[this.#activeIndex + 1]
69+
: this.firstItem
70+
);
71+
}
72+
73+
/**
74+
* previous item after active item in array of focusable items
75+
*/
76+
get prevItem(): HTMLElement | undefined {
77+
return (
78+
this.#activeIndex > 0 ? this.#focusableItems[this.#activeIndex - 1]
79+
: this.lastItem
80+
);
81+
}
82+
83+
constructor(public host: ReactiveControllerHost & HTMLElement) {
84+
this.host.addController(this);
85+
}
86+
87+
/**
88+
* handles keyboard navigation
89+
*/
90+
#onKeydown(event: KeyboardEvent):void {
91+
if (event.ctrlKey || event.altKey || event.metaKey || this.#focusableItems.length < 1) {
92+
return;
93+
}
94+
95+
const item = this.activeItem;
96+
let shouldPreventDefault = false;
97+
const horizontalOnly =
98+
!item ? false
99+
: item.tagName === 'SELECT' ||
100+
item.getAttribute('aria-expanded') === 'true' ||
101+
item.getAttribute('role') === 'spinbutton';
102+
103+
switch (event.key) {
104+
case 'ArrowLeft':
105+
this.focusOnItem(this.prevItem);
106+
shouldPreventDefault = true;
107+
break;
108+
case 'ArrowRight':
109+
this.focusOnItem(this.nextItem);
110+
shouldPreventDefault = true;
111+
break;
112+
case 'ArrowDown':
113+
if (horizontalOnly) {
114+
return;
115+
}
116+
this.focusOnItem(this.prevItem);
117+
shouldPreventDefault = true;
118+
break;
119+
case 'ArrowUp':
120+
if (horizontalOnly) {
121+
return;
122+
}
123+
this.focusOnItem(this.nextItem);
124+
shouldPreventDefault = true;
125+
break;
126+
case 'Home':
127+
this.focusOnItem(this.firstItem);
128+
shouldPreventDefault = true;
129+
break;
130+
case 'PageUp':
131+
if (horizontalOnly) {
132+
return;
133+
}
134+
this.focusOnItem(this.firstItem);
135+
shouldPreventDefault = true;
136+
break;
137+
case 'End':
138+
this.focusOnItem(this.lastItem);
139+
shouldPreventDefault = true;
140+
break;
141+
case 'PageDown':
142+
if (horizontalOnly) {
143+
return;
144+
}
145+
this.focusOnItem(this.lastItem);
146+
shouldPreventDefault = true;
147+
break;
148+
default:
149+
break;
150+
}
151+
152+
if (shouldPreventDefault) {
153+
event.stopPropagation();
154+
event.preventDefault();
155+
}
156+
}
157+
158+
/**
159+
* sets tabindex of item based on whether or not it is active
160+
*/
161+
#updateActiveItem(item?: HTMLElement):void {
162+
if (item) {
163+
if (!!this.#activeItem && item !== this.#activeItem) {
164+
this.#activeItem.tabIndex = -1;
165+
}
166+
item.tabIndex = 0;
167+
this.#activeItem = item;
168+
}
169+
}
170+
171+
/**
172+
* focuses on an item and sets it as active
173+
*/
174+
focusOnItem(item?: HTMLElement):void {
175+
this.#updateActiveItem(item || this.firstItem);
176+
this.#activeItem?.focus();
177+
}
178+
179+
/**
180+
* Focuses next focusable item
181+
*/
182+
updateItems(items: HTMLElement[]) {
183+
const sequence = [...items.slice(this.#itemIndex), ...items.slice(0, this.#itemIndex)];
184+
const first = sequence.find(item => this.#focusableItems.includes(item));
185+
this.focusOnItem(first || this.firstItem);
186+
}
187+
188+
/**
189+
* from array of HTML items, and sets active items
190+
*/
191+
initItems(items: HTMLElement[]) {
192+
this.#items = items ?? [];
193+
const focusableItems = this.#focusableItems;
194+
const [focusableItem] = focusableItems;
195+
this.#activeItem = focusableItem;
196+
for (const item of focusableItems) {
197+
item.tabIndex = this.#activeItem === item ? 0 : -1;
198+
}
199+
}
200+
201+
hostConnected() {
202+
this.host.addEventListener('keydown', this.#onKeydown.bind(this));
203+
}
204+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
2+
3+
export interface ScrollSpyControllerOptions extends IntersectionObserverInit {
4+
/**
5+
* Tag names of legal link children.
6+
* Legal children must have an `href` property/attribute pair, like `<a>`.
7+
*/
8+
tagNames: string[];
9+
10+
/**
11+
* Attribute to set on the active link element.
12+
* @default 'active'
13+
*/
14+
activeAttribute?: string;
15+
16+
/**
17+
* The root node to query content for
18+
* @default the host's root node
19+
*/
20+
rootNode?: Node;
21+
/**
22+
* function to call on link children to get their URL hash (i.e. id to scroll to)
23+
* @default el => el.getAttribute('href');
24+
*/
25+
getHash?: (el: Element) => string|null;
26+
}
27+
28+
export class ScrollSpyController implements ReactiveController {
29+
#tagNames: string[];
30+
#activeAttribute: string;
31+
32+
#io?: IntersectionObserver;
33+
34+
/** Which link's targets have already scrolled past? */
35+
#passedLinks = new Set<Element>();
36+
37+
/** Ignore intersections? */
38+
#force = false;
39+
40+
/** Has the intersection observer found an element? */
41+
#intersected = false;
42+
43+
#root: ScrollSpyControllerOptions['root'];
44+
#rootMargin?: string;
45+
#threshold: number|number[];
46+
47+
#rootNode: Node;
48+
#getHash: (el: Element) => string|null;
49+
50+
get #linkChildren(): Element[] {
51+
return Array.from(this.host.querySelectorAll(this.#tagNames.join(',')))
52+
.filter(this.#getHash);
53+
}
54+
55+
get root() {
56+
return this.#root;
57+
}
58+
59+
set root(v) {
60+
this.#root = v;
61+
this.#io?.disconnect();
62+
this.#initIo();
63+
}
64+
65+
get rootMargin() {
66+
return this.#rootMargin;
67+
}
68+
69+
set rootMargin(v) {
70+
this.#rootMargin = v;
71+
this.#io?.disconnect();
72+
this.#initIo();
73+
}
74+
75+
get threshold() {
76+
return this.#threshold;
77+
}
78+
79+
set threshold(v) {
80+
this.#threshold = v;
81+
this.#io?.disconnect();
82+
this.#initIo();
83+
}
84+
85+
constructor(
86+
private host: ReactiveControllerHost & HTMLElement,
87+
options: ScrollSpyControllerOptions,
88+
) {
89+
host.addController(this);
90+
this.#tagNames = options.tagNames;
91+
this.#root = options.root;
92+
this.#rootMargin = options.rootMargin;
93+
this.#activeAttribute = options.activeAttribute ?? 'active';
94+
this.#threshold = options.threshold ?? 0.85;
95+
this.#rootNode = options.rootNode ?? host.getRootNode();
96+
this.#getHash = options?.getHash ?? ((el: Element) => el.getAttribute('href'));
97+
}
98+
99+
hostConnected() {
100+
this.#initIo();
101+
}
102+
103+
#initIo() {
104+
const rootNode = this.#rootNode;
105+
if (rootNode instanceof Document || rootNode instanceof ShadowRoot) {
106+
const { rootMargin, threshold, root } = this;
107+
this.#io = new IntersectionObserver(r => this.#onIo(r), { root, rootMargin, threshold });
108+
this.#linkChildren
109+
.map(x => this.#getHash(x))
110+
.filter((x): x is string => !!x)
111+
.map(x => rootNode.getElementById(x.replace('#', '')))
112+
.filter((x): x is HTMLElement => !!x)
113+
.forEach(target => this.#io?.observe(target));
114+
}
115+
}
116+
117+
#markPassed(link: Element, force: boolean) {
118+
if (force) {
119+
this.#passedLinks.add(link);
120+
} else {
121+
this.#passedLinks.delete(link);
122+
}
123+
}
124+
125+
#setActive(link?: EventTarget|null) {
126+
for (const child of this.#linkChildren) {
127+
child.toggleAttribute(this.#activeAttribute, child === link);
128+
}
129+
}
130+
131+
async #nextIntersection() {
132+
this.#intersected = false;
133+
// safeguard the loop
134+
setTimeout(() => this.#intersected = false, 3000);
135+
while (!this.#intersected) {
136+
await new Promise(requestAnimationFrame);
137+
}
138+
}
139+
140+
async #onIo(entries: IntersectionObserverEntry[]) {
141+
if (!this.#force) {
142+
for (const { target, boundingClientRect, intersectionRect } of entries) {
143+
const selector = `:is(${this.#tagNames.join(',')})[href="#${target.id}"]`;
144+
const link = this.host.querySelector(selector);
145+
if (link) {
146+
this.#markPassed(link, boundingClientRect.top < intersectionRect.top);
147+
}
148+
}
149+
const link = [...this.#passedLinks];
150+
const last = link.at(-1);
151+
this.#setActive(last ?? this.#linkChildren.at(0));
152+
}
153+
this.#intersected = true;
154+
}
155+
156+
/** Explicitly set the active item */
157+
public async setActive(link: EventTarget|null) {
158+
this.#force = true;
159+
this.#setActive(link);
160+
let sawActive = false;
161+
for (const child of this.#linkChildren) {
162+
this.#markPassed(child, !sawActive);
163+
if (child === link) {
164+
sawActive = true;
165+
}
166+
}
167+
await this.#nextIntersection();
168+
this.#force = false;
169+
}
170+
}

0 commit comments

Comments
 (0)