diff --git a/.changeset/deprecate-input-contrast.md b/.changeset/deprecate-input-contrast.md new file mode 100644 index 00000000000..47c8db30e0d --- /dev/null +++ b/.changeset/deprecate-input-contrast.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +TextInput and Textarea: Deprecate the `contrast` prop. Omit it to use the default background. diff --git a/.changeset/select-panel-default-background.md b/.changeset/select-panel-default-background.md new file mode 100644 index 00000000000..e78f4e42874 --- /dev/null +++ b/.changeset/select-panel-default-background.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +SelectPanel: Use the default filter input background. Enable `primer_react_select_panel_legacy_contrast` to restore the previous appearance. diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-dark-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-dark-high-contrast-linux.png new file mode 100644 index 00000000000..80cd3219934 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-dark-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-dark-linux.png new file mode 100644 index 00000000000..27d42a0e288 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-dark-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-light-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-light-high-contrast-linux.png new file mode 100644 index 00000000000..84a1bf4a108 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-light-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-light-linux.png new file mode 100644 index 00000000000..6efed56c0c8 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Legacy-Contrast-light-linux.png differ diff --git a/e2e/components/SelectPanel.test.ts b/e2e/components/SelectPanel.test.ts index 3f9069a3a7c..dc972e248fc 100644 --- a/e2e/components/SelectPanel.test.ts +++ b/e2e/components/SelectPanel.test.ts @@ -80,6 +80,90 @@ test.describe('SelectPanel', () => { }) } + const contrastThemes = ['light', 'dark', 'light_high_contrast', 'dark_high_contrast'] + const contrastScenarios = matrix({ + theme: contrastThemes, + legacyContrast: [false, true], + panel: [ + {name: 'anchored', id: 'components-selectpanel--default', narrow: false}, + {name: 'modal', id: 'components-selectpanel-features--multi-select-modal', narrow: false}, + {name: 'fullscreen', id: 'components-selectpanel--default', narrow: true}, + ], + }) + + for (const {theme, legacyContrast, panel} of contrastScenarios) { + test(`Filter input ${panel.name} legacy contrast ${legacyContrast} @aat ${theme}`, async ({page}) => { + if (panel.narrow) { + await page.setViewportSize({width: 375, height: 667}) + } + await visit(page, { + id: panel.id, + globals: { + colorScheme: theme, + featureFlags: { + primer_react_select_panel_legacy_contrast: legacyContrast, + primer_react_select_panel_fullscreen_on_narrow: panel.narrow, + }, + }, + }) + + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + + const input = page.getByRole('combobox', {name: 'Filter items'}) + const wrapper = page.locator('[data-component="TextInput"]').filter({has: input}) + await expect(input).toBeVisible() + await expect(input).toBeEnabled() + await expect(input).toBeFocused() + if (legacyContrast) { + await expect(wrapper).toHaveAttribute('data-contrast') + } else { + await expect(wrapper).not.toHaveAttribute('data-contrast') + } + + const expectedBackground = await wrapper.evaluate((element, contrast) => { + const probe = document.createElement('span') + probe.style.backgroundColor = contrast + ? 'var(--control-bgColor-contrast, var(--bgColor-inset))' + : 'var(--bgColor-default)' + element.appendChild(probe) + const background = getComputedStyle(probe).backgroundColor + probe.remove() + return background + }, legacyContrast) + expect(expectedBackground).not.toBe('rgba(0, 0, 0, 0)') + await expect(wrapper).toHaveCSS('background-color', expectedBackground) + await expect(page).toHaveNoViolations({rules: {'color-contrast': {enabled: true}}}) + + await input.fill('bug') + await expect(input).toHaveValue('bug') + await expect(page.getByRole('option')).toHaveCount(1) + await expect(page.getByRole('option', {name: /bug/})).toBeVisible() + await page.keyboard.press('Escape') + await expect(input).toBeHidden() + await expect(page.getByRole('button', {name: /bug/})).toBeFocused() + }) + } + + for (const theme of contrastThemes) { + test(`Legacy contrast @vrt ${theme}`, async ({page}) => { + await visit(page, { + id: 'components-selectpanel--default', + globals: { + colorScheme: theme, + featureFlags: {primer_react_select_panel_legacy_contrast: true}, + }, + }) + await page.getByRole('button', {name: /bug/}).click() + await expect(page.locator('[data-component="TextInput"]')).toHaveAttribute('data-contrast') + await expect(page.getByRole('combobox', {name: 'Filter items'})).toHaveValue('') + await expect(page.getByRole('option')).toHaveCount(7) + expect(await page.screenshot({animations: 'disabled', caret: 'hide'})).toMatchSnapshot( + `SelectPanel.Legacy Contrast.${theme}.png`, + ) + }) + } + // eslint-disable-next-line playwright/no-skipped-test test.skip(`Default @vrt forced-colors`, async ({page}) => { await visit(page, { diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index 2663906ca1a..0862870621f 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -3,6 +3,7 @@ import {FeatureFlagScope} from './FeatureFlagScope' export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_css_anchor_positioning: false, primer_react_select_panel_fullscreen_on_narrow: false, + primer_react_select_panel_legacy_contrast: false, primer_react_select_panel_order_selected_at_top: false, primer_react_styled_react_use_primer_theme_providers: false, primer_react_action_list_group_heading_trailing_action: false, diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index d7f91b944e1..faa9ccbc97b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -146,7 +146,7 @@ "name": "textInputProps", "type": "Partial>", "defaultValue": "", - "description": "See [TextInput props](/react/TextInput#props)." + "description": "See [TextInput props](/react/TextInput#props). The filter input uses the normal TextInput background by default. Enable `primer_react_select_panel_legacy_contrast` to restore the previous contrast background. An explicit `textInputProps.contrast` value takes precedence over the flag: `true` enables contrast, while `false` or explicitly passing `undefined` disables it. The `contrast` prop remains deprecated." }, { "name": "variant", diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index ff1b7c2ae94..a63abe77687 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -27,6 +27,9 @@ import FormControl from '../FormControl' import {SegmentedControl} from '../SegmentedControl' import {Stack} from '../Stack' import classes from './SelectPanel.features.stories.module.css' +import {FeatureFlags} from '../FeatureFlags' +import {Default} from './SelectPanel.stories' +import {expect, within} from 'storybook/test' const meta: Meta = { title: 'Components/SelectPanel/Features', @@ -35,6 +38,26 @@ const meta: Meta = { export default meta +export const LegacyContrast: StoryObj = { + render: () => ( + + + + ), + play: async ({canvas, canvasElement, userEvent}) => { + await userEvent.click(canvas.getByRole('button', {name: /bug/})) + const body = within(canvasElement.ownerDocument.body) + const input = body.getByRole('combobox', {name: 'Filter items'}) + await expect(input.closest('[data-component="TextInput"]')).toHaveAttribute('data-contrast') + await expect(input).toBeEnabled() + await expect(input).toHaveFocus() + await userEvent.type(input, 'enhancement') + await expect(body.getAllByRole('option')).toHaveLength(1) + await expect(body.getByRole('option', {name: /enhancement/})).toBeVisible() + await userEvent.clear(input) + }, +} + const NoResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => { return { variant: 'empty', diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 038dccd06ab..941d860b11c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -1,7 +1,8 @@ -import {render, screen, waitFor} from '@testing-library/react' +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react' import {describe, expect, it, beforeEach, vi} from 'vitest' import {page} from 'vitest/browser' import React from 'react' +import {flushSync} from 'react-dom' import {SelectPanel, type SelectPanelProps, type ItemInput, type GroupedListProps} from '../SelectPanel' import {userEvent} from '@testing-library/user-event' import {FeatureFlags} from '../FeatureFlags' @@ -502,7 +503,7 @@ for (const usingRemoveActiveDescendant of [false, true]) { }) }) - function FilterableSelectPanel() { + function FilterableSelectPanel({textInputProps}: Pick) { const [selected, setSelected] = React.useState([]) const [filter, setFilter] = React.useState('') const [open, setOpen] = React.useState(false) @@ -518,6 +519,7 @@ for (const usingRemoveActiveDescendant of [false, true]) { items={items.filter(item => item.text?.includes(filter))} placeholder="Select items" placeholderText="Filter items" + textInputProps={textInputProps} selected={selected} onSelectedChange={onSelectedChange} filterValue={filter} @@ -646,6 +648,98 @@ for (const usingRemoveActiveDescendant of [false, true]) { }) }) + describe.each([ + {name: 'absent', flags: {}, contrast: false}, + {name: 'false', flags: {primer_react_select_panel_legacy_contrast: false}, contrast: false}, + {name: 'true', flags: {primer_react_select_panel_legacy_contrast: true}, contrast: true}, + ])('legacy contrast flag $name', ({flags, contrast}) => { + it('should set the default background without changing filtering or focus', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', {name: 'Select items'})) + + const input = screen.getByLabelText('Filter items') + const wrapper = input.closest('[data-component="TextInput"]') + expect(wrapper).toBeInTheDocument() + expect(wrapper?.hasAttribute('data-contrast')).toBe(contrast) + expect(input).toBeEnabled() + expect(input).toHaveFocus() + + await user.type(input, 'two') + expect(input).toHaveValue('two') + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByRole('option', {name: 'item two'})).toBeInTheDocument() + + // Keep close-time ref and focus updates inside the active act scope. + act(() => { + flushSync(() => { + fireEvent.keyDown(input, {key: 'Escape'}) + }) + }) + await waitFor(() => expect(input).not.toBeInTheDocument()) + expect(screen.getByRole('button', {name: 'Select items'})).toHaveFocus() + }) + + it.each([true, false, undefined])('should preserve explicit contrast=%s', async explicitContrast => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', {name: 'Select items'})) + + const input = screen.getByLabelText('Filter items') + const wrapper = input.closest('[data-component="TextInput"]') + expect(wrapper).toBeInTheDocument() + expect(wrapper?.hasAttribute('data-contrast')).toBe(explicitContrast === true) + expect(input).toBeEnabled() + expect(input).toHaveFocus() + await user.type(input, 'two') + expect(input).toHaveValue('two') + expect(screen.getByRole('option', {name: 'item two'})).toBeInTheDocument() + }) + }) + + it('should update contrast with the flag while preserving the input, filter, and focus', async () => { + const user = userEvent.setup() + const textInputProps = {'aria-label': 'Search items'} + const panel = + const {rerender} = render( + {panel}, + ) + + await user.click(screen.getByRole('button', {name: 'Select items'})) + const input = screen.getByLabelText('Search items') + const wrapper = input.closest('[data-component="TextInput"]') + expect(wrapper).toBeInTheDocument() + expect(wrapper).not.toHaveAttribute('data-contrast') + await user.type(input, 'two') + + for (const legacyContrast of [true, false]) { + rerender( + {panel}, + ) + + expect(screen.getByLabelText('Search items')).toBe(input) + expect(wrapper?.hasAttribute('data-contrast')).toBe(legacyContrast) + expect(input).toBeEnabled() + expect(input).toHaveValue('two') + expect(input).toHaveFocus() + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByRole('option', {name: 'item two'})).toBeInTheDocument() + } + + await user.clear(input) + expect(screen.getAllByRole('option')).toHaveLength(3) + }) + describe('screen reader announcements', () => { beforeEach(() => { const liveRegion = document.createElement('live-region') diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index d818724c193..e32be4fd5ff 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -232,6 +232,7 @@ function Panel({ const KEYBOARD_VISIBILITY_THRESHOLD = 10 const featureFlagFullScreenOnNarrow = useFeatureFlag('primer_react_select_panel_fullscreen_on_narrow') + const legacyContrast = useFeatureFlag('primer_react_select_panel_legacy_contrast') const usingFullScreenOnNarrow = disableFullscreenOnNarrow ? false : featureFlagFullScreenOnNarrow const shouldOrderSelectedFirst = useFeatureFlag('primer_react_select_panel_order_selected_at_top') && showSelectedOptionsFirst @@ -743,12 +744,12 @@ function Panel({ const extendedTextInputProps: Partial = useMemo(() => { return { className: classes.TextInput, - contrast: true, + contrast: legacyContrast, leadingVisual: SearchIcon, 'aria-label': inputLabel, ...textInputProps, } - }, [inputLabel, textInputProps]) + }, [inputLabel, legacyContrast, textInputProps]) const loadingType = (): FilteredActionListLoadingType => { if (dataLoadedOnce) { diff --git a/packages/react/src/TextInput/TextInput.docs.json b/packages/react/src/TextInput/TextInput.docs.json index ff68d969c31..af10bba0d6e 100644 --- a/packages/react/src/TextInput/TextInput.docs.json +++ b/packages/react/src/TextInput/TextInput.docs.json @@ -82,7 +82,8 @@ "name": "contrast", "type": "boolean", "defaultValue": "false", - "description": "Changes background color to a higher contrast color" + "deprecated": true, + "description": "Deprecated. Omit this prop to use the default background." }, { "name": "size", diff --git a/packages/react/src/TextInput/TextInput.test.tsx b/packages/react/src/TextInput/TextInput.test.tsx index 41b7054e43d..0cbe72bfec3 100644 --- a/packages/react/src/TextInput/TextInput.test.tsx +++ b/packages/react/src/TextInput/TextInput.test.tsx @@ -52,6 +52,24 @@ describe('TextInput', () => { expect(render().container).toMatchSnapshot() }) + it.each([ + {label: 'true', props: {contrast: true}}, + {label: 'false', props: {contrast: false}}, + {label: 'omitted', props: {}}, + ])('preserves deprecated contrast behavior when $label', ({props}) => { + const {container} = render() + const input = screen.getByRole('textbox') + + if (props.contrast) { + expect(container.firstElementChild).toHaveAttribute('data-contrast', 'true') + } else { + expect(container.firstElementChild).not.toHaveAttribute('data-contrast') + } + + expect(input).not.toHaveAttribute('contrast') + expect(input).toBeEnabled() + }) + it('renders monospace', () => { expect(render().container).toMatchSnapshot() }) diff --git a/packages/react/src/Textarea/Textarea.docs.json b/packages/react/src/Textarea/Textarea.docs.json index 11afa62f593..3ef30d34ab2 100644 --- a/packages/react/src/Textarea/Textarea.docs.json +++ b/packages/react/src/Textarea/Textarea.docs.json @@ -82,7 +82,8 @@ "name": "contrast", "type": "boolean", "defaultValue": "false", - "description": "Changes background color to a higher contrast color" + "deprecated": true, + "description": "Deprecated. Omit this prop to use the default background." }, { "name": "validationStatus", diff --git a/packages/react/src/Textarea/Textarea.test.tsx b/packages/react/src/Textarea/Textarea.test.tsx index b39980df561..c626bc9ec2b 100644 --- a/packages/react/src/Textarea/Textarea.test.tsx +++ b/packages/react/src/Textarea/Textarea.test.tsx @@ -37,6 +37,24 @@ describe('Textarea', () => { expect(screen.getByRole('textbox')).toBeInTheDocument() }) + it.each([ + {label: 'true', props: {contrast: true}}, + {label: 'false', props: {contrast: false}}, + {label: 'omitted', props: {}}, + ])('preserves deprecated contrast behavior when $label', ({props}) => { + const {container} = render(