diff --git a/.changeset/segmented-control-truncated-labels.md b/.changeset/segmented-control-truncated-labels.md new file mode 100644 index 00000000000..d2cda896506 --- /dev/null +++ b/.changeset/segmented-control-truncated-labels.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +SegmentedControl: Prevent constrained labels from wrapping, reveal truncated labels on hover and keyboard focus, and preserve their full accessible names. diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-colorblind-linux.png new file mode 100644 index 00000000000..7848f919f26 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-dimmed-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-dimmed-linux.png new file mode 100644 index 00000000000..bb4386566a5 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-high-contrast-linux.png new file mode 100644 index 00000000000..f24499f331e Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-linux.png new file mode 100644 index 00000000000..7848f919f26 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-tritanopia-linux.png new file mode 100644 index 00000000000..7848f919f26 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-colorblind-linux.png new file mode 100644 index 00000000000..1f010e88b00 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-high-contrast-linux.png new file mode 100644 index 00000000000..6856c582c7f Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-linux.png new file mode 100644 index 00000000000..1f010e88b00 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-tritanopia-linux.png new file mode 100644 index 00000000000..1f010e88b00 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Compact-Toolbar-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-colorblind-linux.png new file mode 100644 index 00000000000..7b131c351ed Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-dimmed-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-dimmed-linux.png new file mode 100644 index 00000000000..edccc969350 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-high-contrast-linux.png new file mode 100644 index 00000000000..c69ac366147 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-linux.png new file mode 100644 index 00000000000..7b131c351ed Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-tritanopia-linux.png new file mode 100644 index 00000000000..7b131c351ed Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-colorblind-linux.png new file mode 100644 index 00000000000..710315f6b20 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-high-contrast-linux.png new file mode 100644 index 00000000000..056b50953e9 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-linux.png new file mode 100644 index 00000000000..710315f6b20 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-tritanopia-linux.png new file mode 100644 index 00000000000..710315f6b20 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-Stress-Test-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-colorblind-linux.png new file mode 100644 index 00000000000..3f5de9f528c Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-dimmed-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-dimmed-linux.png new file mode 100644 index 00000000000..ef22f7d1aad Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-high-contrast-linux.png new file mode 100644 index 00000000000..a317c4ced78 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-linux.png new file mode 100644 index 00000000000..3f5de9f528c Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-tritanopia-linux.png new file mode 100644 index 00000000000..3f5de9f528c Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-colorblind-linux.png new file mode 100644 index 00000000000..f4b723bbda2 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-high-contrast-linux.png new file mode 100644 index 00000000000..efe33fa89d9 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-linux.png new file mode 100644 index 00000000000..f4b723bbda2 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-tritanopia-linux.png new file mode 100644 index 00000000000..f4b723bbda2 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Constrained-Label-Overflow-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-colorblind-linux.png new file mode 100644 index 00000000000..c054fbf1119 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-dimmed-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-dimmed-linux.png new file mode 100644 index 00000000000..5e118f0e891 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-high-contrast-linux.png new file mode 100644 index 00000000000..06a58588d74 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-linux.png new file mode 100644 index 00000000000..c054fbf1119 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-tritanopia-linux.png new file mode 100644 index 00000000000..c054fbf1119 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-colorblind-linux.png new file mode 100644 index 00000000000..138ecdbb967 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-high-contrast-linux.png new file mode 100644 index 00000000000..3e0f1973185 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-linux.png new file mode 100644 index 00000000000..138ecdbb967 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-tritanopia-linux.png new file mode 100644 index 00000000000..138ecdbb967 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Dev-Constrained-Controls-light-tritanopia-linux.png differ diff --git a/e2e/components/SegmentedControl.test.ts b/e2e/components/SegmentedControl.test.ts index 5e854c5d2b7..fcf6ad85d39 100644 --- a/e2e/components/SegmentedControl.test.ts +++ b/e2e/components/SegmentedControl.test.ts @@ -72,13 +72,462 @@ const stories = [ title: 'Dev: With Css', id: 'components-segmentedcontrol-dev--with-css', }, + { + title: 'Dev: Constrained Controls', + id: 'components-segmentedcontrol-dev--constrained-controls', + }, { title: 'With Disabled Buttons', id: 'components-segmentedcontrol-examples--with-disabled-buttons', }, + { + title: 'Compact Toolbar', + id: 'components-segmentedcontrol-features--compact-toolbar', + }, + { + title: 'Constrained Label Overflow', + id: 'components-segmentedcontrol-features--constrained-label-overflow', + }, + { + title: 'Constrained Label Overflow Stress Test', + id: 'components-segmentedcontrol-features--constrained-label-overflow-stress-test', + }, ] as const test.describe('SegmentedControl', () => { + test('truncated labels reveal their full text on hover and keyboard focus @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-features--constrained-label-overflow'}) + const control = page.getByRole('list', {name: 'File view, constrained label', exact: true}) + const button = control.getByRole('button', {name: 'Preview changes', exact: true}) + const tooltip = control.locator('[data-component="Tooltip"]').filter({hasText: /^Preview changes$/}) + const bounds = await button.boundingBox() + + await button.hover() + await expect(tooltip).toBeVisible() + await expect(button).toHaveAccessibleName('Preview changes') + expect(await button.boundingBox()).toEqual(bounds) + await page.keyboard.press('Escape') + await expect(tooltip).toBeHidden() + + await page.mouse.move(0, 0) + await page + .getByRole('list', {name: 'File view, full label', exact: true}) + .getByRole('button', {name: 'Blame', exact: true}) + .focus() + await page.keyboard.press('Tab') + await expect(button).toBeFocused() + await expect(tooltip).toBeVisible() + await page.keyboard.press('Enter') + await expect(button).toHaveAttribute('aria-pressed', 'true') + await expect(button).toHaveAccessibleName('Preview changes') + expect(await button.boundingBox()).toEqual(bounds) + await page.keyboard.press('Escape') + await expect(tooltip).toBeHidden() + await expect(button).toBeFocused() + await page.keyboard.press('Tab') + await expect(control.getByRole('button', {name: 'Code', exact: true})).toBeFocused() + }) + + test('label reveal tracks container resizing without replacing the focused button @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-dev--constrained-controls'}) + const control = page.getByRole('list', {name: 'Text file view', exact: true}) + const container = control.locator('..') + const button = control.getByRole('button', {name: 'Preview changes before publishing', exact: true}) + const tooltip = control.locator('[data-component="Tooltip"]').first() + + await container.evaluate(element => { + element.style.width = '260px' + }) + await expect(tooltip).toHaveText('Preview changes before publishing') + await button.focus() + await expect(button).toBeFocused() + await expect(tooltip).toBeVisible() + await container.evaluate(element => { + element.style.width = '1024px' + }) + await expect(tooltip).toBeHidden() + await expect(button).toBeFocused() + await expect(button).toHaveAccessibleName('Preview changes before publishing') + await button.hover() + await expect(tooltip).toBeHidden() + await container.evaluate(element => { + element.style.width = '260px' + }) + await expect(tooltip).toHaveText('Preview changes before publishing') + await control.getByRole('button', {name: 'Code', exact: true}).focus() + await page.keyboard.press('Shift+Tab') + await expect(tooltip).toBeVisible() + await expect(tooltip).toHaveText('Preview changes before publishing') + await expect(button).toBeFocused() + }) + + test('fitting labels do not reveal a tooltip on hover or keyboard focus @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-features--constrained-label-overflow'}) + const control = page.getByRole('list', {name: 'File view, full label', exact: true}) + const buttons = control.getByRole('button') + + for (const button of await buttons.all()) { + await button.hover() + await button.press('Tab') + await page.keyboard.press('Shift+Tab') + await expect(button).toBeFocused() + await expect(control.locator('[data-component="Tooltip"]:visible')).toHaveCount(0) + } + }) + + test('label reveal updates after text changes and remains available when aria-disabled @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-dev--changing-label'}) + const control = page.getByRole('list', {name: 'File view', exact: true}) + const labelInput = page.getByRole('textbox', {name: 'Segment label', exact: true}) + const button = control.getByRole('button').first() + const tooltip = control.locator('[data-component="Tooltip"]').first() + const bounds = await control.boundingBox() + + await button.hover() + await expect(tooltip).toBeVisible() + await expect(tooltip).toHaveText('Preview changes before publishing') + await expect(button).toHaveAccessibleName('Preview changes before publishing (5)') + await expect(button).toHaveAccessibleDescription('Choose a file view Preview changes before publishing') + await control.getByRole('button', {name: 'Code', exact: true}).click() + await page.getByRole('checkbox', {name: 'Disable first segment'}).check() + await page.keyboard.press('Tab') + await expect(button).toBeFocused() + await page.keyboard.press('Enter') + await expect(tooltip).toBeVisible() + await expect(button).toHaveAttribute('aria-disabled', 'true') + await expect(button).toHaveAttribute('aria-pressed', 'false') + + await labelInput.fill('Files') + await expect(tooltip).toBeEmpty() + await page.getByRole('checkbox', {name: 'Disable first segment'}).press('Tab') + await button.hover() + await expect(tooltip).toBeHidden() + await expect(button).toHaveAccessibleName('Files (5)') + await expect(button).toHaveAccessibleDescription('Choose a file view') + await labelInput.fill('Review all changes before publishing') + await expect(tooltip).toHaveText('Review all changes before publishing') + await page.getByRole('checkbox', {name: 'Disable first segment'}).press('Tab') + await expect(tooltip).toBeVisible() + await expect(tooltip).toHaveText('Review all changes before publishing') + await expect(button).toHaveAccessibleName('Review all changes before publishing (5)') + expect(await control.boundingBox()).toMatchObject({height: bounds?.height}) + }) + + test('consumer callbacks still run for pointer focus and mouse-over capture @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-dev--changing-label'}) + await page.clock.install() + await page.getByRole('textbox', {name: 'Segment label', exact: true}).fill('Files') + const control = page.getByRole('list', {name: 'File view', exact: true}) + const button = control.getByRole('button', {name: 'Files (5)', exact: true}) + await expect(control.locator('[data-component="Tooltip"]').first()).toBeEmpty() + await button.click() + await expect(button).toBeFocused() + await expect(page.getByRole('status', {name: 'Focus events'})).toHaveText('1') + await expect(page.getByRole('status', {name: 'Mouse-over events'})).toHaveText(/^[1-9]\d*$/) + await page.clock.runFor(100) + await expect(page.getByRole('status', {name: 'Mouse-enter events'})).toHaveText('1') + await expect(control.locator('[data-component="Tooltip"]:visible')).toHaveCount(0) + }) + + test('label reveal tracks selection-dependent font weight without refocusing @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-features--constrained-label-overflow'}) + const control = page.getByRole('list', {name: 'File view, constrained label', exact: true}) + const button = control.getByRole('button', {name: 'Preview changes', exact: true}) + const code = control.getByRole('button', {name: 'Code', exact: true}) + const label = button.locator('.segmentedControl-text') + const tooltip = control.locator('[data-component="Tooltip"]').first() + const selectedTextWidth = await label.evaluate(element => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect().width + }) + await code.click() + const normalTextWidth = await label.evaluate(element => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect().width + }) + expect(selectedTextWidth - normalTextWidth).toBeGreaterThan(2) + const availableTextWidth = Math.floor((normalTextWidth + selectedTextWidth) / 2) + const constrainedWidth = await button.evaluate((element, width) => { + const text = element.querySelector('.segmentedControl-text') + if (!(text instanceof HTMLElement)) throw new Error('Expected a segment label') + const nonLabelWidth = element.getBoundingClientRect().width - text.getBoundingClientRect().width + element.style.maxWidth = `${width + nonLabelWidth}px` + return element.style.maxWidth + }, availableTextWidth) + await expect(tooltip).toBeEmpty() + const labelBounds = await label.boundingBox() + const buttonBounds = await button.boundingBox() + + await code.press('Shift+Tab') + await expect(button).toBeFocused() + await expect(tooltip).toBeHidden() + await page.keyboard.press('Enter') + await expect(button).toHaveAttribute('aria-pressed', 'true') + expect(await label.boundingBox()).toEqual(labelBounds) + expect(await button.boundingBox()).toEqual(buttonBounds) + await expect(tooltip).toHaveText('Preview changes') + await expect(tooltip).toBeVisible() + await expect(button).toBeFocused() + await page.keyboard.press('Escape') + await expect(tooltip).toBeHidden() + await button.evaluate(element => { + element.style.maxWidth = '240px' + }) + await expect(tooltip).toBeEmpty() + await button.evaluate((element, width) => { + element.style.maxWidth = width + }, constrainedWidth) + await expect(tooltip).toHaveText('Preview changes') + await expect(tooltip).toBeHidden() + await expect(button).toBeFocused() + await button.press('Tab') + await page.keyboard.press('Shift+Tab') + await expect(tooltip).toBeVisible() + await code.click() + await expect(tooltip).toBeEmpty() + expect(await label.boundingBox()).toEqual(labelBounds) + expect(await button.boundingBox()).toEqual(buttonBounds) + await button.hover() + await expect(tooltip).toBeHidden() + }) + + for (const size of ['small', 'medium'] as const) { + for (const variant of ['default', 'subtle'] as const) { + for (const fullWidth of [undefined, false] as const) { + const description = `${size} ${variant}, fullWidth ${fullWidth ?? 'omitted'}` + const args = {size, appearance: variant, fullWidthMode: fullWidth === false ? 'explicit-false' : 'omitted'} + + test(`unequal labels preserve icons and counters (${description}) @vrt`, async ({page}) => { + await visit(page, { + id: 'components-segmentedcontrol-dev--constrained-controls', + args: {...args, textContent: 'unequal-labels-with-visuals'}, + }) + const control = page.getByRole('list', {name: 'Text file view', exact: true}) + await expect(control).toHaveAttribute('data-size', size) + await expect(control).toHaveAttribute('data-variant', variant) + await expect(control.locator('svg')).toHaveCount(2) + await expect(control.getByText('5', {exact: true})).toHaveCount(1) + await expect(control.getByText('2', {exact: true})).toHaveCount(1) + const buttons = control.getByRole('button') + + for (const width of [260, 360]) { + await control.locator('..').evaluate((element, width) => { + element.style.width = `${width}px` + }, width) + for (const index of [0, 1, 2]) { + await buttons.nth(index).click() + expect(await control.boundingBox()).toMatchObject({width, height: size === 'small' ? 28 : 32}) + const contained = await buttons.evaluateAll(buttons => + buttons.every(button => { + const bounds = button.getBoundingClientRect() + const content = button.querySelector('.segmentedControl-content') + if (!content) throw new Error('Expected segment content') + const visuals = [...content.children] + .filter(element => !element.classList.contains('segmentedControl-text')) + .flatMap(element => [element, ...element.querySelectorAll('*')]) + return visuals.every(element => { + const rect = element.getBoundingClientRect() + return rect.left >= bounds.left - 0.5 && rect.right <= bounds.right + 0.5 + }) + }), + ) + expect(contained).toBe(true) + } + } + }) + + test(`auto-sized controls fit constrained parents (${description}) @vrt`, async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-dev--constrained-controls', args}) + const control = page.getByRole('list', {name: 'Text file view', exact: true}) + const container = control.locator('..') + const buttons = control.getByRole('button') + if (fullWidth === false) { + await expect(control).toHaveAttribute('data-full-width', 'false') + } else { + await expect(control).not.toHaveAttribute('data-full-width') + } + await expect(control).toHaveAttribute('data-size', size) + await expect(control).toHaveAttribute('data-variant', variant) + + for (const display of ['block', 'flex', 'grid']) { + await container.evaluate((element, layout) => { + element.style.width = '260px' + element.style.display = layout + }, display) + + const box = await control.boundingBox() + expect(box?.width).toBeLessThanOrEqual(260) + expect(box?.height).toBe(size === 'small' ? 28 : 32) + expect( + await control + .locator('.segmentedControl-text') + .first() + .evaluate(element => element.scrollWidth > element.clientWidth), + ).toBe(true) + await expect(buttons.first()).toHaveAccessibleName('Preview changes before publishing') + + for (const index of [1, 2, 0]) { + await buttons.nth(index).click() + expect(await control.boundingBox()).toEqual(box) + } + + await container.evaluate(element => { + element.style.width = '1024px' + }) + if (display !== 'grid') { + const roomyBox = await control.boundingBox() + expect(roomyBox?.width).toBeGreaterThan(260) + expect(roomyBox?.width).toBeLessThan(1024) + } + expect( + await control + .locator('.segmentedControl-text') + .evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth)), + ).toBe(true) + } + }) + + test(`constrained icon buttons do not overlap (${description}) @vrt`, async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-dev--constrained-controls', args}) + + for (const [name, width] of [ + ['Icon file view', 80], + ['Mixed file view', 140], + ] as const) { + const control = page.getByRole('list', {name, exact: true}) + const buttons = control.getByRole('button') + await expect(control).toHaveAttribute('data-size', size) + await expect(control).toHaveAttribute('data-variant', variant) + await control.evaluate((element, constrainedWidth) => { + element.style.width = `${constrainedWidth}px` + }, width) + + for (const index of [0, 1, 2]) { + await buttons.nth(index).click() + const iconDimensions = await control + .locator('[data-component="SegmentedControl.IconButton"]') + .evaluateAll(items => + items.map(item => { + const button = item.querySelector('button') + if (!button) throw new Error('Expected an icon button') + return { + itemWidth: item.getBoundingClientRect().width, + buttonWidth: button.getBoundingClientRect().width, + } + }), + ) + for (const {itemWidth, buttonWidth} of iconDimensions) { + expect(buttonWidth).toBe(32) + expect(itemWidth).toBeGreaterThanOrEqual(buttonWidth) + } + const bounds = await buttons.evaluateAll(elements => + elements.map(element => element.getBoundingClientRect().toJSON()), + ) + for (let i = 0; i < bounds.length - 1; i++) { + expect(bounds[i].right).toBeLessThanOrEqual(bounds[i + 1].left) + } + expect(await control.boundingBox()).toMatchObject({height: size === 'small' ? 28 : 32}) + } + } + }) + } + } + } + + test('small controls retain 28px with inherited typography @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-features--compact-toolbar'}) + const control = page.getByRole('list', {name: 'File view'}) + const buttons = control.getByRole('button') + await expect(control).toHaveCSS('font-size', '14px') + await expect(buttons.first()).toHaveCSS('line-height', '21px') + expect( + await control + .locator('.segmentedControl-text') + .evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth)), + ).toBe(true) + + for (const name of ['Code', 'Blame', 'Code']) { + await control.getByRole('button', {name}).click() + expect(await control.boundingBox()).toMatchObject({height: 28}) + expect( + await buttons.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height)), + ).toEqual([28, 28]) + } + }) + + test('constraining one label keeps other labels readable @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-features--constrained-label-overflow'}) + + for (const example of ['full label', 'constrained label']) { + const control = page.getByRole('list', {name: `File view, ${example}`, exact: true}) + const buttons = control.getByRole('button') + const box = await control.boundingBox() + const segments = await buttons.evaluateAll(elements => + elements.map(element => element.getBoundingClientRect().toJSON()), + ) + expect(box?.height).toBe(32) + await expect(control.getByRole('button', {name: 'Preview changes', exact: true})).toHaveAccessibleName( + 'Preview changes', + ) + + for (const name of ['Code', 'Blame', 'Preview changes']) { + await control.getByRole('button', {name, exact: true}).click() + expect( + await control.locator('.segmentedControl-text').evaluateAll(elements => + elements.map(element => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect().width > element.clientWidth + 0.5 + }), + ), + ).toEqual([example === 'constrained label', false, false]) + expect(await control.boundingBox()).toEqual(box) + expect( + await buttons.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().toJSON())), + ).toEqual(segments) + } + } + }) + + test('extremely constrained labels truncate without changing segment geometry @vrt', async ({page}) => { + await visit(page, {id: 'components-segmentedcontrol-features--constrained-label-overflow-stress-test'}) + + for (const size of ['small', 'medium']) { + for (const variant of ['default', 'subtle']) { + const control = page.getByRole('list', {name: `${size} ${variant}`, exact: true}) + const buttons = control.getByRole('button') + const box = await control.boundingBox() + const segments = await buttons.evaluateAll(elements => + elements.map(element => element.getBoundingClientRect().toJSON()), + ) + const longLabel = control.locator('.segmentedControl-text').first() + + await expect(longLabel).toHaveCSS('overflow', 'hidden') + await expect(longLabel).toHaveCSS('text-overflow', 'ellipsis') + await expect(longLabel).toHaveCSS('white-space', 'nowrap') + await expect(control).toHaveCSS('overflow-y', 'visible') + expect(await longLabel.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + await expect(control.getByRole('button', {name: 'Preview changes before publishing (5)'})).toHaveAccessibleName( + 'Preview changes before publishing (5)', + ) + expect(box?.height).toBe(size === 'small' ? 28 : 32) + + for (const name of ['Code', 'Preview changes before publishing (5)']) { + await control.getByRole('button', {name, exact: true}).click() + expect(await control.boundingBox()).toEqual(box) + expect( + await buttons.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().toJSON())), + ).toEqual(segments) + } + await page.mouse.move(0, 0) + await page.keyboard.press('Escape') + await expect(control.locator('[data-component="Tooltip"]:visible')).toHaveCount(0) + } + } + }) + for (const story of stories) { test.describe(story.title, () => { for (const theme of themes) { diff --git a/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.module.css b/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.module.css new file mode 100644 index 00000000000..b03548a3b01 --- /dev/null +++ b/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.module.css @@ -0,0 +1,10 @@ +.ConstrainedControls { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--base-size-24); +} + +.ChangingLabelControl { + width: 260px; +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.tsx index ca0574b42d4..0f179455431 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.dev.stories.tsx @@ -1,8 +1,11 @@ -import type {Meta} from '@storybook/react-vite' +import type {Meta, StoryFn} from '@storybook/react-vite' +import {useState} from 'react' import {SegmentedControl} from '.' import SegmentedControlIconButton from './SegmentedControlIconButton' import SegmentedControlButton from './SegmentedControlButton' import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react' +import classes from './SegmentedControl.dev.stories.module.css' +import TextInput from '../TextInput' const excludedControlKeys = ['variant'] @@ -107,3 +110,106 @@ export const WithCss = () => ( ) + +type ConstrainedControlsArgs = { + size: 'small' | 'medium' + appearance: 'default' | 'subtle' + fullWidthMode: 'omitted' | 'explicit-false' + textContent: 'plain' | 'unequal-labels-with-visuals' +} + +export const ConstrainedControls: StoryFn = ({ + size, + appearance, + fullWidthMode, + textContent, +}) => { + const props = {size, variant: appearance, fullWidth: fullWidthMode === 'explicit-false' ? false : undefined} + const withVisuals = textContent === 'unequal-labels-with-visuals' + + return ( +
+
+ + + {withVisuals ? 'Preview changes before publishing '.repeat(8) : 'Preview changes before publishing'} + + Code + + Blame + + +
+
+ + Preview changes + + + +
+
+ + + + + +
+
+ ) +} + +ConstrainedControls.args = {size: 'medium', appearance: 'default', fullWidthMode: 'omitted', textContent: 'plain'} + +ConstrainedControls.argTypes = { + size: {control: 'radio', options: ['small', 'medium']}, + appearance: {control: 'radio', options: ['default', 'subtle']}, + fullWidthMode: {control: 'radio', options: ['omitted', 'explicit-false']}, + textContent: {control: 'radio', options: ['plain', 'unequal-labels-with-visuals']}, +} + +export const ChangingLabel = () => { + const [label, setLabel] = useState('Preview changes before publishing') + const [disabled, setDisabled] = useState(false) + const [focusEvents, setFocusEvents] = useState(0) + const [mouseOverEvents, setMouseOverEvents] = useState(0) + const [mouseEnterEvents, setMouseEnterEvents] = useState(0) + + return ( +
+ setLabel(event.target.value)} /> + +

Choose a file view

+
+ + setFocusEvents(value => value + 1)} + onMouseOverCapture={() => setMouseOverEvents(value => value + 1)} + onMouseEnter={() => setMouseEnterEvents(value => value + 1)} + > + {label} + + Code + Blame + +
+ {focusEvents} + {mouseOverEvents} + {mouseEnterEvents} +
+ ) +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css index d578fa504b6..f540c085616 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css @@ -29,3 +29,43 @@ font-size: var(--text-body-size-medium); color: var(--fgColor-muted); } + +.CompactToolbar { + display: flex; + align-items: center; + gap: var(--base-size-8); +} + +.ConsumerTypography { + font-size: var(--text-body-size-medium); +} + +.ConsumerTypography button { + line-height: inherit; +} + +.ConstrainedLabelExamples { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--base-size-24); +} + +.ConstrainedLabelExample { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--base-size-8); +} + +.ConstrainedLabelButton { + max-width: 120px; +} + +.ConstrainedLabelStressExamples { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--base-size-16); + width: 260px; +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx index 1893aee05a7..2604170301e 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx @@ -215,3 +215,79 @@ export const AssociatedWithALabelAndCaption = () => ( ) AssociatedWithALabelAndCaption.storyName = '[Example] Associated with a label and caption' + +export const CompactToolbar = () => ( +
+ + Code + Blame + + +
+) + +export const ConstrainedLabelOverflow = () => ( +
+
+ Full label + + Preview changes + Code + Blame + +
+
+ Constrained label + + + Preview changes + + Code + Blame + +
+
+) + +ConstrainedLabelOverflow.parameters = { + docs: { + description: { + story: + 'The second example limits the first segment to show label truncation. Hover or focus it to reveal the full label. Prefer enough space to display labels in full.', + }, + }, +} + +export const ConstrainedLabelOverflowStressTest = () => ( +
+ {(['small', 'medium'] as const).flatMap(size => + (['default', 'subtle'] as const).map(variant => ( + + + Preview changes before publishing + + Code + + Blame + + + )), + )} +
+) + +ConstrainedLabelOverflowStressTest.storyName = 'Constrained label overflow (stress test)' +ConstrainedLabelOverflowStressTest.parameters = { + docs: { + description: { + story: + 'Stress test of defensive label truncation, not a recommended layout. Keep labels short and provide enough space to show them in full. If labels regularly truncate, give the control more space, shorten the labels or use a different component.', + }, + }, +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index aa7c5703d87..e40729208a8 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -101,6 +101,8 @@ --segmented-control-outer-radius: var(--borderRadius-medium); display: inline-flex; + min-width: 0; + max-width: 100%; /* TODO: use primitive `control.{small|medium}.size` when it is available */ height: 32px; @@ -450,6 +452,7 @@ } .Counter { + grid-column: 3; margin-inline-start: var(--base-size-8); display: flex; align-items: center; @@ -547,7 +550,8 @@ } .Content { - display: flex; + display: grid; + grid-template-columns: auto minmax(0, max-content) auto; height: 100%; /* stylelint-disable-next-line primer/spacing */ padding-right: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); @@ -599,6 +603,14 @@ } } +.Text { + grid-column: 2; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .Text::after { display: block; height: 0; @@ -611,5 +623,6 @@ } .LeadingIcon { + grid-column: 1; margin-right: var(--base-size-4); } diff --git a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx index d0fd3e82d3e..5dec7faa57e 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx @@ -477,6 +477,7 @@ describe('SegmentedControl', () => { , ) + expect(getByRole('button', {name: 'All (5)'})).toHaveAccessibleName('All (5)') const iconActionButton = getByRole('button', {name: 'Add view'}) expect(iconActionButton.parentElement?.closest('[data-component]')).toHaveAttribute( diff --git a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx index a1896c94d38..b16848ba0a8 100644 --- a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx @@ -1,5 +1,6 @@ import type {ButtonHTMLAttributes} from 'react' import type React from 'react' +import {useCallback, useRef, useState} from 'react' import type {IconProps} from '@primer/octicons-react' import {isElement} from 'react-is' @@ -7,6 +8,9 @@ import classes from './SegmentedControl.module.css' import {clsx} from 'clsx' import CounterLabel from '../CounterLabel' import type {FCWithSlotMarker} from '../utils/types' +import {Tooltip} from '../TooltipV2' +import {useResizeObserver} from '../hooks/useResizeObserver' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' export type SegmentedControlButtonProps = { /** The visible label rendered in the button */ @@ -40,6 +44,16 @@ const SegmentedControlButton: FCWithSlotMarker { const {'aria-disabled': ariaDisabled, ...rest} = props + const labelRef = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) + const updateTruncation = useCallback(() => { + const label = labelRef.current + if (label) { + setIsTruncated(label.scrollWidth > label.clientWidth) + } + }, []) + useResizeObserver(updateTruncation, labelRef) + useIsomorphicLayoutEffect(updateTruncation, [children, selected, updateTruncation]) // Use leadingVisual if provided, otherwise fall back to leadingIcon for backwards compatibility const LeadingVisual = leadingVisual ?? leadingIcon @@ -49,27 +63,29 @@ const SegmentedControlButton: FCWithSlotMarker - + + + ) } diff --git a/packages/react/src/TooltipV2/Tooltip.tsx b/packages/react/src/TooltipV2/Tooltip.tsx index 4c8f7f42867..cbc0008b9c2 100644 --- a/packages/react/src/TooltipV2/Tooltip.tsx +++ b/packages/react/src/TooltipV2/Tooltip.tsx @@ -1,4 +1,4 @@ -import React, {Children, useEffect, useState, useMemo, type ForwardRefExoticComponent, useRef} from 'react' +import React, {Children, useEffect, useState, useMemo, useCallback, type ForwardRefExoticComponent, useRef} from 'react' import {useId, useOnEscapePress, useMergedRefs, useProvidedRefOrCreate} from '../hooks' import {useFeatureFlag} from '../FeatureFlags' import {invariant} from '../utils/invariant' @@ -30,7 +30,7 @@ export type TooltipProps = React.PropsWithChildren<{ */ delay?: 'short' | 'medium' | 'long' /** - * Private API for use internally only. Prevents the tooltip from opening if `true`. + * Private API for use internally only. Closes the tooltip and prevents it from opening if `true`. * * Accessibility note: This prop should be used with caution. Only use when needing to * programmatically close the tooltip in response to a specific user action, such as @@ -151,10 +151,11 @@ export const Tooltip: ForwardRefExoticComponent< const [isPopoverOpen, setIsPopoverOpen] = useState(false) const openTimeoutRef = React.useRef(null) + const shouldOpenOnFocusRef = useRef(false) const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() - const openTooltip = () => { + const openTooltip = useCallback(() => { try { if ( tooltipElRef.current && @@ -196,8 +197,8 @@ export const Tooltip: ForwardRefExoticComponent< throw error } } - } - const closeTooltip = () => { + }, [_privateDisableTooltip, readTriggerRef, direction]) + const closeTooltip = useCallback(() => { if (openTimeoutRef.current) { safeClearTimeout(openTimeoutRef.current) openTimeoutRef.current = null @@ -229,7 +230,16 @@ export const Tooltip: ForwardRefExoticComponent< throw error } } - } + }, [readTriggerRef, safeClearTimeout]) + + useEffect(() => { + // Synchronise the native popover with changes to its availability. + if (_privateDisableTooltip) { + closeTooltip() + } else if (shouldOpenOnFocusRef.current) { + openTooltip() + } + }, [_privateDisableTooltip, closeTooltip, openTooltip]) // context value const value = useMemo(() => ({tooltipId}), [tooltipId]) @@ -289,6 +299,7 @@ export const Tooltip: ForwardRefExoticComponent< useOnEscapePress( (event: KeyboardEvent) => { if (isPopoverOpen) { + shouldOpenOnFocusRef.current = false event.stopImmediatePropagation() event.preventDefault() closeTooltip() @@ -331,6 +342,7 @@ export const Tooltip: ForwardRefExoticComponent< // If it is a label type, we use tooltip to label the trigger 'aria-labelledby': type === 'label' ? tooltipId : child.props['aria-labelledby'], onBlur: (event: React.FocusEvent) => { + shouldOpenOnFocusRef.current = false closeTooltip() child.props.onBlur?.(event) }, @@ -342,6 +354,8 @@ export const Tooltip: ForwardRefExoticComponent< safeSetTimeout(() => closeTooltip(), 10) }, onFocus: (event: React.FocusEvent) => { + shouldOpenOnFocusRef.current = false + child.props.onFocus?.(event) // only show tooltip on :focus-visible, not on :focus try { if (!event.target.matches(':focus-visible')) return @@ -350,10 +364,11 @@ export const Tooltip: ForwardRefExoticComponent< // https://github.com/jsdom/jsdom/issues/3426 } + shouldOpenOnFocusRef.current = true openTooltip() - child.props.onFocus?.(event) }, onMouseOverCapture: (event: React.MouseEvent) => { + child.props.onMouseOverCapture?.(event) const delayTime = delayTimeMap[delay] || 50 // We use a `capture` event to ensure this is called first before // events that might cancel the opening timeout (like `onTouchEnd`) @@ -363,7 +378,6 @@ export const Tooltip: ForwardRefExoticComponent< // if the mouse is already moved out, do not show the tooltip if (!openTimeoutRef.current) return openTooltip() - child.props.onMouseEnter?.(event) }, delayTime) }, onMouseLeave: (event: React.MouseEvent) => {