Repository: CrowdStrike/glide-core
Pull request #890: Replace Label's Resize Observer with "mouseover"
Author: login: clintcs
Created: 2025-05-09T14:45:53Z | Merged: 2025-05-09T18:59:23Z

## 🚀 Description

<!--

  - Tell us about your changes and the motivation for them.
  - Write "N/A" if your changes are explained by the PR's title.

-->

We haven't see performance issues with Resize Observer, so in a way this is a premature optimization. But it's not cheap, and it was excessive for Label to be taking measurements before it needed to.

## 📋 Checklist

<!-- Do the following before adding reviewers: -->

- I have followed the [Contributing Guidelines](https://github.com/crowdstrike/glide-core/blob/main/CONTRIBUTING.md).
- I have added tests to cover new or updated functionality.
- I have added or updated Storybook stories.
- I have [localized](https://github.com/CrowdStrike/glide-core/blob/main/CONTRIBUTING.md#translations-and-static-strings) new strings.
- I have followed the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/) or met with the Accessibility Team.
- I have included a [changeset](https://github.com/CrowdStrike/glide-core/blob/main/CONTRIBUTING.md#versioning-a-package).
- I have scheduled a design review.
- I have reviewed the Storybook and Visual Test Report links below.

## 🔬 Testing

1. Navigate to a form control in Storybook.
2. Give the control a `label` long enough that it's truncated.
3. Hover over the label.
4. Verify a tooltip is shown.

<!--

  Tell us how to reproduce and verify your changes:

  1. Navigate to Checkbox in Storybook.
  1. Click the label.
  1. Verify Checkbox is checked.

-->



Diff:
diff --git a/src/label.test.interactions.ts b/src/label.test.interactions.ts
new file mode 100644
index 000000000..d68765169
--- /dev/null
+++ b/src/label.test.interactions.ts
@@ -0,0 +1,25 @@
+import { assert, expect, fixture, html } from '@open-wc/testing';
+import GlideCoreLabel from './label.js';
+import GlideCoreTooltip from './tooltip.js';
+import { hover } from './library/mouse.js';
+
+it('shows a label tooltip on hover', async () => {
+  const host = await fixture<GlideCoreLabel>(
+    html`<glide-core-private-label label="Label">
+      <label for="input"> ${'x'.repeat(500)} </label>
+      <input id="input" slot="control" />
+    </glide-core-private-label>`,
+  );
+
+  const tooltip = host.shadowRoot
+    ?.querySelector<GlideCoreTooltip>('[data-test="label-tooltip"]')
+    ?.shadowRoot?.querySelector<HTMLElement>('[data-test="tooltip"]');
+
+  assert(tooltip);
+  tooltip.dataset.openDelay = '0';
+
+  const label = host.shadowRoot?.querySelector('[data-test="label"]');
+  await hover(label);
+
+  expect(tooltip.checkVisibility()).to.be.true;
+});
diff --git a/src/label.ts b/src/label.ts
index 5255193c3..71421657d 100644
--- a/src/label.ts
+++ b/src/label.ts
@@ -80,9 +80,15 @@ export default class GlideCoreLabel extends LitElement {
   label?: string;
 
   override render() {
-    // `aria-hidden` is used on the tooltip so the contents of the label
-    // aren't read twice to screen readers. The label is truncated using
-    // CSS. So the full text of the label is always available them.
+    // `aria-hidden` is used on the tooltip so the contents of the label aren't
+    // read twice to screen readers. The label is truncated using CSS. So the full
+    // text of the label is always available to them.
+
+    // Lit-a11y doesn't know that we're only using a "mouseover" event to hide and
+    // show Tooltip, which is hidden from screenreaders because the truncated text
+    // is available to them without a tooltip.
+    //
+    /* eslint-disable lit-a11y/mouse-events-have-key-events */
     return html`<div
       class=${classMap({
         component: true,
@@ -129,6 +135,7 @@ export default class GlideCoreLabel extends LitElement {
 
         <glide-core-tooltip
           class="label-tooltip"
+          data-test="label-tooltip"
           label=${this.label ?? ''}
           placement="right"
           ?disabled=${!this.isLabelTooltip}
@@ -141,13 +148,10 @@ export default class GlideCoreLabel extends LitElement {
             })}
             data-test="label"
             slot="target"
+            @mouseover=${this.#onLabelMouseOver}
             ${ref(this.#labelElementRef)}
           >
-            <slot
-              @slotchange=${this.#onDefaultSlotChange}
-              ${assertSlot()}
-              ${ref(this.#defaultSlotElementRef)}
-            >
+            <slot ${assertSlot()} ${ref(this.#defaultSlotElementRef)}>
               <!-- @type {HTMLLabelElement} -->
             </slot>
           </div>
@@ -238,34 +242,6 @@ export default class GlideCoreLabel extends LitElement {
 
   #summarySlotElementRef = createRef<HTMLSlotElement>();
 
-  #onDefaultSlotChange() {
-    const defaultSlotAssignedElement = this.#defaultSlotElementRef.value
-      ?.assignedElements()
-      .at(0);
-
-    const observer = new ResizeObserver(() => {
-      // `getBoundingClientRect` is used so we're comparing apples to apples.
-      //
-      // `clientWidth` on `defaultSlotAssignedElement` is zero if the element
-      // is `display` is `inline`. `labelElement`, on the other hand, isn't
-      // inline.
-      //
-      // But `clientWidth` returns an integer and `getBoundingClientRect().width`
-      // return a float. So using `clientWidth` for `labelElement` would mean the
-      // width of `defaultSlotAssignedElement` is always fractionally greater than
-      // that of `labelElement`.
-      if (defaultSlotAssignedElement && this.#labelElementRef.value) {
-        this.isLabelTooltip =
-          defaultSlotAssignedElement.getBoundingClientRect().width >
-          this.#labelElementRef.value.getBoundingClientRect().width;
-      }
-    });
-
-    if (this.#labelElementRef.value) {
-      observer.observe(this.#labelElementRef.value);
-    }
-  }
-
   #onDescriptionSlotResize() {
     // The "description" slot has a top margin that needs to be conditionally
     // applied only if content is slotted so there's not stray whitespace
@@ -283,6 +259,27 @@ export default class GlideCoreLabel extends LitElement {
     );
   }
 
+  #onLabelMouseOver() {
+    const defaultSlotAssignedElement = this.#defaultSlotElementRef.value
+      ?.assignedElements()
+      .at(0);
+
+    if (defaultSlotAssignedElement && this.#labelElementRef.value) {
+      // `getBoundingClientRect()` is used so we're comparing apples to apples.
+      //
+      // `clientWidth` on `defaultSlotAssignedElement` is zero if the element is
+      // `display: inline`. The label, on the other hand, isn't inline.
+      //
+      // But `clientWidth` returns an integer and `getBoundingClientRect().width`
+      // return a float. So using `clientWidth` with `#labelElementRef.value` would
+      // mean the width of `defaultSlotAssignedElement` is always fractionally
+      // greater than that of `#labelElementRef.value`.
+      this.isLabelTooltip =
+        defaultSlotAssignedElement.getBoundingClientRect().width >
+        this.#labelElementRef.value.getBoundingClientRect().width;
+    }
+  }
+
   #onSummarySlotChange() {
     const assignedNodes = this.#summarySlotElementRef.value?.assignedNodes({
       flatten: true,


Reviews:
user: login: clintcs
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T14:48:26Z
user: login: ynotdraw
body: Nice improvement!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T15:33:31Z
user: login: clintcs
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T15:42:51Z

Review comments:
diff_hunk: @@ -283,6 +255,28 @@ export default class GlideCoreLabel extends LitElement {
     );
   }
 
+  #onLabelMouseOver() {
path: src/label.ts
user: login: clintcs
body: We haven't see performance issues with Resize Observer, so in a way this is a premature optimization. But it's not cheap, and it was excessive for Label to be taking measurements before it needed to.
created_at: 2025-05-09T14:48:26Z
author_association: COLLABORATOR
diff_hunk: @@ -80,9 +80,15 @@ export default class GlideCoreLabel extends LitElement {
   label?: string;
 
   override render() {
-    // `aria-hidden` is used on the tooltip so the contents of the label
-    // aren't read twice to screen readers. The label is truncated using
-    // CSS. So the full text of the label is always available them.
+    // `aria-hidden` is used on the tooltip so the contents of the label aren't
+    // read twice to screen readers. The label is truncated using CSS. So the full
+    // text of the label is always available them.
path: src/label.ts
user: login: ynotdraw
body: ```suggestion
    // text of the label is always available to them.
```
created_at: 2025-05-09T15:31:47Z
author_association: COLLABORATOR
diff_hunk: @@ -283,6 +259,27 @@ export default class GlideCoreLabel extends LitElement {
     );
   }
 
+  #onLabelMouseOver() {
+    const defaultSlotAssignedElement = this.#defaultSlotElementRef.value
+      ?.assignedElements()
+      .at(0);
+
+    if (defaultSlotAssignedElement && this.#labelElementRef.value) {
+      // `getBoundingClientRect()` is used so we're comparing apples to apples.
+      //
+      // `clientWidth` on `defaultSlotAssignedElement` is zero if the element is
+      // `display` is `inline`. The label, on the other hand, isn't inline.
path: src/label.ts
user: login: ynotdraw
body: ```suggestion
      // `clientWidth` on `defaultSlotAssignedElement` is zero if the element's
      // `display` is `inline`. The label, on the other hand, isn't inline.
```

or

```suggestion
      // `clientWidth` on `defaultSlotAssignedElement` is zero if the element is
      // `display: inline`. The label, on the other hand, isn't inline.
```
created_at: 2025-05-09T15:33:03Z
author_association: COLLABORATOR
diff_hunk: @@ -80,9 +80,15 @@ export default class GlideCoreLabel extends LitElement {
   label?: string;
 
   override render() {
-    // `aria-hidden` is used on the tooltip so the contents of the label
-    // aren't read twice to screen readers. The label is truncated using
-    // CSS. So the full text of the label is always available them.
+    // `aria-hidden` is used on the tooltip so the contents of the label aren't
+    // read twice to screen readers. The label is truncated using CSS. So the full
+    // text of the label is always available them.
path: src/label.ts
user: login: clintcs
body: You know I'm always good for a couple typos.
created_at: 2025-05-09T15:42:50Z
author_association: COLLABORATOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-09T14:45:56Z
body: ###  ⚠️  No Changeset found

Latest commit: f5c06fefa77d6b4bda9f0c6011a09ce701860b6b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/CrowdStrike/glide-core/new/replace-label-resize-observer-with-mouseover?filename=.changeset/fifty-hairs-teach.md&value=---%0A%22%40crowdstrike%2Fglide-core%22%3A%20patch%0A---%0A%0AReplace%20Label's%20Resize%20Observer%20with%20%22mouseover%22%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-09T14:47:30Z
body: ## Storybook
https://glide-core.crowdstrike-ux.workers.dev/replace-label-resize-observer-with-mouseover
<!-- Sticky Pull Request Commentstorybook -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-09T14:48:36Z
body: ## ✅ Visual Test Report
https://glide-core.crowdstrike-ux.workers.dev/replace-label-resize-observer-with-mouseover/visual-test-report
<!-- Sticky Pull Request Commentvisual-test-report -->
author_association: CONTRIBUTOR