Skip to content

Commit 82275a1

Browse files
committed
style(radio): refactor tabindex code
1 parent e79a3f7 commit 82275a1

File tree

1 file changed

+82
-61
lines changed

1 file changed

+82
-61
lines changed

elements/pf-radio/pf-radio.ts

Lines changed: 82 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { LitElement, html, type TemplateResult } from 'lit';
22
import { customElement } from 'lit/decorators/custom-element.js';
3-
import styles from './pf-radio.css';
43
import { property } from 'lit/decorators/property.js';
4+
import { observes } from '@patternfly/pfe-core/decorators/observes.js';
5+
import { state } from 'lit/decorators/state.js';
6+
7+
import styles from './pf-radio.css';
58

69
export class PfRadioChangeEvent extends Event {
710
constructor(public event: Event, public value: string) {
@@ -16,46 +19,89 @@ export class PfRadioChangeEvent extends Event {
1619
@customElement('pf-radio')
1720
export class PfRadio extends LitElement {
1821
static readonly styles: CSSStyleSheet[] = [styles];
22+
1923
static formAssociated = true;
24+
2025
static shadowRootOptions: ShadowRootInit = {
2126
...LitElement.shadowRootOptions,
2227
delegatesFocus: true,
2328
};
2429

25-
@property({
26-
type: Boolean,
27-
attribute: 'checked',
28-
converter: {
29-
fromAttribute: value => value === 'true',
30-
},
31-
reflect: true,
32-
})
30+
@property({ type: Boolean, reflect: true })
3331
checked = false;
3432

35-
@property({
36-
type: Boolean,
37-
attribute: 'disabled',
38-
converter: {
39-
fromAttribute: value => value === 'true',
40-
},
41-
reflect: true,
42-
})
33+
@property({ type: Boolean, reflect: true })
4334
disabled = false;
4435

45-
@property({ attribute: 'name', reflect: true }) name = '';
46-
@property({ attribute: 'label', reflect: true }) label?: string;
47-
@property({ attribute: 'value', reflect: true }) value = '';
48-
@property({ attribute: 'id', reflect: true }) id = '';
49-
@property({ attribute: 'tabindex', reflect: true }) tabIndex = -1;
36+
@property({ reflect: true }) name = '';
37+
38+
@property({ reflect: true }) label?: string;
39+
40+
@property({ reflect: true }) value = '';
41+
42+
@state() private focusable = false;
5043

51-
constructor() {
52-
super();
44+
/** Radio groups: instances.get(groupName).forEach(pfRadio => { ... }) */
45+
private static instances = new Map<string, Set<PfRadio>>();
46+
47+
private static selected = new Map<string, PfRadio>;
48+
49+
static {
50+
globalThis.addEventListener('keydown', e => {
51+
switch (e.key) {
52+
case 'Tab':
53+
this.instances.forEach((radioSet, groupName) => {
54+
const selected = this.selected.get(groupName);
55+
[...radioSet].forEach((radio, i, radios) => {
56+
// the radio group has a selected element
57+
// it should be the only focusable member of the group
58+
if (selected) {
59+
radio.focusable = radio === selected;
60+
// when Shift-tabbing into a group, only the last member should be selected
61+
} else if (e.shiftKey) {
62+
radio.focusable = radio === radios.at(-1);
63+
// otherwise, the first member must be focusable
64+
} else {
65+
radio.focusable = i === 0;
66+
}
67+
});
68+
});
69+
break;
70+
}
71+
});
5372
}
5473

5574
connectedCallback(): void {
5675
super.connectedCallback();
5776
this.addEventListener('keydown', this.#onKeydown);
58-
document.addEventListener('keydown', this.#onKeyPress);
77+
}
78+
79+
@observes('checked')
80+
protected checkedChanged(): void {
81+
if (this.checked) {
82+
PfRadio.selected.set(this.name, this);
83+
}
84+
}
85+
86+
@observes('name')
87+
protected nameChanged(oldName: string): void {
88+
// reset the map of groupname to selected radio button
89+
if (PfRadio.selected.get(oldName) === this) {
90+
PfRadio.selected.delete(oldName);
91+
PfRadio.selected.set(this.name, this);
92+
}
93+
if (typeof oldName === 'string') {
94+
PfRadio.instances.get(oldName)?.delete(this);
95+
}
96+
if (!PfRadio.instances.has(this.name)) {
97+
PfRadio.instances.set(this.name, new Set());
98+
}
99+
PfRadio.instances.get(this.name)?.add(this);
100+
}
101+
102+
disconnectedCallback(): void {
103+
PfRadio.instances.get(this.name)?.delete(this);
104+
super.disconnectedCallback();
59105
}
60106

61107
#onRadioButtonClick(event: Event) {
@@ -66,44 +112,17 @@ export class PfRadio extends LitElement {
66112
radioGroup = root.querySelectorAll('pf-radio');
67113
radioGroup.forEach((radio: PfRadio) => {
68114
const element: HTMLElement = radio as HTMLElement;
115+
// avoid removeAttribute: set checked property instead
116+
// even better: listen for `change` on the shadow input,
117+
// and recalculate state from there.
69118
element?.removeAttribute('checked');
70-
element.tabIndex = -1;
71119
});
72120
this.checked = true;
73-
this.tabIndex = 0;
74121
this.dispatchEvent(new PfRadioChangeEvent(event, this.value));
75122
}
76123
}
77124
}
78125

79-
// Function to handle tab key navigation
80-
#onKeyPress = (event: KeyboardEvent) => {
81-
const root: Node = this.getRootNode();
82-
if (root instanceof Document || root instanceof ShadowRoot) {
83-
const radioGroup: NodeListOf<PfRadio> = root.querySelectorAll('pf-radio');
84-
const isRadioChecked: boolean = Array.from(radioGroup).some(
85-
(radio: PfRadio) => radio.checked
86-
);
87-
if (event.key === 'Tab') {
88-
radioGroup.forEach((radio: PfRadio) => {
89-
radio.tabIndex = radio.checked ? 0 : -1;
90-
});
91-
if (!isRadioChecked) {
92-
radioGroup.forEach((radio: PfRadio, index: number) => {
93-
radio.tabIndex = -1;
94-
if (event.shiftKey) {
95-
if (index === radioGroup.length - 1) {
96-
radio.tabIndex = 0;
97-
}
98-
} else if (index === 0) {
99-
radio.tabIndex = 0;
100-
}
101-
});
102-
}
103-
}
104-
}
105-
};
106-
107126
// Function to handle keyboard navigation
108127
#onKeydown = (event: KeyboardEvent) => {
109128
const arrowKeys: string[] = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'];
@@ -113,7 +132,6 @@ export class PfRadio extends LitElement {
113132
const radioGroup: NodeListOf<PfRadio> = root.querySelectorAll('pf-radio');
114133
radioGroup.forEach((radio: PfRadio, index: number) => {
115134
this.checked = false;
116-
this.tabIndex = 0;
117135

118136
if (radio === event.target) {
119137
const isArrowDownOrRight: boolean = ['ArrowDown', 'ArrowRight'].includes(event.key);
@@ -125,6 +143,9 @@ export class PfRadio extends LitElement {
125143
const nextIndex: number = (index + direction + radioGroup.length) % radioGroup.length;
126144
radioGroup[nextIndex].focus();
127145
radioGroup[nextIndex].checked = true;
146+
// TODO: move this to an @observes
147+
// consider the api of this event.
148+
// do we add the group to it? do we fire from every element on every change?
128149
this.dispatchEvent(new PfRadioChangeEvent(event, radioGroup[nextIndex].value));
129150
}
130151
});
@@ -135,15 +156,15 @@ export class PfRadio extends LitElement {
135156
render(): TemplateResult<1> {
136157
return html`
137158
<input
159+
id="radio"
160+
type="radio"
138161
@click=${this.#onRadioButtonClick}
139-
id=${this.id}
140162
.name=${this.name}
141-
type='radio'
142163
value=${this.value}
143-
tabindex=${this.tabIndex}
164+
tabindex=${this.focusable ? 0 : -1}
144165
.checked=${this.checked}
145-
/>
146-
<label for=${this.id}>${this.label}</label>
166+
>
167+
<label for="radio">${this.label}</label>
147168
`;
148169
}
149170
}

0 commit comments

Comments
 (0)