Skip to content

Commit 95deca7

Browse files
committed
fix: propagate focus reliably across components to prevent layout issues
1 parent 67f22f5 commit 95deca7

File tree

28 files changed

+120
-122
lines changed

28 files changed

+120
-122
lines changed

packages/components/src/components/_skeleton/ARC42.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ Both are functionally equivalent. Pattern A is more concise; Pattern B allows ad
203203
Arrow function properties automatically bind `this` at definition time. **Never** create new bound instances with `.bind(this)` in DOM event registration, as this causes listener accumulation and memory leaks:
204204

205205
**Memory Leak — DO NOT DO THIS:**
206+
206207
```tsx
207208
private handleToggle(event: Event) { /* ... */ }
208209

@@ -217,6 +218,7 @@ disconnectedCallback() {
217218
```
218219

219220
**Correct — Arrow Function Property (already bound):**
221+
220222
```tsx
221223
private handleToggle = (event: Event) => { /* ... */ } // Arrow property — this is bound here
222224

@@ -246,6 +248,7 @@ private catchElement = (element?: HTMLElement): void => {
246248
```
247249

248250
**Why this matters:**
251+
249252
- `.bind(this)` creates a new function on each call — `addEventListener` and `removeEventListener` must receive the **exact same function reference** to match
250253
- When references don't match, `removeEventListener` silently fails
251254
- Listeners accumulate over time, slowing down event handling and creating garbage collection barriers

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { JSX } from '@stencil/core';
33
import { Component, Element, h, Method, Prop, State, Watch } from '@stencil/core';
44
import KolCollapsibleFc, { type CollapsibleProps } from '../../functional-components/Collapsible';
5+
import { propagateFocus } from '../../utils/element-focus';
56
import type {
67
AccordionAPI,
78
AccordionCallbacksPropType,
@@ -53,7 +54,7 @@ export class KolAccordion implements AccordionAPI, FocusableElement {
5354
*/
5455
@Method()
5556
public async focus() {
56-
return Promise.resolve(this.buttonWcRef?.focus());
57+
await propagateFocus(this.host, this.buttonWcRef);
5758
}
5859

5960
private handleOnClick = (event: MouseEvent) => {

packages/components/src/components/button-link/shadow.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { JSX } from '@stencil/core';
2-
import { Component, h, Method, Prop } from '@stencil/core';
2+
import { Component, Element, h, Method, Prop } from '@stencil/core';
33
import { KolButtonWcTag } from '../../core/component-names';
4+
import { propagateFocus } from '../../utils/element-focus';
45
import type {
56
AccessKeyPropType,
67
AlternativeButtonLinkRolePropType,
@@ -41,6 +42,7 @@ import type {
4142
shadow: true,
4243
})
4344
export class KolButtonLink implements ButtonLinkProps, FocusableElement {
45+
@Element() private readonly host?: HTMLKolButtonLinkElement;
4446
private buttonWcRef?: HTMLKolButtonWcElement;
4547

4648
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
@@ -61,7 +63,7 @@ export class KolButtonLink implements ButtonLinkProps, FocusableElement {
6163
*/
6264
@Method()
6365
public async focus() {
64-
return Promise.resolve(this.buttonWcRef?.focus());
66+
await propagateFocus(this.host, this.buttonWcRef);
6567
}
6668

6769
public render(): JSX.Element {

packages/components/src/components/button/component.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import type { AriaHasPopupPropType } from '../../schema/props/aria-has-popup';
5757
import { validateAccessAndShortKey } from '../../schema/validators/access-and-short-key';
5858
import clsx from '../../utils/clsx';
5959
import { dispatchDomEvent, KolEvent } from '../../utils/events';
60+
import { propagateFocus } from '../../utils/element-focus';
6061
import { propagateResetEventToForm, propagateSubmitEventToForm } from '../form/controller';
6162
import { AssociatedInputController } from '../input-adapter-leanup/associated.controller';
6263

@@ -77,12 +78,7 @@ export class KolButtonWc implements ButtonAPI, FocusableElement {
7778
*/
7879
@Method()
7980
public async focus() {
80-
return new Promise<void>((resolve) => {
81-
requestAnimationFrame(() => {
82-
this.buttonRef?.focus();
83-
resolve();
84-
});
85-
});
81+
await propagateFocus(this.host, this.buttonRef);
8682
}
8783

8884
private readonly hideTooltip = () => {

packages/components/src/components/button/shadow.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { JSX } from '@stencil/core';
2-
import { Component, h, Method, Prop } from '@stencil/core';
2+
import { Component, Element, h, Method, Prop } from '@stencil/core';
33
import { KolButtonWcTag } from '../../core/component-names';
4+
import { propagateFocus } from '../../utils/element-focus';
45
import type {
56
AccessKeyPropType,
67
AlternativeButtonLinkRolePropType,
@@ -33,6 +34,7 @@ import type {
3334
shadow: true,
3435
})
3536
export class KolButton implements ButtonProps, FocusableElement {
37+
@Element() private readonly host?: HTMLKolButtonElement;
3638
private buttonWcRef?: HTMLKolButtonWcElement;
3739

3840
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
@@ -53,7 +55,7 @@ export class KolButton implements ButtonProps, FocusableElement {
5355
*/
5456
@Method()
5557
public async focus() {
56-
return Promise.resolve(this.buttonWcRef?.focus());
58+
await propagateFocus(this.host, this.buttonWcRef);
5759
}
5860

5961
public render(): JSX.Element {

packages/components/src/components/combobox/shadow.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ComboboxAPI,
1515
ComboboxStates,
1616
DisabledPropType,
17+
FocusableElement,
1718
HideLabelPropType,
1819
HideMsgPropType,
1920
HintPropType,
@@ -34,6 +35,7 @@ import type {
3435
import type { EventDetail } from '../../schema/interfaces/EventDetail';
3536
import clsx from '../../utils/clsx';
3637
import { nonce } from '../../utils/dev.utils';
38+
import { propagateFocus } from '../../utils/element-focus';
3739
import { ComboboxController } from './controller';
3840

3941
/**
@@ -46,8 +48,8 @@ import { ComboboxController } from './controller';
4648
},
4749
shadow: true,
4850
})
49-
export class KolCombobox implements ComboboxAPI {
50-
@Element() private readonly host?: HTMLElement;
51+
export class KolCombobox implements ComboboxAPI, FocusableElement {
52+
@Element() private readonly host?: HTMLKolComboboxElement;
5153
private refInput?: HTMLInputElement;
5254
private refSuggestions: HTMLLIElement[] = [];
5355
private _focusedOptionIndex: number = -1;
@@ -67,12 +69,7 @@ export class KolCombobox implements ComboboxAPI {
6769
*/
6870
@Method()
6971
public async focus() {
70-
return new Promise<void>((resolve) => {
71-
requestAnimationFrame(() => {
72-
this.refInput?.focus();
73-
resolve();
74-
});
75-
});
72+
await propagateFocus(this.host, this.refInput);
7673
}
7774

7875
private toggleListbox = () => {

packages/components/src/components/details/shadow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Component, Element, h, type JSX, Method, Prop, State, Watch } from '@stencil/core';
22
import KolCollapsibleFc, { type CollapsibleProps } from '../../functional-components/Collapsible';
33
import type { DetailsAPI, DetailsCallbacksPropType, DetailsStates, DisabledPropType, FocusableElement, HeadingLevel, LabelPropType } from '../../schema';
4+
import { propagateFocus } from '../../utils/element-focus';
45
import { validateDetailsCallbacks, validateDisabled, validateLabel, validateOpen } from '../../schema';
56
import { nonce } from '../../utils/dev.utils';
67
import { dispatchDomEvent, KolEvent } from '../../utils/events';
@@ -39,7 +40,7 @@ export class KolDetails implements DetailsAPI, FocusableElement {
3940
*/
4041
@Method()
4142
public async focus() {
42-
return Promise.resolve(this.buttonWcRef?.focus());
43+
await propagateFocus(this.host, this.buttonWcRef);
4344
}
4445

4546
private toggleTimeout?: ReturnType<typeof setTimeout>;

packages/components/src/components/input-checkbox/shadow.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
TooltipAlignPropType,
2727
} from '../../schema';
2828

29+
import { propagateFocus } from '../../utils/element-focus';
2930
import { nonce } from '../../utils/dev.utils';
3031
import { InputCheckboxController } from './controller';
3132

@@ -75,12 +76,7 @@ export class KolInputCheckbox implements InputCheckboxAPI, FocusableElement {
7576
*/
7677
@Method()
7778
public async focus() {
78-
return new Promise<void>((resolve) => {
79-
requestAnimationFrame(() => {
80-
this.inputRef?.focus();
81-
resolve();
82-
});
83-
});
79+
await propagateFocus(this.host, this.inputRef);
8480
}
8581

8682
private getFormFieldProps(): FormFieldStateWrapperProps {

packages/components/src/components/input-color/shadow.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import KolFormFieldStateWrapperFc, { type FormFieldStateWrapperProps } from '../
2626
import KolInputContainerFc from '../../functional-component-wrappers/InputContainerStateWrapper/InputContainerStateWrapper';
2727
import KolInputStateWrapperFc, { type InputStateWrapperProps } from '../../functional-component-wrappers/InputStateWrapper/InputStateWrapper';
2828
import { nonce } from '../../utils/dev.utils';
29+
import { propagateFocus } from '../../utils/element-focus';
2930
import { InputColorController } from './controller';
3031

3132
/**
@@ -96,12 +97,7 @@ export class KolInputColor implements InputColorAPI, FocusableElement {
9697
*/
9798
@Method()
9899
public async focus() {
99-
return new Promise<void>((resolve) => {
100-
requestAnimationFrame(() => {
101-
this.refInputText?.focus();
102-
resolve();
103-
});
104-
});
100+
await propagateFocus(this.host, this.refInputText);
105101
}
106102

107103
private get hasSuggestions(): boolean {

packages/components/src/components/input-date/shadow.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { deprecatedHint } from '../../schema';
3333
import KolFormFieldStateWrapperFc, { type FormFieldStateWrapperProps } from '../../functional-component-wrappers/FormFieldStateWrapper/FormFieldStateWrapper';
3434
import KolInputContainerFc from '../../functional-component-wrappers/InputContainerStateWrapper/InputContainerStateWrapper';
3535
import KolInputStateWrapperFc, { type InputStateWrapperProps } from '../../functional-component-wrappers/InputStateWrapper/InputStateWrapper';
36+
import { propagateFocus } from '../../utils/element-focus';
3637
import { nonce } from '../../utils/dev.utils';
3738
import { propagateSubmitEventToForm } from '../form/controller';
3839
import { InputDateController } from './controller';
@@ -73,12 +74,7 @@ export class KolInputDate implements InputDateAPI, FocusableElement {
7374
*/
7475
@Method()
7576
public async focus() {
76-
return new Promise<void>((resolve) => {
77-
requestAnimationFrame(() => {
78-
this.inputRef?.focus();
79-
resolve();
80-
});
81-
});
77+
await propagateFocus(this.host, this.inputRef);
8278
}
8379

8480
/**

0 commit comments

Comments
 (0)