Skip to content

Commit f4bd861

Browse files
authored
Refactor focus management in FocusElements using callback refs (#9852)
2 parents 43d1fcd + 2a3e4ee commit f4bd861

File tree

60 files changed

+1306
-291
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+1306
-291
lines changed

docs/FOCUS_PROPAGATION_CONCEPT.md

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
# Focus Propagation Concept
2+
3+
Beschreibt, wie der Focus durch die KoliBri-Komponentenschichten delegiert wird: Shadow DOM → (optional Light DOM) → HTML5-Element.
4+
5+
## Überblick
6+
7+
KoliBri Web Components verwenden Shadow DOM für Style-Isolation. Da der Browser fokussierbare Elemente innerhalb eines Shadow DOM nicht direkt ansteuern kann, muss der Focus programmatisch von der öffentlichen Shadow-Komponente an das tatsächlich fokussierbare HTML5-Element weitergeleitet werden.
8+
9+
Die zentrale Herausforderung: Bevor der Focus gesetzt werden kann, müssen die Adopted Style Sheets geladen und angewendet sein. Ohne diese Absicherung kann es zu Race Conditions kommen — der Focus wird auf ein Element gesetzt, das noch nicht vollständig gerendert ist.
10+
11+
## Architektur
12+
13+
### Zwei Varianten der Focus-Delegation
14+
15+
Es gibt zwei Varianten, je nach Komponentenaufbau:
16+
17+
**Variante A: Shadow → Light DOM WC → HTML5-Element** (z. B. `kol-button`)
18+
19+
```
20+
┌─────────────────────────────────────────────────────────┐
21+
│ Shadow Component: kol-button (shadow: true) │
22+
│ focus() → delegateFocus(host, () => setFocus(wcRef)) │
23+
└─────────────────────────┬───────────────────────────────┘
24+
25+
┌─────────────────────────────────────────────────────────┐
26+
│ Light DOM Component: kol-button-wc (shadow: false) │
27+
│ focus() → setFocus(buttonRef) │
28+
└─────────────────────────┬───────────────────────────────┘
29+
30+
┌─────────────────────────────────────────────────────────┐
31+
│ HTML5 Element: <button> │
32+
│ Tatsächlich fokussierbar │
33+
└─────────────────────────────────────────────────────────┘
34+
```
35+
36+
**Variante B: Shadow → HTML5-Element direkt** (z. B. `kol-input-text`)
37+
38+
```
39+
┌─────────────────────────────────────────────────────────┐
40+
│ Shadow Component: kol-input-text (shadow: true) │
41+
│ focus() → delegateFocus(host, () => setFocus(inputRef)) │
42+
└─────────────────────────┬───────────────────────────────┘
43+
44+
┌─────────────────────────────────────────────────────────┐
45+
│ HTML5 Element: <input> │
46+
│ Tatsächlich fokussierbar │
47+
└─────────────────────────────────────────────────────────┘
48+
```
49+
50+
### Das `data-themed`-Attribut
51+
52+
Das Theme-System setzt das `data-themed`-Attribut auf Shadow-Komponenten, sobald die Adopted Style Sheets geladen sind. Der Ablauf:
53+
54+
1. **Theme-Registrierung:** Beim Bootstrapping werden Themes über `register()` aus dem `adopted-style-sheets`-Paket registriert (`packages/components/src/core/bootstrap.ts`).
55+
56+
2. **Style-Anwendung:** Stencils `setMode()`-Callback wird für jede Komponente aufgerufen (`packages/components/src/global/script.ts`). Dort wird `setThemeStyle(elm, getThemeDetails(elm))` aufgerufen, um die Adopted Style Sheets in den Shadow DOM zu injizieren.
57+
58+
3. **Markierung:** `setThemeStyle()` setzt nach erfolgreicher Style-Anwendung das `data-themed`-Attribut auf dem Host-Element.
59+
60+
## Utility Functions
61+
62+
Datei: `packages/components/src/utils/element-focus.ts`
63+
64+
### `delegateFocus(host, callback)`
65+
66+
Zentrale Focus-Delegations-Funktion für **Shadow Components** (`shadow: true`). Wartet auf die Theme-Bereitschaft des Host-Elements und führt dann die Focus-Callback-Funktion aus.
67+
68+
```typescript
69+
export async function delegateFocus(host: HTMLElement, callback: () => Promise<void>): Promise<void> {
70+
try {
71+
if (!host.hasAttribute('data-themed')) {
72+
await waitForThemed(host);
73+
}
74+
await callback();
75+
} catch {
76+
throw new Error(
77+
`The interactive element inside the KoliBri web component could not be focused. Try calling the focus method on the web component after a short delay again.`,
78+
);
79+
}
80+
}
81+
```
82+
83+
**Parameter:**
84+
85+
- `host` — Das Shadow Component Host-Element (`this.host!`)
86+
- `callback` — Async Funktion, die `setFocus()` auf dem Ziel-Element aufruft
87+
88+
**Verhalten:**
89+
90+
1. Prüft, ob `data-themed` bereits gesetzt ist
91+
2. Falls nicht: wartet über `waitForThemed()` (MutationObserver) bis maximal 5 Sekunden
92+
3. Ruft dann den `callback` auf, der den eigentlichen Focus setzt
93+
4. Bei Fehler (Timeout oder Focus-Fehler): wirft einen benutzerfreundlichen Fehler
94+
95+
### `setFocus(element)`
96+
97+
Fokussiert ein HTML-Element durch wiederholte Versuche pro Animation Frame. Wird sowohl in Shadow- als auch in Light-DOM-Komponenten verwendet.
98+
99+
```typescript
100+
export async function setFocus(element: HTMLElement): Promise<void> {
101+
let attempts = 0;
102+
do {
103+
if (element) {
104+
element.focus();
105+
}
106+
await new Promise((r) => requestAnimationFrame(r));
107+
attempts++;
108+
} while (!isActiveElement(element) && attempts < MAX_FOCUS_ATTEMPTS);
109+
}
110+
```
111+
112+
**Verhalten:**
113+
114+
- Ruft `element.focus()` auf und prüft pro Animation Frame, ob das Element fokussiert ist
115+
- Maximal `MAX_FOCUS_ATTEMPTS` (10) Versuche
116+
- Nutzt `isActiveElement()` für korrekte Focus-Erkennung innerhalb von Shadow DOMs
117+
118+
### `isActiveElement(element)` (intern)
119+
120+
Prüft, ob ein Element aktuell fokussiert ist. Berücksichtigt dabei korrekt die Shadow-DOM-Grenze.
121+
122+
```typescript
123+
function isActiveElement(element: HTMLElement): boolean {
124+
const root = element.getRootNode();
125+
if (root instanceof ShadowRoot) {
126+
return root.activeElement === element;
127+
}
128+
return document.activeElement === element;
129+
}
130+
```
131+
132+
**Warum nötig:** `document.activeElement` zeigt bei fokussierten Elementen innerhalb eines Shadow DOM nur den Shadow Host, nicht das tatsächlich fokussierte Element. Daher wird `shadowRoot.activeElement` geprüft.
133+
134+
### `waitForThemed(host)` (intern)
135+
136+
Wartet per MutationObserver darauf, dass das `data-themed`-Attribut auf dem Host-Element gesetzt wird.
137+
138+
```typescript
139+
function waitForThemed(host: HTMLElement): Promise<void> {
140+
return new Promise<void>((resolve, reject) => {
141+
const observer = new MutationObserver(() => {
142+
if (host.hasAttribute('data-themed')) {
143+
clearTimeout(timeoutId);
144+
observer.disconnect();
145+
resolve();
146+
}
147+
});
148+
149+
const timeoutId = setTimeout(() => {
150+
observer.disconnect();
151+
reject(new Error('Timeout waiting for data-themed attribute'));
152+
}, MAX_TIMEOUT_DURATION);
153+
154+
observer.observe(host, {
155+
attributes: true,
156+
attributeFilter: ['data-themed'],
157+
});
158+
});
159+
}
160+
```
161+
162+
**Verhalten:**
163+
164+
- Beobachtet Attribut-Änderungen auf dem Host-Element
165+
- Resolved sofort, wenn `data-themed` gesetzt wird
166+
- Timeout nach `MAX_TIMEOUT_DURATION` (5000 ms) mit Error
167+
168+
### Konstanten
169+
170+
```typescript
171+
const MAX_FOCUS_ATTEMPTS = 10;
172+
const MAX_TIMEOUT_DURATION = 5000;
173+
```
174+
175+
## Umsetzung in Komponenten
176+
177+
### Interface
178+
179+
Alle fokussierbaren Komponenten implementieren das `FocusableElement`-Interface:
180+
181+
```typescript
182+
export interface FocusableElement {
183+
focus(): Promise<void>;
184+
}
185+
```
186+
187+
### Regel 1: Shadow Component (`shadow: true`)
188+
189+
**Immer** `delegateFocus()` mit `setFocus()` verwenden.
190+
191+
#### Variante A: Mit innerem WC-Element
192+
193+
Wenn die Shadow-Komponente eine Light-DOM-Komponente (`-wc`) rendert:
194+
195+
```typescript
196+
@Component({ tag: 'kol-button', shadow: true })
197+
export class KolButton implements ButtonProps, FocusableElement {
198+
@Element() private readonly host?: HTMLKolButtonElement;
199+
private buttonWcRef?: HTMLKolButtonWcElement;
200+
201+
private readonly setButtonWcRef = (ref?: HTMLKolButtonWcElement) => {
202+
this.buttonWcRef = ref;
203+
};
204+
205+
@Method()
206+
public async focus(): Promise<void> {
207+
return delegateFocus(this.host!, () => setFocus(this.buttonWcRef!));
208+
}
209+
210+
public render(): JSX.Element {
211+
return (
212+
<KolButtonWcTag ref={this.setButtonWcRef} /* ...props */>
213+
<slot name="expert" slot="expert"></slot>
214+
</KolButtonWcTag>
215+
);
216+
}
217+
}
218+
```
219+
220+
Weitere Beispiele: `kol-link`, `kol-button-link`, `kol-link-button`, `kol-select`, `kol-accordion`, `kol-details`
221+
222+
#### Variante B: Direkt auf HTML5-Element
223+
224+
Wenn die Shadow-Komponente das fokussierbare HTML5-Element direkt rendert (ohne `-wc`-Zwischenschicht):
225+
226+
```typescript
227+
@Component({ tag: 'kol-input-text', shadow: true })
228+
export class KolInputText implements InputTextAPI, FocusableElement {
229+
@Element() private readonly host?: HTMLKolInputTextElement;
230+
private inputRef?: HTMLInputElement;
231+
232+
private readonly setInputRef = (ref?: HTMLInputElement) => {
233+
this.inputRef = ref;
234+
};
235+
236+
@Method()
237+
public async focus() {
238+
return delegateFocus(this.host!, () => setFocus(this.inputRef!));
239+
}
240+
}
241+
```
242+
243+
Weitere Beispiele: `kol-input-email`, `kol-input-number`, `kol-input-file`, `kol-textarea`, `kol-combobox`, `kol-single-select`, `kol-input-radio`
244+
245+
### Regel 2: Light DOM Component (`shadow: false`)
246+
247+
**Nur** `setFocus()` verwenden. Kein `delegateFocus()` nötig, da kein Shadow DOM vorhanden ist und das Theme-System nicht abgewartet werden muss.
248+
249+
```typescript
250+
@Component({ tag: 'kol-button-wc', shadow: false })
251+
export class KolButtonWc implements ButtonAPI, FocusableElement {
252+
private buttonRef?: HTMLButtonElement;
253+
254+
private readonly setButtonRef = (ref?: HTMLButtonElement) => {
255+
this.buttonRef = ref;
256+
};
257+
258+
@Method()
259+
public async focus(): Promise<void> {
260+
return setFocus(this.buttonRef!);
261+
}
262+
263+
public render(): JSX.Element {
264+
return (
265+
<Host>
266+
<button ref={this.setButtonRef}>
267+
{/* content */}
268+
</button>
269+
</Host>
270+
);
271+
}
272+
}
273+
```
274+
275+
### Zusammenfassung der Regeln
276+
277+
| Komponente | `shadow` | Focus-Methode |
278+
| ------------------- | -------- | ------------------------------------------------ |
279+
| Shadow Component | `true` | `delegateFocus(this.host!, () => setFocus(ref))` |
280+
| Light DOM Component | `false` | `setFocus(ref)` |
281+
282+
**Wichtig:**
283+
284+
- `delegateFocus` darf **nur** in `shadow: true`-Komponenten verwendet werden
285+
- `setFocus` wird **in beiden Fällen** als eigentliche Focus-Funktion genutzt
286+
- Ref-Callbacks speichern die Referenz zum Ziel-Element (`ref={this.setXxxRef}`)
287+
- Alle `focus()`-Methoden sind `async` und geben `Promise<void>` zurück
288+
- Die `@Method()`-Dekorator macht die Methode auf dem Custom Element aufrufbar

packages/components/src/components/accordion/shadow.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from '../../schema';
1515
import { featureHint, validateAccordionCallbacks, validateDisabled, validateLabel, validateOpen } from '../../schema';
1616
import { nonce } from '../../utils/dev.utils';
17+
import { delegateFocus, setFocus } from '../../utils/element-focus';
1718
import { dispatchDomEvent, KolEvent } from '../../utils/events';
1819
import { watchHeadingLevel } from '../heading/validation';
1920

@@ -44,16 +45,16 @@ export class KolAccordion implements AccordionAPI, FocusableElement {
4445
private readonly nonce = nonce();
4546
private buttonWcRef?: HTMLKolButtonWcElement;
4647

47-
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
48+
private readonly setButtonWcRef = (ref?: HTMLKolButtonWcElement) => {
4849
this.buttonWcRef = ref;
4950
};
5051

5152
/**
5253
* Sets focus on the internal element.
5354
*/
5455
@Method()
55-
public async focus() {
56-
return Promise.resolve(this.buttonWcRef?.focus());
56+
public async focus(): Promise<void> {
57+
return delegateFocus(this.host!, () => setFocus(this.buttonWcRef!));
5758
}
5859

5960
private handleOnClick = (event: MouseEvent) => {
@@ -87,7 +88,7 @@ export class KolAccordion implements AccordionAPI, FocusableElement {
8788
class: rootClass,
8889
HeadingProps: { class: `${rootClass}__heading` },
8990
HeadingButtonProps: {
90-
ref: this.catchRef,
91+
ref: this.setButtonWcRef,
9192
class: `${rootClass}__heading-button`,
9293
},
9394
ContentProps: {

packages/components/src/components/alert/component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class KolAlertWc implements AlertAPI {
2929
private readonly close = () => {
3030
this._on?.onClose?.(new Event('Close'));
3131
if (this.host) {
32-
dispatchDomEvent(this.host as HTMLElement, KolEvent.close);
32+
dispatchDomEvent(this.host, KolEvent.close);
3333
}
3434
};
3535

packages/components/src/components/badge/shadow.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, h, Method, Prop, State, Watch } from '@stencil/core';
1+
import { Component, Element, h, Method, Prop, State, Watch } from '@stencil/core';
22
import { SpanFC } from '../../internal/functional-components/span/component';
33
import type { BadgeAPI, BadgeStates, FocusableElement, InternalButtonProps, KoliBriIconsProp, LabelPropType, PropColor, Stringified } from '../../schema';
44
import { featureHint, handleColorChange, objectObjectHandler, parseJson, setState, validateColor, validateIcons } from '../../schema';
@@ -8,6 +8,7 @@ import { nonce } from '../../utils/dev.utils';
88
import type { JSX } from '@stencil/core';
99
import { KolButtonWcTag } from '../../core/component-names';
1010
import clsx from '../../utils/clsx';
11+
import { delegateFocus, setFocus } from '../../utils/element-focus';
1112
featureHint(`[KolBadge] Optimierung des _color-Properties (rgba, rgb, hex usw.).`);
1213

1314
/**
@@ -22,19 +23,20 @@ featureHint(`[KolBadge] Optimierung des _color-Properties (rgba, rgb, hex usw.).
2223
shadow: true,
2324
})
2425
export class KolBadge implements BadgeAPI, FocusableElement {
26+
@Element() private readonly host?: HTMLKolBadgeElement;
2527
private bgColorStr = '#000';
2628
private colorStr = '#fff';
2729
private readonly id = nonce();
2830
private smartButtonRef?: HTMLKolButtonWcElement;
2931

30-
private readonly catchSmartButtonRef = (ref?: HTMLKolButtonWcElement) => {
32+
private readonly setSmartButtonRef = (ref?: HTMLKolButtonWcElement) => {
3133
this.smartButtonRef = ref;
3234
};
3335

3436
private renderSmartButton(props: InternalButtonProps): JSX.Element {
3537
return (
3638
<KolButtonWcTag
37-
ref={this.catchSmartButtonRef}
39+
ref={this.setSmartButtonRef}
3840
class="kol-badge__smart-button"
3941
_ariaControls={this.id}
4042
_ariaDescription={props._ariaDescription}
@@ -56,7 +58,7 @@ export class KolBadge implements BadgeAPI, FocusableElement {
5658
*/
5759
@Method()
5860
public async focus(): Promise<void> {
59-
return Promise.resolve(this.smartButtonRef?.focus());
61+
return delegateFocus(this.host!, () => setFocus(this.smartButtonRef!));
6062
}
6163

6264
public render(): JSX.Element {

0 commit comments

Comments
 (0)