or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

focus-navigation.mdfocus-ring.mdfocus-scope.mdindex.mdvirtual-focus.md

virtual-focus.mddocs/

0

# Virtual Focus System

1

2

Support for aria-activedescendant focus patterns commonly used in comboboxes, listboxes, and other composite widgets where focus remains on a container while a descendant is highlighted.

3

4

## Capabilities

5

6

### moveVirtualFocus Function

7

8

Moves virtual focus from the current element to a target element, properly dispatching blur and focus events.

9

10

```typescript { .api }

11

/**

12

* Moves virtual focus from current element to target element.

13

* Dispatches appropriate virtual blur and focus events.

14

*/

15

function moveVirtualFocus(to: Element | null): void;

16

```

17

18

**Usage Examples:**

19

20

```typescript

21

import React, { useRef, useState } from "react";

22

import { moveVirtualFocus } from "@react-aria/focus";

23

24

// Listbox with virtual focus

25

function VirtualListbox({ options, value, onChange }) {

26

const listboxRef = useRef<HTMLDivElement>(null);

27

const [activeIndex, setActiveIndex] = useState(0);

28

const optionRefs = useRef<(HTMLDivElement | null)[]>([]);

29

30

const moveToOption = (index: number) => {

31

if (index >= 0 && index < options.length) {

32

const option = optionRefs.current[index];

33

if (option) {

34

moveVirtualFocus(option);

35

setActiveIndex(index);

36

37

// Update aria-activedescendant on the listbox

38

if (listboxRef.current) {

39

listboxRef.current.setAttribute('aria-activedescendant', option.id);

40

}

41

}

42

}

43

};

44

45

const handleKeyDown = (e: React.KeyboardEvent) => {

46

switch (e.key) {

47

case 'ArrowDown':

48

e.preventDefault();

49

moveToOption(Math.min(activeIndex + 1, options.length - 1));

50

break;

51

case 'ArrowUp':

52

e.preventDefault();

53

moveToOption(Math.max(activeIndex - 1, 0));

54

break;

55

case 'Enter':

56

case ' ':

57

e.preventDefault();

58

onChange(options[activeIndex]);

59

break;

60

}

61

};

62

63

return (

64

<div

65

ref={listboxRef}

66

role="listbox"

67

tabIndex={0}

68

onKeyDown={handleKeyDown}

69

aria-activedescendant={`option-${activeIndex}`}

70

>

71

{options.map((option, index) => (

72

<div

73

key={index}

74

ref={(el) => (optionRefs.current[index] = el)}

75

id={`option-${index}`}

76

role="option"

77

aria-selected={value === option}

78

onClick={() => {

79

moveToOption(index);

80

onChange(option);

81

}}

82

>

83

{option}

84

</div>

85

))}

86

</div>

87

);

88

}

89

90

// Combobox with virtual focus

91

function VirtualCombobox({ options, value, onChange }) {

92

const [isOpen, setIsOpen] = useState(false);

93

const [activeIndex, setActiveIndex] = useState(-1);

94

const inputRef = useRef<HTMLInputElement>(null);

95

const optionRefs = useRef<(HTMLDivElement | null)[]>([]);

96

97

const moveToOption = (index: number) => {

98

if (index >= 0 && index < options.length) {

99

const option = optionRefs.current[index];

100

if (option) {

101

moveVirtualFocus(option);

102

setActiveIndex(index);

103

104

// Update aria-activedescendant on the input

105

if (inputRef.current) {

106

inputRef.current.setAttribute('aria-activedescendant', option.id);

107

}

108

}

109

} else {

110

// Clear virtual focus

111

moveVirtualFocus(null);

112

setActiveIndex(-1);

113

if (inputRef.current) {

114

inputRef.current.removeAttribute('aria-activedescendant');

115

}

116

}

117

};

118

119

return (

120

<div className="combobox">

121

<input

122

ref={inputRef}

123

type="text"

124

role="combobox"

125

aria-expanded={isOpen}

126

aria-haspopup="listbox"

127

value={value}

128

onChange={(e) => onChange(e.target.value)}

129

onFocus={() => setIsOpen(true)}

130

onKeyDown={(e) => {

131

if (!isOpen) return;

132

133

switch (e.key) {

134

case 'ArrowDown':

135

e.preventDefault();

136

moveToOption(activeIndex + 1);

137

break;

138

case 'ArrowUp':

139

e.preventDefault();

140

moveToOption(activeIndex - 1);

141

break;

142

case 'Enter':

143

if (activeIndex >= 0) {

144

e.preventDefault();

145

onChange(options[activeIndex]);

146

setIsOpen(false);

147

}

148

break;

149

case 'Escape':

150

setIsOpen(false);

151

moveToOption(-1);

152

break;

153

}

154

}}

155

/>

156

{isOpen && (

157

<div role="listbox">

158

{options.map((option, index) => (

159

<div

160

key={index}

161

ref={(el) => (optionRefs.current[index] = el)}

162

id={`combobox-option-${index}`}

163

role="option"

164

onClick={() => {

165

onChange(option);

166

setIsOpen(false);

167

}}

168

>

169

{option}

170

</div>

171

))}

172

</div>

173

)}

174

</div>

175

);

176

}

177

```

