Skip to content

Commit 0723ebf

Browse files
feat(tooltip, popover2): Share the overlay focus-return suppression:
* Extract the detection into `ɵintentionalFocus$`, driven by the CDK `FocusMonitor`. * Add `focusSilently` for an application moving the focus itself. * Require both a `program` origin and a return from a foreign overlay pane. * Restore the popover2 trigger focus through a `program` origin. * Drop the popover2 `#skipNextFocus` flag. * Drop the tooltip `(focus)` and `(focusout)` host listeners. * Cover both components with focus-return story tests. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2619636 commit 0723ebf

7 files changed

Lines changed: 248 additions & 57 deletions

File tree

packages/ng/popover2/content/popover-content/popover-content.component.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FocusMonitor } from '@angular/cdk/a11y';
12
import { CdkObserveContent } from '@angular/cdk/observers';
23
import { AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, Injector, input, OnDestroy, ViewEncapsulation } from '@angular/core';
34
import { ButtonComponent } from '@lucca-front/ng/button';
@@ -35,6 +36,8 @@ export class PopoverContentComponent implements AfterViewInit, OnDestroy {
3536

3637
content = this.config.content;
3738

39+
#focusMonitor = inject(FocusMonitor);
40+
3841
#focusManager = new PopoverFocusTrap(this.#elementRef.nativeElement, this.config.triggerElement, inject(Injector));
3942

4043
readonly closed$ = new Subject<void>();
@@ -75,8 +78,9 @@ export class PopoverContentComponent implements AfterViewInit, OnDestroy {
7578

7679
close(): void {
7780
if (!this.config.disableInitialTriggerFocus) {
78-
// Focus initial trigger element
79-
this.config.triggerElement.focus();
81+
// - The `program` origin tells this handover apart from the user reaching the trigger.
82+
// - It reopens neither the popover nor a tooltip the trigger carries.
83+
this.#focusMonitor.focusVia(this.config.triggerElement, 'program');
8084
}
8185
// Tell the directive we're closed now
8286
this.#destroyEvents();

packages/ng/popover2/popover.directive.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '@angular/core';
2222
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
2323
import { getPushPanelViewportMargin, intlInputOptions, isNotNil, luBooleanAttribute, luNumberAttribute } from '@lucca-front/ng/core';
24+
import { ɵintentionalFocus$ } from '@lucca/prisme/core';
2425
import { combineLatest, debounce, filter, map, merge, Subject, switchMap, take, timer } from 'rxjs';
2526
import { PopoverContentComponent } from './content/popover-content/popover-content.component';
2627
import { POPOVER_CONFIG, PopoverConfig } from './popover-tokens';
@@ -75,7 +76,6 @@ const defaultPositionPairs: Record<PopoverPosition, ConnectionPositionPair> = {
7576
'[attr.aria-controls]': 'ariaControls',
7677
'(click)': 'onMouseClick()',
7778
'(mouseleave)': 'onMouseLeave()',
78-
'(focus)': 'onFocus()',
7979
'(mouseenter)': 'onMouseEnter()',
8080
'(keydown.Tab)': 'focusBackToContent($event)',
8181
'(keydown.Shift.Tab)': 'focusOutBefore()',
@@ -151,7 +151,6 @@ export class PopoverDirective implements OnDestroy {
151151

152152
#listenToMouseLeave = false;
153153
#listenToMouseEnter = true;
154-
#skipNextFocus = false;
155154

156155
#overlayRef: OverlayRef;
157156

@@ -169,6 +168,10 @@ export class PopoverDirective implements OnDestroy {
169168
protected additionalProviders: Provider[] = [];
170169

171170
constructor() {
171+
ɵintentionalFocus$(this.elementRef)
172+
.pipe(takeUntilDestroyed())
173+
.subscribe(() => this.onFocus());
174+
172175
combineLatest([toObservable(this.luPopoverOpenDelay), toObservable(this.luPopoverCloseDelay), toObservable(this.luPopoverTrigger)])
173176
.pipe(
174177
filter(([, , trigger]) => {
@@ -213,13 +216,8 @@ export class PopoverDirective implements OnDestroy {
213216

214217
onFocus() {
215218
if (this.luPopoverTrigger().includes('focus')) {
216-
if (this.#skipNextFocus) {
217-
this.#skipNextFocus = false;
218-
} else {
219-
this.open$.next('focus');
220-
this.#listenToMouseLeave = true;
221-
this.#skipNextFocus = true;
222-
}
219+
this.open$.next('focus');
220+
this.#listenToMouseLeave = true;
223221
}
224222
}
225223

@@ -325,7 +323,6 @@ export class PopoverDirective implements OnDestroy {
325323
this.#componentRef.mouseEnter$.pipe(takeUntilDestroyed(this.#componentRef.destroyRef), takeUntilDestroyed(this.#destroyRef)).subscribe(() => this.open$.next('hover'));
326324
this.#componentRef.closed$.pipe(take(1), takeUntilDestroyed(this.#componentRef.destroyRef), takeUntilDestroyed(this.#destroyRef)).subscribe(() => {
327325
this.opened.set(false);
328-
this.#skipNextFocus = false;
329326
this.luPopoverClosed.emit();
330327
this.#listenToMouseLeave = false;
331328
if (this.#screenReaderDescription) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './intentional-focus';
12
export * from './push-panel';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
2+
import { DestroyRef, ElementRef, inject } from '@angular/core';
3+
import { Observable } from 'rxjs';
4+
import { filter } from 'rxjs/operators';
5+
import { isNotNil } from '../misc';
6+
7+
function paneOf(element: Element): Element | null {
8+
// A trigger inside an overlay keeps its own behaviour when the focus moves within that same overlay.
9+
return element.closest('.cdk-overlay-pane');
10+
}
11+
12+
const silentRequests = new WeakSet<HTMLElement>();
13+
14+
/**
15+
* Focuses `element` the way a closing overlay does.
16+
*
17+
* - Whatever a user focus reveals — a tooltip, a popover panel — stays hidden.
18+
* - Use it when an application moves the focus itself and wants none of that.
19+
* - Keep `element.focus()` when it wants that reveal.
20+
* - To reveal from an actual overlay handover, focus through `inject(FocusMonitor).focusVia(element, 'keyboard')`.
21+
*/
22+
export function focusSilently(element: HTMLElement): void {
23+
if (element.ownerDocument.activeElement !== element) {
24+
silentRequests.add(element);
25+
}
26+
27+
element.focus();
28+
}
29+
30+
/**
31+
* Emits every focus arrival on `host` that was aimed at `host` itself.
32+
*
33+
* - Skips the handover an overlay performs when it hands the focus back to its trigger on close.
34+
* - Skips the calls to `focusSilently`.
35+
* - Pointer, touch and keyboard arrivals always emit.
36+
* - An application calling `focus()` on the trigger emits too: it stays a request to focus it.
37+
* - Must be called in an injection context.
38+
* - Monitoring stops when that context is destroyed.
39+
*/
40+
export function ɵintentionalFocus$(host: ElementRef<HTMLElement>): Observable<FocusOrigin> {
41+
const element = host.nativeElement;
42+
const focusMonitor = inject(FocusMonitor);
43+
44+
// Some overlays dispose their pane before handing the focus back, leaving nothing to inspect on the return leg.
45+
let leftToPane: Element | null = null;
46+
const trackDeparture = ({ relatedTarget }: FocusEvent) => {
47+
leftToPane = relatedTarget instanceof Element ? paneOf(relatedTarget) : null;
48+
};
49+
element.addEventListener('focusout', trackDeparture);
50+
51+
inject(DestroyRef).onDestroy(() => {
52+
element.removeEventListener('focusout', trackDeparture);
53+
focusMonitor.stopMonitoring(host);
54+
});
55+
56+
return focusMonitor.monitor(host).pipe(
57+
// `null` is the blur, which every trigger already handles on its own.
58+
filter(isNotNil),
59+
filter((origin) => {
60+
const cameBackFromPane = leftToPane;
61+
leftToPane = null;
62+
63+
if (silentRequests.delete(element)) {
64+
return false;
65+
}
66+
67+
// A handover comes back from the pane of another overlay, where an application's own `focus()` does not.
68+
return origin !== 'program' || cameBackFromPane === null || cameBackFromPane === paneOf(element);
69+
}),
70+
);
71+
}

packages/prisme/tooltip/trigger/tooltip-trigger.directive.ts

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
} from '@angular/core';
3131
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
3232
import { SafeHtml } from '@angular/platform-browser';
33-
import { getPushPanelViewportMargin, isNil, isNotNil, ɵeffectWithDeps } from '@lucca/prisme/core';
33+
import { getPushPanelViewportMargin, isNil, isNotNil, ɵeffectWithDeps, ɵintentionalFocus$ } from '@lucca/prisme/core';
3434
import { startWith, timer } from 'rxjs';
3535
import { debounce, filter, map, tap } from 'rxjs/operators';
3636
import { LuTooltipPanelComponent } from '../panel';
@@ -51,8 +51,6 @@ let nextId = 0;
5151
'[attr.id]': 'id()',
5252
'(mouseenter)': 'onMouseEnter()',
5353
'(mouseleave)': 'onMouseLeave()',
54-
'(focus)': 'onFocus()',
55-
'(focusout)': 'onFocusOut($event)',
5654
'(blur)': 'onBlur()',
5755
'(keydown.escape)': 'onEscape($event)',
5856
},
@@ -156,12 +154,13 @@ export class LuTooltipTriggerDirective implements OnDestroy {
156154

157155
#effectRef?: EffectRef;
158156

159-
// pane the focus moved into when it last left this trigger, or null
160-
#focusLeftToPane: Element | null = null;
161-
162157
constructor() {
163158
this.#destroyRef.onDestroy(() => (this.#destroyed = true));
164159

160+
ɵintentionalFocus$(this.#host)
161+
.pipe(takeUntilDestroyed())
162+
.subscribe(() => this.onFocus());
163+
165164
// Action debounce pipeline — kept as Observable since signals can't debounce
166165
toObservable(this.#realAction)
167166
.pipe(
@@ -304,36 +303,11 @@ export class LuTooltipTriggerDirective implements OnDestroy {
304303
}
305304

306305
onFocus() {
307-
const leftToPane = this.#focusLeftToPane;
308-
this.#focusLeftToPane = null;
309-
310-
// A closing overlay hands the focus back to its trigger.
311-
// That is not the user reaching the trigger, so the tooltip stays closed.
312-
if (this.#isForeignPane(leftToPane)) {
313-
return;
314-
}
315-
316306
if (this.#host.nativeElement.getAttribute('aria-expanded') !== 'true') {
317307
this.#action.set('open');
318308
}
319309
}
320310

321-
onFocusOut(event: FocusEvent) {
322-
// Captured on the way out.
323-
// Some overlays dispose their pane before handing the focus back, leaving nothing to inspect.
324-
this.#focusLeftToPane = event.relatedTarget instanceof Element ? this.#paneOf(event.relatedTarget) : null;
325-
}
326-
327-
#isForeignPane(pane: Element | null): boolean {
328-
// Panes, not the whole container.
329-
// A trigger inside an overlay keeps its tooltip when the focus moves within that same overlay.
330-
return isNotNil(pane) && pane !== this.#paneOf(this.#host.nativeElement);
331-
}
332-
333-
#paneOf(element: Element): Element | null {
334-
return element.closest('.cdk-overlay-pane');
335-
}
336-
337311
onBlur() {
338312
this.#action.set('close');
339313
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ButtonComponent } from '@lucca-front/ng/button';
2+
import { configureLuPopover, PopoverDirective } from '@lucca-front/ng/popover2';
3+
import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular-vite';
4+
import { waitForAngular } from '@/helpers/test';
5+
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
6+
import { createTestStory } from '../../../helpers/stories';
7+
8+
export default {
9+
title: 'Documentation/Overlays/Popover2/Retour de focus depuis le panneau',
10+
component: PopoverDirective,
11+
decorators: [
12+
applicationConfig({
13+
providers: [configureLuPopover()],
14+
}),
15+
moduleMetadata({
16+
imports: [ButtonComponent, PopoverDirective],
17+
}),
18+
],
19+
} as Meta;
20+
21+
export const FocusReturn: StoryObj<PopoverDirective> = {
22+
render: () => ({
23+
template: `<p>Le panneau s’ouvre au focus. À sa fermeture, le focus revient sur le déclencheur sans le réouvrir.</p>
24+
25+
<button type="button" luButton="text">Point de départ</button>
26+
27+
<button
28+
type="button"
29+
luButton
30+
[luPopover2]="contentRef"
31+
luPopoverTrigger="hover+focus"
32+
[luPopoverOpenDelay]="0"
33+
[luPopoverCloseDelay]="0"
34+
>Déclencheur</button>
35+
36+
<ng-template #contentRef>
37+
<p>Contenu du panneau.</p>
38+
</ng-template>
39+
`,
40+
}),
41+
};
42+
43+
export const FocusReturnTEST = createTestStory(FocusReturn, async ({ canvasElement, step }) => {
44+
await waitForAngular();
45+
46+
const canvas = within(canvasElement);
47+
const start = () => canvas.getByRole('button', { name: /Point de départ/ });
48+
const trigger = () => canvas.getByRole('button', { name: /Déclencheur/ });
49+
const panel = () => screen.queryByText(/Contenu du panneau/);
50+
51+
const tabToTrigger = async () => {
52+
await userEvent.click(start());
53+
await userEvent.tab();
54+
await expect(trigger()).toHaveFocus();
55+
};
56+
57+
await step('La navigation au clavier ouvre le panneau', async () => {
58+
await tabToTrigger();
59+
await waitFor(() => expect(panel()).toBeVisible());
60+
});
61+
62+
await step('Le retour du focus après une fermeture laisse le panneau fermé', async () => {
63+
await userEvent.tab();
64+
await userEvent.click(await screen.findByRole('button', { name: /Fermer/ }));
65+
await waitForAngular();
66+
await expect(trigger()).toHaveFocus();
67+
await new Promise((resolve) => setTimeout(resolve, 400));
68+
await expect(panel()).toBeNull();
69+
});
70+
71+
await step('Un focus programmatique, hors retour d’overlay, ouvre le panneau', async () => {
72+
start().focus();
73+
trigger().focus();
74+
await waitFor(() => expect(panel()).toBeVisible());
75+
await userEvent.click(await screen.findByRole('button', { name: /Fermer/ }));
76+
await waitForAngular();
77+
});
78+
79+
await step('Une nouvelle navigation au clavier réouvre le panneau', async () => {
80+
await tabToTrigger();
81+
await waitFor(() => expect(panel()).toBeVisible());
82+
});
83+
});

0 commit comments

Comments
 (0)