fix(studio): show keyboard focus on filter bar (#50827)

## Problem

- Filter fields could not be re-entered with Tab, and remove buttons
were previously skipped.
- Earlier focus rings flashed or crowded controls. Segmented filters and
design system examples also had uneven layout.

## Solution

- Tab now reaches each filter’s property, operator, value and remove
button. Editable fields use the caret; read-only values and remove
buttons keep a visible focus cue.
- Refined spacing and sizing. The segmented highlight is inset and
clears when focus enters a control. Both variants and the focus guidance
are documented on the [design system
page](https://design-system-git-dnywh-fix-filter-bar-focus-supabase.vercel.app/design-system/docs/fragments/filter-bar).

|After |
| --- |
| <img width="462" height="126" alt="CleanShot 2026-09-24 at 15 04
23@2x"
src="https://github.com/user-attachments/assets/2ee8bee2-f4fa-40f4-ada5-67bfe32384e7"
/> |
| <img width="362" height="96" alt="CleanShot 2026-09-24 at 15 04 47@2x"
src="https://github.com/user-attachments/assets/9f42054a-f432-493d-b417-f10892676c96"
/> |

## Review instructions

The easiest way to test this is to open Table Editor on both
production/staging and on this PR’s [deploy
preview](https://studio-staging-git-dnywh-fix-filter-bar-focus-supabase.vercel.app/).
Try navigating by keyboard (tab, left/right arrow) within the Filter
Bar. Compare the two.

- In Studio’s Table Editor or the [design system
example](https://design-system-git-dnywh-fix-filter-bar-focus-supabase.vercel.app/design-system/docs/fragments/filter-bar),
add a filter. Tab through its property, operator, value and remove
button, then Shift+Tab back into editing.
- Check that the segmented and pill examples fill their preview width
and that focus cues do not collide with neighbouring controls.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary

* **New Features**
* Added a pill appearance for the Filter Bar, with usage guidance and
interactive examples for pill and segmented presentations.
* **Accessibility**
* Improved keyboard navigation through filter controls, including
removing a filter with the keyboard.
* Clarified that an editable text field’s insertion caret can serve as
its visible focus indicator; read-only fields and controls without a
caret still need another visible indicator.
* **Style**
  * Refined Filter Bar spacing and focus styling across appearances.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-09-28 10:13:53 +10:00
1 parent 36371de151
commit 9a03f3cd9c
13 files changed
+255 -36

No files matched your search

@@ -28,6 +28,8 @@ Chromium-based browsers and Firefox handle this automatically via the Tab key. S
Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use one shared focus ring so users recognize this state instantly. Its color follows the theme's brighter primary hue in both themes. See [Primary and brand colors](../docs/color-usage#primary-and-brand-colors).
An editable text field can use its visible insertion caret to show focus. This can be useful for inputs embedded within a compact control, where another ring would obscure nearby elements. Check that the caret clearly identifies the active field. Read-only fields and controls without a caret still need a visible focus indicator.
### Focus ring recipe
Prefer the shared utilities over inventing local styles:
@@ -75,7 +77,7 @@ transition-property: color, background-color, border-color, ...
Rules:
- Prefer `:focus-visible` over `:focus` so click/tap does not show a focus indicator
- Never use `outline-none` / `outline-hidden` without a ring or outline replacement
- Never use `outline-none` / `outline-hidden` without a visible focus indicator, such as a ring, outline, or insertion caret in an editable text field
- Always use the shared color (`ring-ring` / `outline-ring`). Variants (primary, danger, warning) do not change focus color. The shared color derives from `--primary` with enough lightness to remain visible in light mode.
- Do not animate the focus indicator; avoid `transition-all` / `transition` on controls that show one (prefer `transition-colors`)
- Prefer `focus-ring` / `focus-inset` over copy-pasting the class stack
@@ -50,6 +50,34 @@ export function FilterDemo() {
}
```
## Variants
The default variant joins filters into a segmented bar. Use the pill variant to show each filter as a separate rounded chip:
### Segmented
<ComponentPreview
name="filter-bar-segmented-demo"
description="Filters joined into a segmented bar."
peekCode
showDottedGrid
wide
/>
### Pill
<ComponentPreview
name="filter-bar-pill-demo"
description="Rounded chips for each filter, as used in Studio."
peekCode
showDottedGrid
wide
/>
```tsx
<FilterBar variant="pill" className="border-0 bg-transparent overflow-visible" {...props} />
```
## API Reference
### FilterProperty
@@ -95,6 +123,7 @@ interface FilterCondition {
| onFreeformTextChange | (text: string) => void | Callback when free-form text changes |
| actions | FilterBarAction[]? | Optional custom actions to show in the menu |
| isLoading | boolean? | If true, dims the bar while work is in progress |
| variant | 'default' \| 'pill'? | Filter appearance. Defaults to 'default' |
## Custom actions (e.g. AI)
@@ -118,19 +118,28 @@ const initialFilters: FilterGroup = {
conditions: [],
}
export default function FilterBarDemo() {
function FilterBarExample({ variant }: { variant: 'default' | 'pill' }) {
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
const [freeformText, setFreeformText] = useState('')
return (
<div className="w-full">
<FilterBar
filterProperties={filterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
/>
<FilterBar
variant={variant}
className={variant === 'pill' ? 'border-0 bg-transparent overflow-visible' : undefined}
filterProperties={filterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
/>
)
}
export default function FilterBarDemo() {
return (
<div className="w-full space-y-6">
<FilterBarExample variant="default" />
<FilterBarExample variant="pill" />
</div>
)
}
@@ -0,0 +1,32 @@
import { useState } from 'react'
import { FilterBar, type FilterGroup } from 'ui-patterns/FilterBar'
const filterProperties = [
{ label: 'Name', name: 'name', type: 'string' as const, operators: ['=', '!='] },
{
label: 'Status',
name: 'status',
type: 'string' as const,
options: ['active', 'inactive', 'pending'],
operators: ['=', '!='],
},
]
export default function FilterBarPillDemo() {
const [filters, setFilters] = useState<FilterGroup>({ logicalOperator: 'AND', conditions: [] })
const [freeformText, setFreeformText] = useState('')
return (
<div className="w-full">
<FilterBar
variant="pill"
className="border-0 bg-transparent overflow-visible"
filterProperties={filterProperties}
filters={filters}
onFilterChange={setFilters}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
/>
</div>
)
}
@@ -0,0 +1,30 @@
import { useState } from 'react'
import { FilterBar, type FilterGroup } from 'ui-patterns/FilterBar'
const filterProperties = [
{ label: 'Name', name: 'name', type: 'string' as const, operators: ['=', '!='] },
{
label: 'Status',
name: 'status',
type: 'string' as const,
options: ['active', 'inactive', 'pending'],
operators: ['=', '!='],
},
]
export default function FilterBarSegmentedDemo() {
const [filters, setFilters] = useState<FilterGroup>({ logicalOperator: 'AND', conditions: [] })
const [freeformText, setFreeformText] = useState('')
return (
<div className="w-full">
<FilterBar
filterProperties={filterProperties}
filters={filters}
onFilterChange={setFilters}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
/>
</div>
)
}
+12
View File
@@ -605,6 +605,18 @@ export const examples: Registry = [
registryDependencies: ['filter-bar'],
files: ['example/filter-bar-demo.tsx'],
},
{
name: 'filter-bar-pill-demo',
type: 'components:example',
registryDependencies: ['filter-bar'],
files: ['example/filter-bar-pill-demo.tsx'],
},
{
name: 'filter-bar-segmented-demo',
type: 'components:example',
registryDependencies: ['filter-bar'],
files: ['example/filter-bar-segmented-demo.tsx'],
},
{
name: 'hover-card-demo',
type: 'components:example',
+3 -1
View File
@@ -1159,7 +1159,9 @@ test.describe('Filter Bar', () => {
await addFilter(page, ref, columnName, '=', 'Alice')
const conditionEl = page.getByTestId(`filter-condition-${columnName}`)
await conditionEl.locator('span', { hasText: columnName }).first().click()
await conditionEl
.getByRole('button', { name: `Change property from ${columnName}` })
.click()
const searchInput = page.getByTestId(`filter-property-search-${columnName}`)
await expect(searchInput).toBeVisible()
+1 -2
View File
@@ -68,8 +68,7 @@ export async function addFilterWithDropdownValue(
export async function switchProperty(page: Page, currentColumnName: string, newColumnName: string) {
const conditionEl = page.getByTestId(`filter-condition-${currentColumnName}`)
await conditionEl
.locator(`span`, { hasText: new RegExp(`^${currentColumnName}$`, 'i') })
.first()
.getByRole('button', { name: `Change property from ${currentColumnName}` })
.click()
const searchInput = page.getByTestId(`filter-property-search-${currentColumnName}`)
@@ -171,6 +171,84 @@ describe('FilterBar', () => {
expect(input).toBeInTheDocument()
})
it('lets keyboard users reenter and edit a selected filter', async () => {
const user = userEvent.setup()
render(
<FilterBar
filterProperties={mockFilterProperties}
filters={{
logicalOperator: 'AND',
conditions: [{ propertyName: 'name', operator: '=', value: 'test' }],
}}
onFilterChange={mockOnFilterChange}
freeformText=""
onFreeformTextChange={mockOnFreeformTextChange}
/>
)
await user.tab()
expect(screen.getByLabelText('Change property from Name')).toHaveFocus()
await user.tab()
expect(screen.getByLabelText('Operator for Name')).toHaveFocus()
await user.tab()
expect(screen.getByLabelText('Value for Name')).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Remove Name filter' })).toHaveFocus()
await user.tab({ shift: true })
expect(screen.getByLabelText('Value for Name')).toHaveFocus()
})
it('clears the chip highlight when focus moves to its remove button', async () => {
const user = userEvent.setup()
render(
<FilterBar
filterProperties={mockFilterProperties}
filters={{
logicalOperator: 'AND',
conditions: [{ propertyName: 'name', operator: '=', value: 'test' }],
}}
onFilterChange={mockOnFilterChange}
freeformText=""
onFreeformTextChange={mockOnFreeformTextChange}
/>
)
await user.click(screen.getByTestId('filter-bar-freeform-input'))
await user.keyboard('{ArrowLeft}')
const condition = screen.getByTestId('filter-condition-name')
expect(condition).toHaveAttribute('data-highlighted', 'true')
await user.tab({ shift: true })
expect(screen.getByRole('button', { name: 'Remove Name filter' })).toHaveFocus()
expect(condition).toHaveAttribute('data-highlighted', 'false')
})
it('lets keyboard users remove a selected filter', async () => {
const user = userEvent.setup()
const onFilterChange = vi.fn()
render(
<FilterBar
filterProperties={mockFilterProperties}
filters={{
logicalOperator: 'AND',
conditions: [{ propertyName: 'name', operator: '=', value: 'test' }],
}}
onFilterChange={onFilterChange}
freeformText=""
onFreeformTextChange={mockOnFreeformTextChange}
/>
)
await user.tab()
await user.tab()
await user.tab()
await user.tab()
expect(screen.getByRole('button', { name: 'Remove Name filter' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(onFilterChange).toHaveBeenCalledWith(initialFilters)
await waitFor(() => expect(screen.getByTestId('filter-bar-freeform-input')).toHaveFocus())
})
it('opens group popover and allows selecting a property', async () => {
const user = userEvent.setup()
let currentFilters = initialFilters
@@ -59,7 +59,7 @@ function FilterBarContent({ className }: { className?: string }) {
<div className="w-full space-y-2 relative">
<div
className={cn(
'relative flex items-stretch gap-0 w-full border rounded-md h-full bg-foreground/[.026] cursor-text p-0 pr-2 overflow-auto',
'relative flex items-stretch gap-0 w-full border rounded-md h-full bg-foreground/[.026] cursor-text p-0 overflow-auto',
className
)}
>
@@ -40,6 +40,7 @@ export type FilterBarContextValue = {
isLoading: boolean
error: string | null
highlightedConditionPath: number[] | null
setHighlightedConditionPath: (path: number[] | null) => void
onFilterChange: (filters: FilterGroup) => void
commitFilters: (filters: FilterGroup) => void
@@ -370,6 +371,7 @@ export const FilterBarRoot = forwardRef<FilterBarHandle, FilterBarRootProps>(fun
isLoading: loading,
error,
highlightedConditionPath,
setHighlightedConditionPath,
onFilterChange,
commitFilters,
@@ -41,8 +41,10 @@ export function FilterCondition({
handlePropertyChange,
handleKeyDown,
handleRemoveCondition,
rootRef,
handleSelectMenuItem,
setActiveInput,
setHighlightedConditionPath,
variant,
} = useFilterBar()
@@ -253,9 +255,19 @@ export function FilterCondition({
[handleInputChange, path]
)
const onRemove = useCallback(() => {
handleRemoveCondition(path)
}, [handleRemoveCondition, path])
const onRemove = useCallback(
(event?: React.MouseEvent<HTMLButtonElement>) => {
handleRemoveCondition(path)
if (event?.detail === 0) {
window.setTimeout(() => {
rootRef.current
?.querySelector<HTMLInputElement>('[data-testid="filter-bar-freeform-input"]')
?.focus()
}, 0)
}
},
[handleRemoveCondition, path, rootRef]
)
if (!property) return null
@@ -263,10 +275,12 @@ export function FilterCondition({
<div
ref={wrapperRef}
className={cn(
'flex items-stretch px-0 h-[26px] bg-muted group shrink-0',
variant === 'pill' ? 'rounded-sm border' : 'border-r',
isHighlighted && 'ring-2 ring-primary-bright'
'relative flex items-stretch px-0 bg-muted group shrink-0',
variant === 'pill' ? 'h-[26px] rounded-sm border' : 'self-stretch border-r',
isHighlighted &&
(variant === 'pill' ? 'ring-2 ring-ring' : 'rounded-sm ring-2 ring-inset ring-ring')
)}
onFocusCapture={() => setHighlightedConditionPath(null)}
data-testid={`filter-condition-${property.name}`}
data-highlighted={isHighlighted}
>
@@ -280,7 +294,7 @@ export function FilterCondition({
onChange={(e) => setPropertySearchText(e.target.value)}
onBlur={handlePropertyBlur}
onKeyDown={handlePropertyKeyDown}
className="h-full border-none bg-transparent py-0 pl-2 pr-1 text-xs hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 text-foreground-light w-full absolute left-0 top-0"
className="h-full border-none bg-transparent py-0 pl-2 pr-1 text-xs md:text-xs leading-4 hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 text-foreground-light w-full absolute left-0 top-0"
placeholder={property.label}
autoFocus
aria-label={`Change property from ${property.label}`}
@@ -292,15 +306,22 @@ export function FilterCondition({
data-form-type="other"
/>
) : null}
<span
className={cn(
'text-xs pl-2 pr-1 shrink-0 whitespace-nowrap text-foreground-light h-full flex items-center cursor-pointer hover:text-foreground transition-colors',
isPropertyActive && 'invisible'
)}
onClick={() => handleLabelClick(path)}
>
{property.label}
</span>
{isPropertyActive ? (
<span className="invisible text-xs leading-4 pl-2 pr-1 shrink-0 whitespace-nowrap h-full flex items-center">
{property.label}
</span>
) : (
<button
type="button"
tabIndex={0}
className="text-xs leading-4 pl-2 pr-1 shrink-0 whitespace-nowrap text-foreground-light h-full flex items-center cursor-pointer hover:text-foreground transition-colors"
aria-label={`Change property from ${property.label}`}
onFocus={() => handleLabelClick(path)}
onClick={() => handleLabelClick(path)}
>
{property.label}
</button>
)}
</div>
</PopoverAnchor>
<PopoverContent
@@ -341,7 +362,7 @@ export function FilterCondition({
className="h-full border-none bg-transparent py-0 px-1 text-center text-xs md:text-xs hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 text-foreground w-full absolute left-0 top-0"
aria-label={`Operator for ${property.label}`}
data-testid={`filter-operator-${property.name}`}
tabIndex={-1}
tabIndex={0}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
@@ -386,10 +407,14 @@ export function FilterCondition({
onFocus={() => handleInputFocus(path)}
onBlur={handleValueBlur}
onKeyDown={handleValueKeyDown}
className="h-full border-none bg-transparent py-0 px-1 text-xs md:text-xs hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-full absolute left-0 top-0"
className={cn(
'h-full border-none bg-transparent py-0 px-1 text-xs md:text-xs hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-full absolute left-0 top-0',
hasFormattedCustomValue &&
'focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring'
)}
aria-label={`Value for ${property.label}`}
data-testid={`filter-value-${property.name}`}
tabIndex={-1}
tabIndex={0}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
@@ -453,9 +478,8 @@ export function FilterCondition({
/>
}
onClick={onRemove}
className="group hover:text-foreground hover:!bg-surface-600 rounded-none px-1 h-auto py-0"
className="group relative !h-[18px] !w-[18px] shrink-0 self-center m-[3px] rounded-sm !p-0 hover:text-foreground hover:!bg-surface-600 focus-visible:z-20 focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-offset-0"
aria-label={`Remove ${property.label} filter`}
tabIndex={-1}
data-testid={`filter-remove-${property.name}`}
/>
</div>
@@ -206,7 +206,7 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
onFocus={() => handleGroupFreeformFocus(path)}
onBlur={handleFreeformBlur}
onKeyDown={handleFreeformKeyDown}
className="border-none bg-transparent text-xs hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-full flex-1 h-auto min-w-0 px-2 py-1"
className="relative m-[3px] border-none bg-transparent text-xs hover:border-transparent focus:border-transparent focus-visible:border-transparent focus:outline-hidden focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-auto flex-1 h-auto min-w-0 px-2 py-1"
placeholder={
group.conditions.length === 0 ? emptyPlaceholder : 'Add more filters...'
}