Skip to content

Commit

Permalink
FilterBar: manage focus on add/remove filter
Browse files Browse the repository at this point in the history
  • Loading branch information
HeartSquared authored and dougmacknz committed Oct 21, 2024
1 parent 03bc020 commit bd43488
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/many-plums-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kaizen/components": patch
---

FilterBar: manage focus on add/remove filters
94 changes: 94 additions & 0 deletions packages/components/src/Filter/FilterBar/FilterBar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const simpleFilters = [
type ValuesRemovable = {
flavour: string
topping: string
others: string
}

const filtersRemovable = [
Expand All @@ -86,6 +87,37 @@ const filtersRemovable = [
),
isRemovable: true,
},
{
id: "others",
name: "Others",
Component: (
<FilterBar.MultiSelect
items={[
{ value: "gluten-free", label: "Gluten Free" },
{ value: "no-sugar", label: "No Sugar" },
{ value: "dairy-free", label: "Dairy Free" },
]}
>
{(): JSX.Element => (
<>
<FilterMultiSelect.SearchInput />
<FilterMultiSelect.ListBox>
{({ allItems }): JSX.Element | JSX.Element[] =>
allItems.map(item => (
<FilterMultiSelect.Option key={item.key} item={item} />
))
}
</FilterMultiSelect.ListBox>
<FilterMultiSelect.MenuFooter>
<FilterMultiSelect.SelectAllButton />
<FilterMultiSelect.ClearButton />
</FilterMultiSelect.MenuFooter>
</>
)}
</FilterBar.MultiSelect>
),
isRemovable: true,
},
] satisfies Filters<ValuesRemovable>

type ValuesDependent = {
Expand Down Expand Up @@ -288,6 +320,68 @@ describe("<FilterBar />", () => {
expect(filters[1]).toHaveTextContent("Flavour")
expect(filters[2]).toHaveTextContent("Sugar Level")
})

it("moves focus to recently added filter button", async () => {
const { getByRole } = render(
<FilterBarWrapper<ValuesSimple>
filters={simpleFilters.map(filter => ({
...filter,
isRemovable: true,
}))}
/>
)
await waitForI18nContent()

const addFiltersButton = getByRole("button", { name: "Add Filters" })
await user.click(addFiltersButton)

const menuOptionIceLevel = getByRole("button", { name: "Ice Level" })
await user.click(menuOptionIceLevel)

expect(getByRole("button", { name: "Ice Level" })).toHaveFocus()
})

it("moves focus to recently added filter button in the FilterMultiSelect case", async () => {
const { getByRole } = render(
<FilterBarWrapper<ValuesSimple>
filters={simpleFilters.map(filter => ({
...filter,
isRemovable: true,
}))}
/>
)
await waitForI18nContent()

const addFiltersButton = getByRole("button", { name: "Add Filters" })
await user.click(addFiltersButton)

const menuOptionOthers = getByRole("button", { name: "Others" })
await user.click(menuOptionOthers)

await waitFor(() => {
expect(getByRole("button", { name: "Others" })).toHaveFocus()
})
})

it("restores focus to the add filter button after remove", async () => {
const { getByRole } = render(
<FilterBarWrapper<ValuesRemovable>
filters={filtersRemovable}
defaultValues={{ topping: "pearls" }}
/>
)
await waitForI18nContent()

const filterButton = getByRole("button", { name: "Topping : Pearls" })
expect(filterButton).toBeVisible()

await user.click(getByRole("button", { name: "Remove filter - Topping" }))
await waitFor(() => {
expect(filterButton).not.toBeInTheDocument()
})

expect(getByRole("button", { name: "Add Filters" })).toHaveFocus()
})
})

describe("Dependent filters", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type FilterBarContextValue<
hideFilter: <Id extends keyof ValuesMap>(id: Id) => void
getInactiveFilters: () => Array<FilterAttributes<ValuesMap>>
clearAllFilters: () => void
setFocus: <Id extends keyof ValuesMap>(id: Id | undefined) => void
focusId: keyof ValuesMap | undefined
}