178

179

### dispatchVirtualBlur Function

180

181

Dispatches virtual blur events on an element when virtual focus is moving away from it.

182

183

```typescript { .api }

184

/**

185

* Dispatches virtual blur events on element when virtual focus moves away.

186

*/

187

function dispatchVirtualBlur(from: Element, to: Element | null): void;

188

```

189

190

### dispatchVirtualFocus Function

191

192

Dispatches virtual focus events on an element when virtual focus is moving to it.

193

194

```typescript { .api }

195

/**

196

* Dispatches virtual focus events on element when virtual focus moves to it.

197

*/

198

function dispatchVirtualFocus(to: Element, from: Element | null): void;

199

```

200

201

**Usage Examples:**

202

203

```typescript

204

import React, { useRef } from "react";

205

import { dispatchVirtualBlur, dispatchVirtualFocus } from "@react-aria/focus";

206

207

// Custom virtual focus implementation

208

function CustomVirtualFocus({ items }) {

209

const [focusedIndex, setFocusedIndex] = useState(-1);

210

const itemRefs = useRef<(HTMLDivElement | null)[]>([]);

211

const previousFocusedRef = useRef<Element | null>(null);

212

213

const setVirtualFocus = (index: number) => {

214

const previousElement = previousFocusedRef.current;

215

const newElement = index >= 0 ? itemRefs.current[index] : null;

216

217

// Dispatch blur event on previously focused element

218

if (previousElement && previousElement !== newElement) {

219

dispatchVirtualBlur(previousElement, newElement);

220

}

221

222

// Dispatch focus event on newly focused element

223

if (newElement && newElement !== previousElement) {

224

dispatchVirtualFocus(newElement, previousElement);

225

}

226

227

previousFocusedRef.current = newElement;

228

setFocusedIndex(index);

229

};

230

231

return (

232

<div>

233

{items.map((item, index) => (

234

<div

235

key={index}

236

ref={(el) => (itemRefs.current[index] = el)}

237

onVirtualFocus={() => console.log(`Virtual focus on ${item}`)}

238

onVirtualBlur={() => console.log(`Virtual blur from ${item}`)}

239

onClick={() => setVirtualFocus(index)}

240

style={{

241

backgroundColor: focusedIndex === index ? '#e0e0e0' : 'transparent'

242

}}

243

>

244

{item}

245

</div>

246

))}

247

<button onClick={() => setVirtualFocus(-1)}>Clear Virtual Focus</button>

248

</div>

249

);

250

}

251

252

// Event listener example

253

function VirtualFocusEventListener() {

254

const itemRef = useRef<HTMLDivElement>(null);

255

256

useEffect(() => {

257

const element = itemRef.current;

258

if (!element) return;

259

260

const handleVirtualFocus = (e: Event) => {

261

console.log('Virtual focus received:', e);

262

element.classList.add('virtually-focused');

263

};

264

265

const handleVirtualBlur = (e: Event) => {

266

console.log('Virtual blur received:', e);

267

element.classList.remove('virtually-focused');

268

};

269

270

element.addEventListener('focus', handleVirtualFocus);

271

element.addEventListener('blur', handleVirtualBlur);

272

273

return () => {

274

element.removeEventListener('focus', handleVirtualFocus);

275

element.removeEventListener('blur', handleVirtualBlur);

276

};

277

}, []);

278

279

return (

280

<div>

281

<div ref={itemRef}>Virtual focusable item</div>

282

<button

283

onClick={() => dispatchVirtualFocus(itemRef.current!, null)}

284

>

285

Focus Item

286

</button>

287

<button

288

onClick={() => dispatchVirtualBlur(itemRef.current!, null)}

289

>

290

Blur Item

291

</button>

292

</div>

293

);

294

}

295

```

