Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deprecate-input-contrast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

TextInput and Textarea: Deprecate the `contrast` prop. Omit it to use the default background.
5 changes: 5 additions & 0 deletions .changeset/select-panel-default-background.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 84 additions & 0 deletions e2e/components/SelectPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/SelectPanel/SelectPanel.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
"name": "textInputProps",
"type": "Partial<Omit<TextInputProps, 'onChange'>>",
"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",
Expand Down
23 changes: 23 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof SelectPanel> = {
title: 'Components/SelectPanel/Features',
Expand All @@ -35,6 +38,26 @@ const meta: Meta<typeof SelectPanel> = {

export default meta

export const LegacyContrast: StoryObj<typeof SelectPanel> = {
render: () => (
<FeatureFlags flags={{primer_react_select_panel_legacy_contrast: true}}>
<Default />
</FeatureFlags>
),
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',
Expand Down
98 changes: 96 additions & 2 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -502,7 +503,7 @@ for (const usingRemoveActiveDescendant of [false, true]) {
})
})

function FilterableSelectPanel() {
function FilterableSelectPanel({textInputProps}: Pick<SelectPanelProps, 'textInputProps'>) {
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
const [filter, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)
Expand All @@ -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}
Expand Down Expand Up @@ -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(
<FeatureFlags flags={flags}>
<FilterableSelectPanel />
</FeatureFlags>,
)

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(
<FeatureFlags flags={flags}>
<FilterableSelectPanel textInputProps={{contrast: explicitContrast}} />
</FeatureFlags>,
)

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 = <FilterableSelectPanel textInputProps={textInputProps} />
const {rerender} = render(
<FeatureFlags flags={{primer_react_select_panel_legacy_contrast: false}}>{panel}</FeatureFlags>,
)

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(
<FeatureFlags flags={{primer_react_select_panel_legacy_contrast: legacyContrast}}>{panel}</FeatureFlags>,
)

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')
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -743,12 +744,12 @@ function Panel({
const extendedTextInputProps: Partial<TextInputProps> = 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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/TextInput/TextInput.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/TextInput/TextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ describe('TextInput', () => {
expect(render(<TextInput name="zipcode" contrast value="" />).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(<TextInput name="zipcode" {...props} />)
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(<TextInput name="zipcode" monospace value="" />).container).toMatchSnapshot()
})
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/Textarea/Textarea.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/Textarea/Textarea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Textarea {...props} />)
const textarea = screen.getByRole('textbox')

if (props.contrast) {
expect(container.firstElementChild).toHaveAttribute('data-contrast', 'true')
} else {
expect(container.firstElementChild).not.toHaveAttribute('data-contrast')
}

expect(textarea).not.toHaveAttribute('contrast')
expect(textarea).toBeEnabled()
})

it('renders data-component attribute', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).toHaveAttribute('data-component', 'Textarea')
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Textarea/Textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type TextareaProps = {
*/
autoSize?: boolean
/**
* apply a high contrast color to background
* @deprecated Omit this prop to use the default background.
*/
contrast?: boolean
/**
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/internal/components/TextInputWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export type TextInputSizes = 'small' | 'medium' | 'large'

type StyledTextInputBaseWrapperProps = {
block?: boolean
/**
* @deprecated Omit this prop to use the default background.
*/
contrast?: boolean
disabled?: boolean
hasTrailingAction?: boolean
Expand Down
Loading