diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx index 93c1d9c13a2..58b03b8fd9a 100644 --- a/apps/design-system/content/docs/accessibility.mdx +++ b/apps/design-system/content/docs/accessibility.mdx @@ -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 diff --git a/apps/design-system/content/docs/fragments/filter-bar.mdx b/apps/design-system/content/docs/fragments/filter-bar.mdx index 50a5b2a9e2b..669004d818d 100644 --- a/apps/design-system/content/docs/fragments/filter-bar.mdx +++ b/apps/design-system/content/docs/fragments/filter-bar.mdx @@ -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 + + + +### Pill + + + +```tsx + +``` + ## 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) diff --git a/apps/design-system/registry/default/example/filter-bar-demo.tsx b/apps/design-system/registry/default/example/filter-bar-demo.tsx index 456f71a7945..604043a5cbd 100644 --- a/apps/design-system/registry/default/example/filter-bar-demo.tsx +++ b/apps/design-system/registry/default/example/filter-bar-demo.tsx @@ -118,19 +118,28 @@ const initialFilters: FilterGroup = { conditions: [], } -export default function FilterBarDemo() { +function FilterBarExample({ variant }: { variant: 'default' | 'pill' }) { const [filters, setFilters] = useState(initialFilters) const [freeformText, setFreeformText] = useState('') return ( -
- + + ) +} + +export default function FilterBarDemo() { + return ( +
+ +
) } diff --git a/apps/design-system/registry/default/example/filter-bar-pill-demo.tsx b/apps/design-system/registry/default/example/filter-bar-pill-demo.tsx new file mode 100644 index 00000000000..f202135edcb --- /dev/null +++ b/apps/design-system/registry/default/example/filter-bar-pill-demo.tsx @@ -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({ logicalOperator: 'AND', conditions: [] }) + const [freeformText, setFreeformText] = useState('') + + return ( +
+ +
+ ) +} diff --git a/apps/design-system/registry/default/example/filter-bar-segmented-demo.tsx b/apps/design-system/registry/default/example/filter-bar-segmented-demo.tsx new file mode 100644 index 00000000000..81e435d8de7 --- /dev/null +++ b/apps/design-system/registry/default/example/filter-bar-segmented-demo.tsx @@ -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({ logicalOperator: 'AND', conditions: [] }) + const [freeformText, setFreeformText] = useState('') + + return ( +
+ +
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 540b6ac2e17..256eaa8f60f 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -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', diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 680bd44cddd..36b3d25d99e 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -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() diff --git a/e2e/studio/utils/filter-bar-helpers.ts b/e2e/studio/utils/filter-bar-helpers.ts index 6440e430e6f..f1c94dd53e1 100644 --- a/e2e/studio/utils/filter-bar-helpers.ts +++ b/e2e/studio/utils/filter-bar-helpers.ts @@ -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}`) diff --git a/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx b/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx index cc19cc4fa04..a34a8a61fe0 100644 --- a/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + 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 diff --git a/packages/ui-patterns/src/FilterBar/FilterBar.tsx b/packages/ui-patterns/src/FilterBar/FilterBar.tsx index 4ffd929a0aa..84cc0753af7 100644 --- a/packages/ui-patterns/src/FilterBar/FilterBar.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterBar.tsx @@ -59,7 +59,7 @@ function FilterBarContent({ className }: { className?: string }) {
diff --git a/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx b/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx index cc0306fbce2..7effeb09317 100644 --- a/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx @@ -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(fun isLoading: loading, error, highlightedConditionPath, + setHighlightedConditionPath, onFilterChange, commitFilters, diff --git a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx index 6a512c7d79f..d6a86a5c97f 100644 --- a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx @@ -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) => { + handleRemoveCondition(path) + if (event?.detail === 0) { + window.setTimeout(() => { + rootRef.current + ?.querySelector('[data-testid="filter-bar-freeform-input"]') + ?.focus() + }, 0) + } + }, + [handleRemoveCondition, path, rootRef] + ) if (!property) return null @@ -263,10 +275,12 @@ export function FilterCondition({
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} - handleLabelClick(path)} - > - {property.label} - + {isPropertyActive ? ( + + {property.label} + + ) : ( + + )}
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}`} />
diff --git a/packages/ui-patterns/src/FilterBar/FilterGroup.tsx b/packages/ui-patterns/src/FilterBar/FilterGroup.tsx index 6f9dae85de5..29add803ef6 100644 --- a/packages/ui-patterns/src/FilterBar/FilterGroup.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterGroup.tsx @@ -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...' }