296

297

### getVirtuallyFocusedElement Function

298

299

Gets the currently virtually focused element using aria-activedescendant or the active element.

300

301

```typescript { .api }

302

/**

303

* Gets currently virtually focused element using aria-activedescendant

304

* or falls back to the active element.

305

*/

306

function getVirtuallyFocusedElement(document: Document): Element | null;

307

```

308

309

**Usage Examples:**

310

311

```typescript

312

import React, { useEffect, useState } from "react";

313

import { getVirtuallyFocusedElement } from "@react-aria/focus";

314

315

// Virtual focus tracker

316

function VirtualFocusTracker() {

317

const [virtuallyFocused, setVirtuallyFocused] = useState<Element | null>(null);

318

319

useEffect(() => {

320

const updateVirtualFocus = () => {

321

const element = getVirtuallyFocusedElement(document);

322

setVirtuallyFocused(element);

323

};

324

325

// Update on focus changes

326

document.addEventListener('focusin', updateVirtualFocus);

327

document.addEventListener('focusout', updateVirtualFocus);

328

329

// Update when aria-activedescendant changes

330

const observer = new MutationObserver((mutations) => {

331

for (const mutation of mutations) {

332

if (mutation.type === 'attributes' &&

333

mutation.attributeName === 'aria-activedescendant') {

334

updateVirtualFocus();

335

}

336

}

337

});

338

339

observer.observe(document.body, {

340

attributes: true,

341

subtree: true,

342

attributeFilter: ['aria-activedescendant']

343

});

344

345

updateVirtualFocus();

346

347

return () => {

348

document.removeEventListener('focusin', updateVirtualFocus);

349

document.removeEventListener('focusout', updateVirtualFocus);

350

observer.disconnect();

351

};

352

}, []);

353

354

return (

355

<div>

356

<p>Currently virtually focused element:</p>

357

<pre>{virtuallyFocused ? virtuallyFocused.outerHTML : 'None'}</pre>

358

</div>

359

);

360

}

361

362

// Focus synchronization

363

function FocusSynchronizer({ onVirtualFocusChange }) {

364

useEffect(() => {

365

const checkVirtualFocus = () => {

366

const element = getVirtuallyFocusedElement(document);

367

onVirtualFocusChange(element);

368

};

369

370

// Polling approach for demonstration

371

const interval = setInterval(checkVirtualFocus, 100);

372

373

return () => clearInterval(interval);

374

}, [onVirtualFocusChange]);

375

376

return null;

377

}

378

```

379

380

## Virtual Focus Patterns

381

382

### aria-activedescendant Pattern

383

384

The virtual focus system supports the aria-activedescendant pattern where:

385

- A container element maintains actual focus

386

- A descendant element is marked as "active" via aria-activedescendant

387

- Virtual focus events are dispatched on the active descendant

388

- Screen readers announce the active descendant as if it has focus

389

390

### Common Use Cases

391

392

**Listbox/Combobox:**

393

- Container input or div has actual focus

394

- Options are marked as active via aria-activedescendant

395

- Arrow keys change which option is virtually focused

396

397

**Grid/TreeGrid:**

398

- Grid container has actual focus

399

- Individual cells are virtually focused via aria-activedescendant

400

- Arrow keys navigate between cells

401

402

**Menu/Menubar:**

403

- Menu has actual focus

404

- Menu items are virtually focused

405

- Arrow keys and letters navigate items

406

407

### Event Dispatching

408

409

Virtual focus events are regular DOM events:

410

- `focus` event with `relatedTarget` set to previous element

411

- `blur` event with `relatedTarget` set to next element

412

- `focusin` and `focusout` events that bubble normally

413

- Events can be prevented with `preventDefault()`

414

415

### Screen Reader Support

416

417

Virtual focus is announced by screen readers when:

418

- The virtually focused element has proper ARIA roles

419

- The container has aria-activedescendant pointing to the virtual element

420

- The virtual element has appropriate labels and descriptions

421

- Focus events are properly dispatched for screen reader detection

422

423

### Performance Considerations

424

425

- Virtual focus avoids the performance cost of moving actual DOM focus

426

- Useful for large lists where moving focus would cause scrolling issues

427

- Reduces layout thrashing in complex UI components

428

- Allows custom focus styling without browser focus ring limitations