const FilterBarContext = React.createContext<FilterBarContextValue<any> | null>(
Expand Down Expand Up @@ -119,10 +121,13 @@ export const FilterBarProvider = <ValuesMap extends FiltersValues>({
values: { ...values, [id]: getValidValue(newValue) },
})
},
showFilter: <Id extends keyof ValuesMap>(id: Id): void =>
dispatch({ type: "activate_filter", id }),
showFilter: <Id extends keyof ValuesMap>(id: Id): void => {
dispatch({ type: "activate_filter", id })
dispatch({ type: "set_focus", id })
},
hideFilter: <Id extends keyof ValuesMap>(id: Id): void => {
dispatch({ type: "deactivate_filter", id })
dispatch({ type: "set_focus", id: "add_filter" })
},
getInactiveFilters: () => getInactiveFilters<ValuesMap>(state),
clearAllFilters: () => {
Expand All @@ -132,6 +137,10 @@ export const FilterBarProvider = <ValuesMap extends FiltersValues>({
})
dispatch({ type: "update_values", values: {} })
},
setFocus: <Id extends keyof ValuesMap>(id: Id | undefined) => {
dispatch({ type: "set_focus", id })
},
focusId: state.focusId,
} satisfies FilterBarContextValue<any, ValuesMap>

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ type Actions<ValuesMap extends FiltersValues> =
| { type: "activate_filter"; id: keyof ValuesMap }
| { type: "deactivate_filter"; id: keyof ValuesMap }
| { type: "update_filter_labels"; data: Filters<ValuesMap> }
| { type: "set_focus"; id: keyof ValuesMap | undefined }

