-
Notifications
You must be signed in to change notification settings - Fork 45
Refactor focus management in FocusElements using callback refs #9852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
90334b2
feat(tree): enhance focus management and caching for tree items
deleonio 1dab9d0
fix(tree-item): improve shadow DOM element selection and focus handling
deleonio 42360a5
Update all snapshots
deleonio c237e6c
refactor: update focus handling in components to use delegateFocus an…
deleonio c5c35b3
fix: update focus methods to use Promise.resolve for consistency
deleonio 740b72d
fix: update focus reference methods to accept optional parameters for…
deleonio c9aab5a
fix: remove unnecessary type assertions for host in button and select…
deleonio 523eb43
fix: update focus methods to use setFocus utility for consistency
deleonio 18b7a60
fix: update focus methods to consistently use setFocus utility across…
deleonio d025915
fix: refactor waitForThemed function to improve timeout handling and …
deleonio d171c7b
fix: enhance focus handling for elements in Shadow DOM and improve er…
deleonio 1a8c59a
fix: ensure animation frame is canceled on component disconnect
deleonio 33204ac
fix: update comments in getFocusElements for clarity on focus testing…
deleonio 559ccae
fix: simplify test descriptions for focus delegation in input-checkbo…
deleonio b8b407f
fix: update focus propagation concept documentation for clarity and s…
deleonio 41004be
fix: move the focus propagation concept file in docs folder
deleonio 6848ba1
Merge branch 'develop' into claude/fix-focus-elements-eIVnw
deleonio 2a3e4ee
Update all snapshots
deleonio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,288 @@ | ||
| # Focus Propagation Concept | ||
|
|
||
| Beschreibt, wie der Focus durch die KoliBri-Komponentenschichten delegiert wird: Shadow DOM → (optional Light DOM) → HTML5-Element. | ||
|
|
||
| ## Überblick | ||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| ## Architektur | ||
|
|
||
| ### Zwei Varianten der Focus-Delegation | ||
|
|
||
| Es gibt zwei Varianten, je nach Komponentenaufbau: | ||
|
|
||
| **Variante A: Shadow → Light DOM WC → HTML5-Element** (z. B. `kol-button`) | ||
|
|
||
| ``` | ||
| ┌─────────────────────────────────────────────────────────┐ | ||
| │ Shadow Component: kol-button (shadow: true) │ | ||
| │ focus() → delegateFocus(host, () => setFocus(wcRef)) │ | ||
| └─────────────────────────┬───────────────────────────────┘ | ||
| ↓ | ||
| ┌─────────────────────────────────────────────────────────┐ | ||
| │ Light DOM Component: kol-button-wc (shadow: false) │ | ||
| │ focus() → setFocus(buttonRef) │ | ||
| └─────────────────────────┬───────────────────────────────┘ | ||
| ↓ | ||
| ┌─────────────────────────────────────────────────────────┐ | ||
| │ HTML5 Element: <button> │ | ||
| │ Tatsächlich fokussierbar │ | ||
| └─────────────────────────────────────────────────────────┘ | ||
| ``` | ||
|
|
||
| **Variante B: Shadow → HTML5-Element direkt** (z. B. `kol-input-text`) | ||
|
|
||
| ``` | ||
| ┌─────────────────────────────────────────────────────────┐ | ||
| │ Shadow Component: kol-input-text (shadow: true) │ | ||
| │ focus() → delegateFocus(host, () => setFocus(inputRef)) │ | ||
| └─────────────────────────┬───────────────────────────────┘ | ||
| ↓ | ||
| ┌─────────────────────────────────────────────────────────┐ | ||
| │ HTML5 Element: <input> │ | ||
| │ Tatsächlich fokussierbar │ | ||
| └─────────────────────────────────────────────────────────┘ | ||
| ``` | ||
|
|
||
| ### Das `data-themed`-Attribut | ||
|
|
||
| Das Theme-System setzt das `data-themed`-Attribut auf Shadow-Komponenten, sobald die Adopted Style Sheets geladen sind. Der Ablauf: | ||
|
|
||
| 1. **Theme-Registrierung:** Beim Bootstrapping werden Themes über `register()` aus dem `adopted-style-sheets`-Paket registriert (`packages/components/src/core/bootstrap.ts`). | ||
|
|
||
| 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. | ||
|
|
||
| 3. **Markierung:** `setThemeStyle()` setzt nach erfolgreicher Style-Anwendung das `data-themed`-Attribut auf dem Host-Element. | ||
|
|
||
| ## Utility Functions | ||
|
|
||
| Datei: `packages/components/src/utils/element-focus.ts` | ||
|
|
||
| ### `delegateFocus(host, callback)` | ||
|
|
||
| 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. | ||
|
|
||
| ```typescript | ||
| export async function delegateFocus(host: HTMLElement, callback: () => Promise<void>): Promise<void> { | ||
| try { | ||
| if (!host.hasAttribute('data-themed')) { | ||
| await waitForThemed(host); | ||
| } | ||
| await callback(); | ||
| } catch { | ||
| throw new Error( | ||
| `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.`, | ||
| ); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| **Parameter:** | ||
|
|
||
| - `host` — Das Shadow Component Host-Element (`this.host!`) | ||
| - `callback` — Async Funktion, die `setFocus()` auf dem Ziel-Element aufruft | ||
|
|
||
| **Verhalten:** | ||
|
|
||
| 1. Prüft, ob `data-themed` bereits gesetzt ist | ||
| 2. Falls nicht: wartet über `waitForThemed()` (MutationObserver) bis maximal 5 Sekunden | ||
| 3. Ruft dann den `callback` auf, der den eigentlichen Focus setzt | ||
| 4. Bei Fehler (Timeout oder Focus-Fehler): wirft einen benutzerfreundlichen Fehler | ||
|
|
||
| ### `setFocus(element)` | ||
|
|
||
| Fokussiert ein HTML-Element durch wiederholte Versuche pro Animation Frame. Wird sowohl in Shadow- als auch in Light-DOM-Komponenten verwendet. | ||
|
|
||
| ```typescript | ||
| export async function setFocus(element: HTMLElement): Promise<void> { | ||
| let attempts = 0; | ||
| do { | ||
| if (element) { | ||
| element.focus(); | ||
| } | ||
| await new Promise((r) => requestAnimationFrame(r)); | ||
| attempts++; | ||
| } while (!isActiveElement(element) && attempts < MAX_FOCUS_ATTEMPTS); | ||
| } | ||
| ``` | ||
|
|
||
| **Verhalten:** | ||
|
|
||
| - Ruft `element.focus()` auf und prüft pro Animation Frame, ob das Element fokussiert ist | ||
| - Maximal `MAX_FOCUS_ATTEMPTS` (10) Versuche | ||
| - Nutzt `isActiveElement()` für korrekte Focus-Erkennung innerhalb von Shadow DOMs | ||
|
|
||
| ### `isActiveElement(element)` (intern) | ||
|
|
||
| Prüft, ob ein Element aktuell fokussiert ist. Berücksichtigt dabei korrekt die Shadow-DOM-Grenze. | ||
|
|
||
| ```typescript | ||
| function isActiveElement(element: HTMLElement): boolean { | ||
| const root = element.getRootNode(); | ||
| if (root instanceof ShadowRoot) { | ||
| return root.activeElement === element; | ||
| } | ||
| return document.activeElement === element; | ||
| } | ||
| ``` | ||
|
|
||
| **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. | ||
|
|
||
| ### `waitForThemed(host)` (intern) | ||
|
|
||
| Wartet per MutationObserver darauf, dass das `data-themed`-Attribut auf dem Host-Element gesetzt wird. | ||
|
|
||
| ```typescript | ||
| function waitForThemed(host: HTMLElement): Promise<void> { | ||
| return new Promise<void>((resolve, reject) => { | ||
| const observer = new MutationObserver(() => { | ||
| if (host.hasAttribute('data-themed')) { | ||
| clearTimeout(timeoutId); | ||
| observer.disconnect(); | ||
| resolve(); | ||
| } | ||
| }); | ||
|
|
||
| const timeoutId = setTimeout(() => { | ||
| observer.disconnect(); | ||
| reject(new Error('Timeout waiting for data-themed attribute')); | ||
| }, MAX_TIMEOUT_DURATION); | ||
|
|
||
| observer.observe(host, { | ||
| attributes: true, | ||
| attributeFilter: ['data-themed'], | ||
| }); | ||
| }); | ||
| } | ||
| ``` | ||
|
|
||
| **Verhalten:** | ||
|
|
||
| - Beobachtet Attribut-Änderungen auf dem Host-Element | ||
| - Resolved sofort, wenn `data-themed` gesetzt wird | ||
| - Timeout nach `MAX_TIMEOUT_DURATION` (5000 ms) mit Error | ||
|
|
||
| ### Konstanten | ||
|
|
||
| ```typescript | ||
| const MAX_FOCUS_ATTEMPTS = 10; | ||
| const MAX_TIMEOUT_DURATION = 5000; | ||
| ``` | ||
|
|
||
| ## Umsetzung in Komponenten | ||
|
|
||
| ### Interface | ||
|
|
||
| Alle fokussierbaren Komponenten implementieren das `FocusableElement`-Interface: | ||
|
|
||
| ```typescript | ||
| export interface FocusableElement { | ||
| focus(): Promise<void>; | ||
| } | ||
| ``` | ||
|
|
||
| ### Regel 1: Shadow Component (`shadow: true`) | ||
|
|
||
| **Immer** `delegateFocus()` mit `setFocus()` verwenden. | ||
|
|
||
| #### Variante A: Mit innerem WC-Element | ||
|
|
||
| Wenn die Shadow-Komponente eine Light-DOM-Komponente (`-wc`) rendert: | ||
|
|
||
| ```typescript | ||
| @Component({ tag: 'kol-button', shadow: true }) | ||
| export class KolButton implements ButtonProps, FocusableElement { | ||
| @Element() private readonly host?: HTMLKolButtonElement; | ||
| private buttonWcRef?: HTMLKolButtonWcElement; | ||
|
|
||
| private readonly setButtonWcRef = (ref?: HTMLKolButtonWcElement) => { | ||
| this.buttonWcRef = ref; | ||
| }; | ||
|
|
||
| @Method() | ||
| public async focus(): Promise<void> { | ||
| return delegateFocus(this.host!, () => setFocus(this.buttonWcRef!)); | ||
| } | ||
|
|
||
| public render(): JSX.Element { | ||
| return ( | ||
| <KolButtonWcTag ref={this.setButtonWcRef} /* ...props */> | ||
| <slot name="expert" slot="expert"></slot> | ||
| </KolButtonWcTag> | ||
| ); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Weitere Beispiele: `kol-link`, `kol-button-link`, `kol-link-button`, `kol-select`, `kol-accordion`, `kol-details` | ||
|
|
||
| #### Variante B: Direkt auf HTML5-Element | ||
|
|
||
| Wenn die Shadow-Komponente das fokussierbare HTML5-Element direkt rendert (ohne `-wc`-Zwischenschicht): | ||
|
|
||
| ```typescript | ||
| @Component({ tag: 'kol-input-text', shadow: true }) | ||
| export class KolInputText implements InputTextAPI, FocusableElement { | ||
| @Element() private readonly host?: HTMLKolInputTextElement; | ||
| private inputRef?: HTMLInputElement; | ||
|
|
||
| private readonly setInputRef = (ref?: HTMLInputElement) => { | ||
| this.inputRef = ref; | ||
| }; | ||
|
|
||
| @Method() | ||
| public async focus() { | ||
| return delegateFocus(this.host!, () => setFocus(this.inputRef!)); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Weitere Beispiele: `kol-input-email`, `kol-input-number`, `kol-input-file`, `kol-textarea`, `kol-combobox`, `kol-single-select`, `kol-input-radio` | ||
|
|
||
| ### Regel 2: Light DOM Component (`shadow: false`) | ||
|
|
||
| **Nur** `setFocus()` verwenden. Kein `delegateFocus()` nötig, da kein Shadow DOM vorhanden ist und das Theme-System nicht abgewartet werden muss. | ||
|
|
||
| ```typescript | ||
| @Component({ tag: 'kol-button-wc', shadow: false }) | ||
| export class KolButtonWc implements ButtonAPI, FocusableElement { | ||
| private buttonRef?: HTMLButtonElement; | ||
|
|
||
| private readonly setButtonRef = (ref?: HTMLButtonElement) => { | ||
| this.buttonRef = ref; | ||
| }; | ||
|
|
||
| @Method() | ||
| public async focus(): Promise<void> { | ||
| return setFocus(this.buttonRef!); | ||
| } | ||
|
|
||
| public render(): JSX.Element { | ||
| return ( | ||
| <Host> | ||
| <button ref={this.setButtonRef}> | ||
| {/* content */} | ||
| </button> | ||
| </Host> | ||
| ); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Zusammenfassung der Regeln | ||
|
|
||
| | Komponente | `shadow` | Focus-Methode | | ||
| | ------------------- | -------- | ------------------------------------------------ | | ||
| | Shadow Component | `true` | `delegateFocus(this.host!, () => setFocus(ref))` | | ||
| | Light DOM Component | `false` | `setFocus(ref)` | | ||
|
|
||
| **Wichtig:** | ||
|
|
||
| - `delegateFocus` darf **nur** in `shadow: true`-Komponenten verwendet werden | ||
| - `setFocus` wird **in beiden Fällen** als eigentliche Focus-Funktion genutzt | ||
| - Ref-Callbacks speichern die Referenz zum Ziel-Element (`ref={this.setXxxRef}`) | ||
| - Alle `focus()`-Methoden sind `async` und geben `Promise<void>` zurück | ||
| - Die `@Method()`-Dekorator macht die Methode auf dem Custom Element aufrufbar |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.