diff --git a/.changeset/empty-cougars-cross.md b/.changeset/empty-cougars-cross.md
new file mode 100644
index 0000000000..c123591880
--- /dev/null
+++ b/.changeset/empty-cougars-cross.md
@@ -0,0 +1,6 @@
+---
+'@sl-design-system/checkbox': patch
+---
+
+Fix checkbox group not working with tooltips (only `sl-checkbox` elements are tracked by `@queryAssignedElements`).
+Use `ForwardAriaMixin` to forward `aria-describedby` as element references to the inner input element, enabling screen reader support.
diff --git a/packages/components/checkbox/src/checkbox-group.spec.ts b/packages/components/checkbox/src/checkbox-group.spec.ts
index cad3f96582..399ea6aa05 100644
--- a/packages/components/checkbox/src/checkbox-group.spec.ts
+++ b/packages/components/checkbox/src/checkbox-group.spec.ts
@@ -1,5 +1,6 @@
import { type SlFormControlEvent } from '@sl-design-system/form';
import '@sl-design-system/form/register.js';
+import '@sl-design-system/tooltip/register.js';
import { fixture } from '@sl-design-system/vitest-browser-lit';
import { LitElement, type TemplateResult, html } from 'lit';
import { spy } from 'sinon';
@@ -437,4 +438,29 @@ describe('sl-checkbox-group', () => {
expect(fitc.shadowRoot!.activeElement).to.equal(input);
});
});
+
+ describe('with tooltips', () => {
+ beforeEach(async () => {
+ el = await fixture(html`
+
+ A
+ B
+ Tooltip
+ C
+
+ `);
+ });
+
+ it('should only contain checkbox elements in the boxes property', () => {
+ expect(el.boxes).to.have.lengthOf(3);
+ expect(el.boxes?.every(box => box.tagName.toLowerCase() === 'sl-checkbox')).to.be.true;
+ });
+
+ it('should correctly reflect only checkbox values', async () => {
+ await userEvent.click(el.querySelector('sl-checkbox[value="b"]')!);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(el.value).to.deep.equal([null, 'b', null]);
+ });
+ });
});
diff --git a/packages/components/checkbox/src/checkbox-group.stories.ts b/packages/components/checkbox/src/checkbox-group.stories.ts
index 8fc5f1e8ed..de866ac7ad 100644
--- a/packages/components/checkbox/src/checkbox-group.stories.ts
+++ b/packages/components/checkbox/src/checkbox-group.stories.ts
@@ -1,6 +1,8 @@
import '@sl-design-system/button/register.js';
import '@sl-design-system/button-bar/register.js';
import '@sl-design-system/form/register.js';
+import { tooltip } from '@sl-design-system/tooltip';
+import '@sl-design-system/tooltip/register.js';
import { type Meta, type StoryObj } from '@storybook/web-components-vite';
import { type TemplateResult, html } from 'lit';
import '../register.js';
@@ -191,3 +193,37 @@ export const CustomAsyncValidity: Story = {
}
}
};
+
+export const WithTooltips: Story = {
+ render: () => {
+ const onClick = (event: Event & { target: HTMLElement }): void => {
+ event.target.closest('sl-form')?.reportValidity();
+ };
+
+ return html`
+
+ This story demonstrates how to use tooltips with checkboxes inside a checkbox group using
+ both the tooltip() directive and the manual
+ aria-describedby approach with a separate sl-tooltip element.
+
+
+
+
+
+ Newsletter
+ Promotions
+ Promotions tooltip
+ Product updates
+
+
+
+ Report validity
+
+
+ `;
+ }
+};
diff --git a/packages/components/checkbox/src/checkbox-group.ts b/packages/components/checkbox/src/checkbox-group.ts
index 3d5b6db374..57b5b3b9be 100644
--- a/packages/components/checkbox/src/checkbox-group.ts
+++ b/packages/components/checkbox/src/checkbox-group.ts
@@ -75,7 +75,7 @@ export class CheckboxGroup extends FormControlMixin(LitElement) {
readonly internals = this.attachInternals();
/** @internal The slotted checkboxes. */
- @queryAssignedElements() boxes?: Array>;
+ @queryAssignedElements({ selector: 'sl-checkbox' }) boxes?: Array>;
/** @internal Emits when the component loses focus. */
@event({ name: 'sl-blur' }) blurEvent!: EventEmitter;
diff --git a/packages/components/checkbox/src/checkbox.spec.ts b/packages/components/checkbox/src/checkbox.spec.ts
index ffd7c7e591..f9af794cff 100644
--- a/packages/components/checkbox/src/checkbox.spec.ts
+++ b/packages/components/checkbox/src/checkbox.spec.ts
@@ -13,7 +13,9 @@ describe('sl-checkbox', () => {
describe('defaults', () => {
beforeEach(async () => {
- el = await fixture(html`Hello world`);
+ el = await fixture(html`
+ Hello world
+ `);
input = el.querySelector('input')!;
});
@@ -23,6 +25,10 @@ describe('sl-checkbox', () => {
expect(input.type).to.equal('checkbox');
});
+ it('should have aria-describedby in observedAttributes', () => {
+ expect((el.constructor as typeof Checkbox).observedAttributes).to.include('aria-describedby');
+ });
+
it('should not be checked', () => {
expect(el.checked).not.to.be.true;
expect(input.checked).not.to.be.true;
@@ -121,11 +127,43 @@ describe('sl-checkbox', () => {
});
it('should proxy the aria-labelledby attribute to the input element', async () => {
- el.setAttribute('aria-labelledby', 'id');
+ const label = document.createElement('span');
+ label.id = 'my-label';
+ label.textContent = 'My label';
+ el.parentElement!.appendChild(label);
+
+ el.setAttribute('aria-labelledby', 'my-label');
await new Promise(resolve => setTimeout(resolve, 50));
expect(el).to.not.have.attribute('aria-labelledby');
- expect(el.input).to.have.attribute('aria-labelledby', 'id');
+
+ // In same-scope mode, ForwardAriaMixin uses string attributes and
+ // #onLabelSlotChange appends the checkbox's own label IDs.
+ const ids = el.input.getAttribute('aria-labelledby')!.split(/\s+/);
+ expect(ids).to.include('my-label');
+
+ label.remove();
+ });
+
+ it('should proxy the aria-describedby attribute to the input element', async () => {
+ const tooltip = document.createElement('span');
+ tooltip.id = 'my-tooltip';
+ tooltip.textContent = 'My tooltip';
+ el.parentElement!.appendChild(tooltip);
+
+ el.setAttribute('aria-describedby', 'my-tooltip');
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(el).to.not.have.attribute('aria-describedby');
+
+ // In same-scope mode, ForwardAriaMixin uses string attributes.
+ expect(el.input.getAttribute('aria-describedby')).to.equal('my-tooltip');
+
+ tooltip.remove();
+ });
+
+ it('should return the input element from getProxyTarget', () => {
+ expect(el.getProxyTarget()).to.equal(el.input);
});
it('should be pristine', () => {
@@ -284,7 +322,9 @@ describe('sl-checkbox', () => {
describe('disabled', () => {
beforeEach(async () => {
- el = await fixture(html`Hello world`);
+ el = await fixture(html`
+ Hello world
+ `);
input = el.querySelector('input')!;
});
@@ -323,7 +363,9 @@ describe('sl-checkbox', () => {
describe('validation', () => {
beforeEach(async () => {
- el = await fixture(html`Hello world`);
+ el = await fixture(html`
+ Hello world
+ `);
});
it('should be invalid when required and no option is selected', async () => {
@@ -434,7 +476,9 @@ describe('sl-checkbox', () => {
// empty
}
- el = await fixture(html``);
+ el = await fixture(html`
+
+ `);
});
it('should emit an sl-form-control event after first render', () => {
diff --git a/packages/components/checkbox/src/checkbox.stories.ts b/packages/components/checkbox/src/checkbox.stories.ts
index fd7fe9a664..2f99f58c75 100644
--- a/packages/components/checkbox/src/checkbox.stories.ts
+++ b/packages/components/checkbox/src/checkbox.stories.ts
@@ -1,6 +1,8 @@
import '@sl-design-system/button/register.js';
import '@sl-design-system/button-bar/register.js';
import '@sl-design-system/form/register.js';
+import { tooltip } from '@sl-design-system/tooltip';
+import '@sl-design-system/tooltip/register.js';
import { type Meta, type StoryObj } from '@storybook/web-components-vite';
import { type TemplateResult, html, nothing } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
@@ -85,8 +87,9 @@ export default {
.showValid=${showValid}
.value=${value}
size=${ifDefined(size)}
- >${text}
+ ${text}
+
`}
${reportValidity
@@ -193,21 +196,21 @@ export const Indeterminate: StoryObj = {
This checkbox has no internal or external label. It only has an
- aria-label attribute. That attribute is automatically applied to the
- input element.
+ aria-label
+ attribute. That attribute is automatically applied to the
+ input
+ element.
`;
@@ -281,9 +286,9 @@ export const CustomValidity: Story = {
};
return html`
- I agree to all terms & conditions
+
+ I agree to all terms & conditions
+
`;
}
}
@@ -307,10 +312,73 @@ export const CustomAsyncValidity: Story = {
};
return html`
- I agree to all terms & conditions
+
+ I agree to all terms & conditions
+
`;
}
}
};
+
+export const WithTooltip: Story = {
+ render: () => {
+ const onClick = (event: Event & { target: HTMLElement }): void => {
+ event.target.closest('sl-form')?.reportValidity();
+ };
+
+ return html`
+
+ This story demonstrates how to use tooltips with checkboxes using the
+ tooltip()
+ directive,
+ aria-describedby
+ , and
+ aria-labelledby
+ with a separate
+ sl-tooltip
+ element. The
+ ForwardAriaMixin
+ resolves IDs to element references and forwards them to the focusable input element, which
+ properly crosses the shadow DOM boundary.
+
+
+
+
+ Updates
+
+ Updates tooltip
+
+
+ Report validity
+
+
+ `;
+ }
+};
diff --git a/packages/components/checkbox/src/checkbox.ts b/packages/components/checkbox/src/checkbox.ts
index bc2d9528cf..609c5ab30d 100644
--- a/packages/components/checkbox/src/checkbox.ts
+++ b/packages/components/checkbox/src/checkbox.ts
@@ -3,7 +3,7 @@ import { FormControlMixin } from '@sl-design-system/form';
import {
type EventEmitter,
EventsController,
- ObserveAttributesMixin,
+ ForwardAriaMixin,
event
} from '@sl-design-system/shared';
import {
@@ -45,7 +45,8 @@ let nextUniqueId = 0;
*/
@localized()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export class Checkbox extends ObserveAttributesMixin(FormControlMixin(LitElement), [
+export class Checkbox extends ForwardAriaMixin(FormControlMixin(LitElement), [
+ 'aria-describedby',
'aria-disabled',
'aria-label',
'aria-labelledby'
@@ -166,6 +167,7 @@ export class Checkbox extends ObserveAttributesMixin(FormControlMixin(L
}
this.setFormControlElement(this.input);
+ this.setProxyTarget(this.input);
this.#onLabelSlotChange();
}
@@ -283,6 +285,7 @@ export class Checkbox extends ObserveAttributesMixin(FormControlMixin(L
this.#syncInput(this.input);
this.setFormControlElement(this.input);
+ this.setProxyTarget(this.input);
}
}
@@ -315,13 +318,32 @@ export class Checkbox extends ObserveAttributesMixin(FormControlMixin(L
}
requestAnimationFrame(() => {
- if (!this.input.hasAttribute('aria-labelledby') && this.input.labels?.length) {
- this.input.setAttribute(
- 'aria-labelledby',
- Array.from(this.input.labels)
- .map(label => label.id)
- .join(' ')
- );
+ if (
+ (this.hasAttribute('aria-labelledby') || this.ariaLabelledByElements?.length) &&
+ !this.input.labels?.length
+ ) {
+ // ForwardAriaMixin will handle it; no labels to append.
+ return;
+ }
+
+ if (this.input.labels?.length) {
+ const labelIds = Array.from(this.input.labels)
+ .map(label => label.id)
+ .filter(Boolean)
+ .join(' ');
+
+ const existing = this.input.getAttribute('aria-labelledby') ?? '';
+ const existingIds = existing.split(/\s+/).filter(Boolean);
+
+ // Append label IDs that aren't already present
+ const newIds = labelIds.split(/\s+/).filter(id => id && !existingIds.includes(id));
+
+ if (newIds.length) {
+ const combined = [...existingIds, ...newIds].join(' ');
+ this.input.setAttribute('aria-labelledby', combined);
+ } else if (!existing && labelIds) {
+ this.input.setAttribute('aria-labelledby', labelIds);
+ }
}
});
@@ -336,7 +358,5 @@ export class Checkbox extends ObserveAttributesMixin(FormControlMixin(L
input.checked = !!this.checked;
input.indeterminate = !!this.indeterminate;
-
- this.setAttributesTarget(input);
}
}
diff --git a/packages/components/shared/src/mixins/forward-aria-mixin.ts b/packages/components/shared/src/mixins/forward-aria-mixin.ts
index 0fa2ba6dac..1d3271cea3 100644
--- a/packages/components/shared/src/mixins/forward-aria-mixin.ts
+++ b/packages/components/shared/src/mixins/forward-aria-mixin.ts
@@ -1,6 +1,17 @@
import { type Constructor } from '@open-wc/dedupe-mixin';
import { type ReactiveElement } from 'lit';
+/** Counter for generating stable unique IDs for elements that lack one. */
+let nextAutoId = 0;
+
+/** Ensures the element has an `id` attribute, auto-assigning one if missing. */
+function ensureId(el: HTMLElement): string {
+ if (!el.id) {
+ el.id = `sl-aria-ref-${nextAutoId++}`;
+ }
+ return el.id;
+}
+
export interface ForwardAriaMixinInterface {
getProxyTarget(): HTMLElement | undefined;
setProxyTarget(target: HTMLElement): void;
@@ -77,9 +88,21 @@ export function ForwardAriaMixin<
ariaDisabledStorage = new WeakMap();
class ForwardAriaImpl extends constructor {
+ /** Maximum number of deferred retries before giving up on unresolved IDs. */
+ static readonly #MAX_RETRIES = 3;
+
#observer?: MutationObserver;
#pendingAttributes = new Set();
+ /** Tracks the number of deferred resolution attempts per attribute. */
+ #retryCounts = new Map();
+
+ /**
+ * Stores attribute values at the time they were observed, as a fallback when Lit's ARIA
+ * reflection removes the attribute before resolution.
+ */
+ #storedValues = new Map();
+
static override get observedAttributes(): string[] {
return [...(super.observedAttributes ?? []), ...(observedAttributes ?? [])];
}
@@ -93,11 +116,36 @@ export function ForwardAriaMixin<
setProxyTarget(target: HTMLElement): void {
targetElements.set(this, target);
- // Forward any element reference properties that were set before the target was available
+ // Forward any element reference properties that were set before the target was available.
+ // Apply the same scope-aware logic as #forwardAttributes: when the target shares the
+ // same root as the host, use string attributes (IDs) instead of element references,
+ // because Chrome clears the content attribute when ariaLabelledByElements is assigned.
const stored = propertyStorage.get(this);
if (stored) {
+ const root = this.getRootNode();
+ const targetRoot = target.getRootNode();
+
for (const [prop, value] of stored) {
- (target as unknown as Record)[prop] = value;
+ // Skip null/empty values to avoid adding empty aria-* content attributes
+ const isEmpty = value === null || (Array.isArray(value) && value.length === 0);
+ if (isEmpty) {
+ continue;
+ }
+
+ if (targetRoot === root) {
+ // Same scope: convert element references to a string ID attribute.
+ const attrName = Object.entries(ELEMENT_REFERENCES).find(([, p]) => p === prop)?.[0];
+ if (attrName) {
+ const elements = Array.isArray(value) ? value : [value];
+ const ids = elements.map(el => ensureId(el as HTMLElement)).join(' ');
+ if (ids) {
+ target.setAttribute(attrName, ids);
+ }
+ }
+ } else {
+ // Cross-scope: element references are required.
+ (target as unknown as Record)[prop] = value;
+ }
}
}
@@ -123,16 +171,21 @@ export function ForwardAriaMixin<
// before the element upgraded or before the target is available).
if (!observedAttributes) {
// Collect any aria-* attributes already present on the element
- for (const { name } of this.attributes) {
+ for (const { name, value } of this.attributes) {
if (name.startsWith('aria-')) {
this.#pendingAttributes.add(name);
+ this.#storedValues.set(name, value);
}
}
this.#observer = new MutationObserver(mutations => {
for (const { attributeName } of mutations) {
if (attributeName?.startsWith('aria-')) {
- this.#pendingAttributes.add(attributeName);
+ const value = this.getAttribute(attributeName);
+ if (value !== null) {
+ this.#pendingAttributes.add(attributeName);
+ this.#storedValues.set(attributeName, value);
+ }
}
}
@@ -154,12 +207,23 @@ export function ForwardAriaMixin<
oldValue: string | null,
newValue: string | null
): void {
+ // Store the value BEFORE calling super, since Lit's ARIA reflection may
+ // remove or empty the attribute during super.attributeChangedCallback.
+ if (observedAttributes?.includes(name) && newValue) {
+ this.#pendingAttributes.add(name);
+ this.#storedValues.set(name, newValue);
+ }
+
super.attributeChangedCallback(name, oldValue, newValue);
// Only intercept attributes from our explicit list (the MutationObserver
// path handles the "observe everything" case in connectedCallback).
- if (observedAttributes?.includes(name) && newValue !== null) {
- this.#pendingAttributes.add(name);
+ if (observedAttributes?.includes(name) && newValue) {
+ // Attempt to forward; if #pendingAttributes was already consumed (e.g. by a
+ // re-entrant call triggered from super), re-add and retry.
+ if (!this.#pendingAttributes.has(name)) {
+ this.#pendingAttributes.add(name);
+ }
this.#forwardAttributes();
}
}
@@ -174,9 +238,13 @@ export function ForwardAriaMixin<
// Resolve IDs from the host's root (light DOM document or parent shadow root),
// not from our own shadow root.
const root = this.getRootNode() as Document | ShadowRoot;
+ const unresolved = new Set();
for (const name of this.#pendingAttributes) {
- const value = this.getAttribute(name);
+ // Read from the host attribute first; fall back to stored value if Lit's
+ // built-in ARIA reflection removed the attribute (or set it to empty string)
+ // before we could process it.
+ const value = this.getAttribute(name) || this.#storedValues.get(name);
if (!value) {
continue;
}
@@ -185,25 +253,96 @@ export function ForwardAriaMixin<
if (elementsProp) {
// Reference attribute: resolve space-separated IDs to DOM elements and
// assign via the element reference property (singular or plural).
- const elements = value
- .split(/\s+/)
- .map(id => root.querySelector(`#${CSS.escape(id)}`))
- .filter((el): el is HTMLElement => el !== null);
+ const ids = value.split(/\s+/),
+ elements = ids
+ .map(id => root.querySelector(`#${CSS.escape(id)}`))
+ .filter((el): el is HTMLElement => el !== null);
+
+ // If not all IDs could be resolved, defer until the referenced elements
+ // are in the DOM (e.g. when a sibling is connected after this element).
+ // Only defer when at least one element was found (partial resolution),
+ // indicating siblings are still rendering. When nothing resolves at all,
+ // forward immediately so consumers get a deterministic empty result.
+ // After MAX_RETRIES attempts, give up and forward whatever was resolved
+ // so permanently-missing IDs don't block indefinitely.
+ if (elements.length < ids.length && elements.length > 0) {
+ const retries = this.#retryCounts.get(name) ?? 0;
+ if (retries < ForwardAriaImpl.#MAX_RETRIES) {
+ this.#retryCounts.set(name, retries + 1);
+ unresolved.add(name);
+ continue;
+ }
+ // Fallback: forward partially-resolved elements.
+ // Clean up retry state since we're done trying.
+ this.#retryCounts.delete(name);
+ }
+
+ // Clean up retry count on successful resolution.
+ this.#retryCounts.delete(name);
- if (elementsProp.endsWith('Elements')) {
- (targetElement as unknown as Record)[elementsProp] = elements;
+ // Mirror the element reference in propertyStorage so that external code
+ // (e.g. tooltip anchor matching) can discover the relation via the host.
+ let stored = propertyStorage.get(this);
+ if (!stored) {
+ stored = new Map();
+ propertyStorage.set(this, stored);
+ }
+ const refValue = elementsProp.endsWith('Elements') ? elements : (elements[0] ?? null);
+ stored.set(elementsProp, refValue);
+
+ // If no elements could be resolved at all, handle based on scope:
+ // - Same scope: forward the string IDREF attribute anyway — the browser's
+ // native resolution works dynamically and will pick up elements that
+ // appear in the DOM later.
+ // - Cross scope: forward an empty array so consumers reading the native
+ // property get a deterministic []. Element references can't resolve
+ // natively across shadow boundaries.
+ if (elements.length === 0) {
+ const targetRoot = targetElement.getRootNode();
+ if (targetRoot === root) {
+ targetElement.setAttribute(name, value);
+ } else {
+ (targetElement as unknown as Record)[
+ elementsProp
+ ] = refValue;
+ }
+ this.removeAttribute(name);
+ this.#storedValues.delete(name);
+ continue;
+ }
+
+ // Determine the target's root to decide how to forward the relationship.
+ const targetRoot = targetElement.getRootNode();
+
+ if (targetRoot === root) {
+ // Same scope: the string attribute reliably resolves IDs. Do NOT set
+ // element reference properties here — Chrome replaces the content attribute
+ // with an empty marker when ariaLabelledByElements/ariaDescribedByElements
+ // is assigned, which breaks the accessibility tree.
+ targetElement.setAttribute(name, value);
} else {
- (targetElement as unknown as Record)[elementsProp] =
- elements[0] ?? null;
+ // Cross-scope (target is inside a shadow root): string IDs cannot resolve
+ // across the shadow boundary, so element references are required.
+ (targetElement as unknown as Record)[elementsProp] =
+ refValue;
}
} else {
targetElement.setAttribute(name, value);
}
this.removeAttribute(name);
+ this.#storedValues.delete(name);
}
- this.#pendingAttributes.clear();
+ this.#pendingAttributes = unresolved;
+
+ // If some IDs could not be resolved, retry to give sibling elements time to
+ // connect to the DOM. Use both setTimeout (new macrotask, after all synchronous
+ // rendering) and rAF (next frame) to cover different rendering pipelines.
+ if (unresolved.size > 0) {
+ setTimeout(() => this.#forwardAttributes(), 0);
+ requestAnimationFrame(() => this.#forwardAttributes());
+ }
}
}
@@ -252,7 +391,29 @@ export function ForwardAriaMixin<
const target = targetElements.get(this);
if (target) {
- (target as unknown as Record)[prop] = value;
+ // Only forward non-empty values to the target. Forwarding null or empty arrays
+ // can cause the browser to add an empty aria-describedby/aria-labelledby content
+ // attribute on the target, which interferes with deferred ID resolution.
+ const isEmpty = value === null || (Array.isArray(value) && value.length === 0);
+ if (!isEmpty) {
+ const root = (this as unknown as Element).getRootNode();
+ const targetRoot = target.getRootNode();
+
+ if (targetRoot === root) {
+ // Same scope: use string attribute to avoid Chrome clearing it.
+ const attrName = Object.entries(ELEMENT_REFERENCES).find(([, p]) => p === prop)?.[0];
+ if (attrName) {
+ const elements = Array.isArray(value) ? value : [value];
+ const ids = elements.map(el => ensureId(el as HTMLElement)).join(' ');
+ if (ids) {
+ target.setAttribute(attrName, ids);
+ }
+ }
+ } else {
+ // Cross-scope: element references required.
+ (target as unknown as Record)[prop] = value;
+ }
+ }
}
}
});
diff --git a/packages/components/tooltip/src/tooltip.ts b/packages/components/tooltip/src/tooltip.ts
index 7e093cda5f..90e09c65ab 100644
--- a/packages/components/tooltip/src/tooltip.ts
+++ b/packages/components/tooltip/src/tooltip.ts
@@ -118,7 +118,7 @@ export class Tooltip extends LitElement {
}
};
- const createTooltip = (): void => {
+ const createTooltip = (event?: Event): void => {
if (created) {
return;
}
@@ -166,6 +166,18 @@ export class Tooltip extends LitElement {
// We only need to create the tooltip once, so ignore all future events.
removeListeners();
+
+ // When the tooltip is created in response to a focusin event, the tooltip's own
+ // document-level focusin listener missed the original event. Dispatch a synthetic
+ // focusin on the owning document so the tooltip's normal show logic kicks in
+ // without re-triggering focusin listeners on the anchor or its ancestors.
+ if (event?.type === 'focusin') {
+ requestAnimationFrame(() => {
+ (target.ownerDocument ?? document).dispatchEvent(
+ new FocusEvent('focusin', { bubbles: true, composed: true })
+ );
+ });
+ }
};
const cleanup = () => {
@@ -1313,6 +1325,20 @@ export class Tooltip extends LitElement {
while (true) {
const rootNode = this.#getShadowRoot(normalized.getRootNode());
if (!rootNode) {
+ // For light DOM proxy targets: if the element's parent has getProxyTarget()
+ // pointing to this element, normalize to the parent (e.g. an in the
+ // light DOM of sl-checkbox where sl-checkbox is the actual anchor).
+ const parent = normalized.parentElement;
+ if (parent instanceof HTMLElement) {
+ const parentProxy = (
+ parent as HTMLElement & { getProxyTarget?(): Element | null }
+ ).getProxyTarget?.();
+
+ if (parentProxy === normalized && this.#matchesAnchor(parent)) {
+ return parent;
+ }
+ }
+
return normalized;
}