export const filterBarStateReducer = <ValuesMap extends FiltersValues>(
state: FilterBarState<ValuesMap>,
action: Actions<ValuesMap>
): FilterBarState<ValuesMap> => {
switch (action.type) {
case "set_focus":
return {
...state,
focusId: action.id,
}

case "update_values":
return { ...updateValues(state, action.values) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const setupFilterBarState = <ValuesMap extends FiltersValues>(
values,
dependentFilterIds: new Set(),
hasUpdatedValues: false,
focusId: undefined,
} as FilterBarState<ValuesMap>
)

Expand Down
1 change: 1 addition & 0 deletions packages/components/src/Filter/FilterBar/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type FilterBarState<ValuesMap extends FiltersValues> = {
activeFilterIds: Set<keyof ValuesMap>
values: Partial<ValuesMap>
dependentFilterIds: Set<keyof ValuesMap>
focusId?: keyof ValuesMap | undefined
}

export type ActiveFiltersArray<ValuesMap extends FiltersValues> = Array<
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from "react"
import React, { useEffect, useRef } from "react"
import { useIntl } from "@cultureamp/i18n-react-intl"
import { Menu, MenuList, MenuItem, Button } from "~components/__actions__/v2"
import { Icon } from "~components/__future__/Icon"
import { useFilterBarContext } from "../../context/FilterBarContext"

export const AddFiltersMenu = (): JSX.Element => {
const { formatMessage } = useIntl()
const buttonRef = useRef<HTMLButtonElement>(null)

const menuButtonLabel = formatMessage({
id: "filterBar.addFiltersMenu.buttonLabel",
Expand All @@ -14,13 +15,22 @@ export const AddFiltersMenu = (): JSX.Element => {
"Menu button label to show additional available filter options",
})

const { getInactiveFilters, showFilter } = useFilterBarContext()
const { getInactiveFilters, showFilter, focusId, setFocus } =
useFilterBarContext()
const inactiveFilters = getInactiveFilters()

useEffect(() => {
if (focusId === "add_filter") {
buttonRef.current?.focus()
setFocus(undefined)
}
}, [focusId])

return (
<Menu
button={
<Button
ref={buttonRef}
label={menuButtonLabel}
secondary
disabled={inactiveFilters.length === 0}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { forwardRef } from "react"
import React, { forwardRef, useEffect } from "react"
import { FilterTriggerRef } from "~components/Filter/Filter"
import { useFilterBarContext } from "~components/Filter/FilterBar/context/FilterBarContext"
import {
FilterButton,
FilterButtonProps,
FilterButtonRemovable,
} from "~components/Filter/FilterButton"
import { isRefObject } from "~components/utils/isRefObject"

export type FilterBarButtonProps = FilterButtonProps & {
filterId: string
Expand All @@ -16,7 +17,14 @@ export const FilterBarButton = forwardRef<
FilterTriggerRef,
FilterBarButtonProps
>(({ filterId, isRemovable = false, ...props }, ref): JSX.Element => {
const { hideFilter } = useFilterBarContext()
const { hideFilter, focusId, setFocus } = useFilterBarContext()

useEffect(() => {
if (focusId === filterId && isRefObject(ref)) {
ref?.current?.triggerRef?.current?.focus()
setFocus(undefined)
}
}, [focusId])

return isRemovable ? (
<FilterButtonRemovable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"
import React, { useEffect, useRef, useState } from "react"
import { Selection, Key } from "@react-types/shared"
import {
FilterMultiSelect,
Expand Down Expand Up @@ -43,9 +43,16 @@ export const FilterBarMultiSelect = ({
onSelectionChange,
...props
}: FilterBarMultiSelectProps): JSX.Element | null => {
const { getFilterState, setFilterOpenState, updateValue, hideFilter } =
useFilterBarContext<ConsumableSelection>()
const {
getFilterState,
setFilterOpenState,
updateValue,
hideFilter,
focusId,
setFocus,
} = useFilterBarContext<ConsumableSelection>()
const [items, setItems] = useState<ItemType[]>(propsItems)
const buttonRef = useRef<HTMLButtonElement>(null)

if (!id) throw Error("Missing `id` prop in FilterBarMultiSelect")

Expand All @@ -70,6 +77,13 @@ export const FilterBarMultiSelect = ({
}
}, [items])

useEffect(() => {
if (focusId === id) {
buttonRef.current?.focus()
setFocus(undefined)
}
}, [focusId])

return (
<FilterMultiSelect
label={filterState.name}
Expand Down Expand Up @@ -103,6 +117,7 @@ export const FilterBarMultiSelect = ({
<FilterMultiSelect.TriggerButton {...triggerProps} />
)
}}
triggerRef={buttonRef}
{...props}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type SelectionProps = {
export type FilterMultiSelectProps = {
trigger: (value?: MenuTriggerProviderContextType) => React.ReactNode
children: (value?: SelectionProviderContextType) => React.ReactNode // the content of the menu
triggerRef?: React.RefObject<HTMLButtonElement>
} & Omit<MenuPopupProps, "children"> &
Omit<MenuTriggerProviderProps, "children"> &
SelectionProps
Expand All @@ -60,8 +61,9 @@ export const FilterMultiSelect = ({
onSelectionChange,
selectionMode = "multiple",
onSearchInputChange,
triggerRef,
}: FilterMultiSelectProps): JSX.Element => {
const menuTriggerProps = { isOpen, defaultOpen, onOpenChange }
const menuTriggerProps = { isOpen, defaultOpen, onOpenChange, triggerRef }
const menuPopupProps = { isLoading, loadingSkeleton }
const disabledKeys: Selection = new Set(
items
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type MenuTriggerProviderProps = {
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
children: React.ReactNode
triggerRef?: React.RefObject<HTMLButtonElement>
}

export type MenuTriggerProviderContextType = {
Expand All @@ -32,12 +33,14 @@ export function MenuTriggerProvider({
defaultOpen,
onOpenChange,
children,
triggerRef,
}: MenuTriggerProviderProps): JSX.Element {
// Create state based on the incoming props to manage the open/close
const state = useMenuTriggerState({ isOpen, defaultOpen, onOpenChange })

// Get A11y attributes and events for the menu trigger and menu elements
const ref = useRef<HTMLButtonElement>(null)
const fallbackRef = useRef<HTMLButtonElement>(null)
const ref = triggerRef || fallbackRef
const { menuTriggerProps, menuProps } = useMenuTrigger<ItemType>(
{},
state,
Expand Down

0 comments on commit bd43488

Please sign in to comment.