Skip to content

Commit 54622f2

Browse files
committed
Add support for focus layers
This will allow support for things like Modals and overlays
1 parent 5815845 commit 54622f2

File tree

5 files changed

+500
-34
lines changed

5 files changed

+500
-34
lines changed

.changeset/icy-baboons-join.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plextv/react-lightning": patch
3+
---
4+
5+
Add support for focus layers, for example for Modals
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
FocusGroup,
3+
type KeyEvent,
4+
type LightningElement,
5+
useFocusManager,
6+
} from '@plextv/react-lightning';
7+
import { Column, Row } from '@plextv/react-lightning-components';
8+
import type { Meta } from '@storybook/react';
9+
import { useEffect, useRef, useState } from 'react';
10+
import Button from '../../../components/Button';
11+
12+
export default {
13+
title: '@plextv∕react-lightning/Examples/Focus/Focus Layers',
14+
argTypes: {},
15+
} as Meta;
16+
17+
const Modal = ({
18+
visible,
19+
dismissModal,
20+
}: {
21+
visible: boolean;
22+
dismissModal: () => void;
23+
}) => {
24+
const focusManager = useFocusManager();
25+
const ref = useRef<LightningElement>(null);
26+
const handleKeyPress = (event: KeyEvent) => {
27+
if (event.key === 'Enter' || event.key === 'Escape') {
28+
dismissModal();
29+
return false;
30+
}
31+
32+
return true;
33+
};
34+
35+
useEffect(() => {
36+
if (visible && ref.current) {
37+
focusManager.pushLayer(ref.current);
38+
}
39+
40+
return () => {
41+
focusManager.popLayer();
42+
};
43+
}, [visible, focusManager]);
44+
45+
return (
46+
<Column
47+
ref={ref}
48+
focusable
49+
style={{
50+
// Alpha of 0 will hide the modal, but also prevent capturing focus
51+
alpha: visible ? 1 : 0,
52+
color: 0xffffff05,
53+
border: { color: 0xff0000ff, width: 2 },
54+
width: 600,
55+
height: 200,
56+
x: 50,
57+
y: 200,
58+
}}
59+
>
60+
<lng-text>This is a Modal</lng-text>
61+
<Row focusable>
62+
<Button>---</Button>
63+
<Button>---</Button>
64+
<Button>---</Button>
65+
</Row>
66+
<Button onKeyPress={handleKeyPress}>Close Modal</Button>
67+
</Column>
68+
);
69+
};
70+
71+
export const ModalExample = () => {
72+
const [modalVisible, setModalVisible] = useState(false);
73+
74+
const handleKeyPress = (event: KeyEvent) => {
75+
if (event.key === 'Enter' || event.key === 'Escape') {
76+
setModalVisible(true);
77+
return false;
78+
}
79+
80+
return true;
81+
};
82+
83+
return (
84+
<FocusGroup style={{ width: 400, height: 400 }}>
85+
<lng-text>Focus Layers Example</lng-text>
86+
<Column>
87+
<Row focusable>
88+
<Button>---</Button>
89+
<Button onKeyPress={handleKeyPress}>Open Modal</Button>
90+
<Button>---</Button>
91+
</Row>
92+
<Modal
93+
visible={modalVisible}
94+
dismissModal={() => setModalVisible(false)}
95+
/>
96+
</Column>
97+
</FocusGroup>
98+
);
99+
};

packages/react-lightning/src/focus/FocusManager.spec.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,234 @@ describe('FocusManager', () => {
250250

251251
expect(focusManager.focusPath).toEqual([parent]);
252252
});
253+
254+
describe('Layer Management (Modal Support)', () => {
255+
it('should create a new layer when pushLayer is called', () => {
256+
const mainElement = createMockElement(1, 'main');
257+
const modalElement = createMockElement(2, 'modal');
258+
259+
// Add element to main layer
260+
focusManager.addElement(mainElement, null, { autoFocus: true });
261+
expect(focusManager.focusPath).toEqual([mainElement]);
262+
263+
// Push new layer with modal
264+
focusManager.pushLayer(modalElement);
265+
expect(focusManager.focusPath).toEqual([modalElement]);
266+
267+
// Main element should still be focused on its layer, but modal takes precedence
268+
expect(mainElement.focused).toBe(false);
269+
expect(modalElement.focused).toBe(true);
270+
});
271+
272+
it('should maintain separate focus paths for different layers', () => {
273+
const mainElement = createMockElement(1, 'main');
274+
const modalElement = createMockElement(2, 'modal');
275+
const modalChild = createMockElement(3, 'modalChild');
276+
277+
// Setup main layer
278+
focusManager.addElement(mainElement, null, { autoFocus: true });
279+
expect(focusManager.focusPath).toEqual([mainElement]);
280+
281+
// Push modal layer
282+
focusManager.pushLayer(modalElement);
283+
expect(focusManager.focusPath).toEqual([modalElement]);
284+
285+
// Add child to modal
286+
focusManager.addElement(modalChild, modalElement, { autoFocus: true });
287+
expect(focusManager.focusPath).toEqual([modalElement, modalChild]);
288+
});
289+
290+
it('should not allow focusing elements outside the active layer', () => {
291+
const mainElement = createMockElement(1, 'main');
292+
const modalElement = createMockElement(2, 'modal');
293+
294+
// Setup main layer
295+
focusManager.addElement(mainElement, null, { autoFocus: true });
296+
expect(focusManager.focusPath).toEqual([mainElement]);
297+
298+
// Push modal layer
299+
focusManager.pushLayer(modalElement);
300+
expect(focusManager.focusPath).toEqual([modalElement]);
301+
302+
// Try to focus element from main layer - should be blocked
303+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
304+
focusManager.focus(mainElement);
305+
306+
expect(consoleSpy).toHaveBeenCalledWith(
307+
'FocusManager: Cannot focus element outside of active focus layer',
308+
);
309+
expect(focusManager.focusPath).toEqual([modalElement]);
310+
expect(mainElement.focused).toBe(false);
311+
312+
consoleSpy.mockRestore();
313+
});
314+
315+
it('should restore previous layer focus when popLayer is called', () => {
316+
const mainElement = createMockElement(1, 'main');
317+
const modalElement = createMockElement(2, 'modal');
318+
319+
// Setup main layer
320+
focusManager.addElement(mainElement, null, { autoFocus: true });
321+
expect(focusManager.focusPath).toEqual([mainElement]);
322+
expect(mainElement.focused).toBe(true);
323+
324+
// Push modal layer
325+
focusManager.pushLayer(modalElement);
326+
expect(focusManager.focusPath).toEqual([modalElement]);
327+
expect(mainElement.focused).toBe(false);
328+
expect(modalElement.focused).toBe(true);
329+
330+
// Pop modal layer
331+
focusManager.popLayer();
332+
expect(focusManager.focusPath).toEqual([mainElement]);
333+
expect(modalElement.focused).toBe(false);
334+
expect(mainElement.focused).toBe(true);
335+
});
336+
337+
it('should handle multiple nested layers', () => {
338+
const mainElement = createMockElement(1, 'main');
339+
const modal1Element = createMockElement(2, 'modal1');
340+
const modal2Element = createMockElement(3, 'modal2');
341+
342+
// Setup main layer
343+
focusManager.addElement(mainElement, null, { autoFocus: true });
344+
345+
// Push first modal
346+
focusManager.pushLayer(modal1Element);
347+
expect(focusManager.focusPath).toEqual([modal1Element]);
348+
349+
// Push second modal
350+
focusManager.pushLayer(modal2Element);
351+
expect(focusManager.focusPath).toEqual([modal2Element]);
352+
353+
// Pop second modal
354+
focusManager.popLayer();
355+
expect(focusManager.focusPath).toEqual([modal1Element]);
356+
357+
// Pop first modal
358+
focusManager.popLayer();
359+
expect(focusManager.focusPath).toEqual([mainElement]);
360+
});
361+
362+
it('should not close the main layer when popLayer is called', () => {
363+
const mainElement = createMockElement(1, 'main');
364+
365+
// Setup main layer
366+
focusManager.addElement(mainElement, null, { autoFocus: true });
367+
expect(focusManager.focusPath).toEqual([mainElement]);
368+
369+
// Try to pop the main layer - should not do anything
370+
focusManager.popLayer();
371+
expect(focusManager.focusPath).toEqual([mainElement]);
372+
expect(mainElement.focused).toBe(true);
373+
});
374+
375+
it('should close all layers except main when popAllLayers is called', () => {
376+
const mainElement = createMockElement(1, 'main');
377+
const modal1Element = createMockElement(2, 'modal1');
378+
const modal2Element = createMockElement(3, 'modal2');
379+
const modal3Element = createMockElement(4, 'modal3');
380+
381+
// Setup main layer
382+
focusManager.addElement(mainElement, null, { autoFocus: true });
383+
384+
// Push multiple modals
385+
focusManager.pushLayer(modal1Element);
386+
focusManager.pushLayer(modal2Element);
387+
focusManager.pushLayer(modal3Element);
388+
expect(focusManager.focusPath).toEqual([modal3Element]);
389+
390+
// Pop all layers
391+
focusManager.popAllLayers();
392+
expect(focusManager.focusPath).toEqual([mainElement]);
393+
expect(modal1Element.focused).toBe(false);
394+
expect(modal2Element.focused).toBe(false);
395+
expect(modal3Element.focused).toBe(false);
396+
expect(mainElement.focused).toBe(true);
397+
});
398+
399+
it('should emit modalOpened and modalClosed events', () => {
400+
const mainElement = createMockElement(1, 'main');
401+
const modalElement = createMockElement(2, 'modal');
402+
const modalOpenedSpy = vi.fn();
403+
const modalClosedSpy = vi.fn();
404+
405+
focusManager.on('modalOpened', modalOpenedSpy);
406+
focusManager.on('modalClosed', modalClosedSpy);
407+
408+
// Setup main layer
409+
focusManager.addElement(mainElement, null, { autoFocus: true });
410+
411+
// Push modal layer
412+
focusManager.pushLayer(modalElement);
413+
expect(modalOpenedSpy).toHaveBeenCalledWith(
414+
modalElement,
415+
undefined,
416+
undefined,
417+
undefined,
418+
undefined,
419+
);
420+
421+
// Pop modal layer
422+
focusManager.popLayer();
423+
expect(modalClosedSpy).toHaveBeenCalledWith(
424+
modalElement,
425+
undefined,
426+
undefined,
427+
undefined,
428+
undefined,
429+
);
430+
});
431+
432+
it('should handle complex hierarchies within layers', () => {
433+
const mainParent = createMockElement(1, 'mainParent');
434+
const mainChild = createMockElement(2, 'mainChild');
435+
const modalParent = createMockElement(3, 'modalParent');
436+
const modalChild1 = createMockElement(4, 'modalChild1');
437+
const modalChild2 = createMockElement(5, 'modalChild2');
438+
439+
// Setup main layer hierarchy
440+
focusManager.addElement(mainParent, null, { autoFocus: true });
441+
focusManager.addElement(mainChild, mainParent, { autoFocus: true });
442+
expect(focusManager.focusPath).toEqual([mainParent, mainChild]);
443+
444+
// Push modal with hierarchy
445+
focusManager.pushLayer(modalParent);
446+
focusManager.addElement(modalChild1, modalParent, { autoFocus: true });
447+
focusManager.addElement(modalChild2, modalParent, { autoFocus: false });
448+
expect(focusManager.focusPath).toEqual([modalParent, modalChild1]);
449+
450+
// Focus different child in modal
451+
focusManager.focus(modalChild2);
452+
expect(focusManager.focusPath).toEqual([modalParent, modalChild2]);
453+
454+
// Pop modal and verify main layer is restored
455+
focusManager.popLayer();
456+
expect(focusManager.focusPath).toEqual([mainParent, mainChild]);
457+
});
458+
459+
it('should properly handle element removal within layers', () => {
460+
const mainElement = createMockElement(1, 'main');
461+
const modalParent = createMockElement(2, 'modalParent');
462+
const modalChild1 = createMockElement(3, 'modalChild1');
463+
const modalChild2 = createMockElement(4, 'modalChild2');
464+
465+
// Setup main layer
466+
focusManager.addElement(mainElement, null, { autoFocus: true });
467+
468+
// Push modal with children
469+
focusManager.pushLayer(modalParent);
470+
focusManager.addElement(modalChild1, modalParent, { autoFocus: true });
471+
focusManager.addElement(modalChild2, modalParent, { autoFocus: false });
472+
expect(focusManager.focusPath).toEqual([modalParent, modalChild1]);
473+
474+
// Remove focused child, should focus next available
475+
focusManager.removeElement(modalChild1);
476+
expect(focusManager.focusPath).toEqual([modalParent, modalChild2]);
477+
478+
// Remove last child, should focus parent
479+
focusManager.removeElement(modalChild2);
480+
expect(focusManager.focusPath).toEqual([modalParent]);
481+
});
482+
});
253483
});

0 commit comments

Comments
 (0)