diff --git a/src/forms/BoundIconCardGroupField.test.tsx b/src/forms/BoundIconCardGroupField.test.tsx index 48503dbe1..6498e9dd2 100644 --- a/src/forms/BoundIconCardGroupField.test.tsx +++ b/src/forms/BoundIconCardGroupField.test.tsx @@ -4,25 +4,36 @@ import { ObjectConfig, ObjectState, createObjectState, required } from "@homebou import { BoundIconCardGroupField } from "./BoundIconCardGroupField"; import { click, render } from "src/utils/rtl"; -const categories: IconCardGroupItemOption[] = [ - { icon: "abacus", label: "Math", value: "math" }, - { icon: "archive", label: "History", value: "history" }, - { icon: "dollar", label: "Finance", value: "finance" }, - { icon: "hardHat", label: "Engineering", value: "engineering" }, - { icon: "kanban", label: "Management", value: "management" }, - { icon: "camera", label: "Media", value: "media" }, +enum Category { + Math, + History, + Finance, + Engineering, + Management, + Media, +} + +const categories: IconCardGroupItemOption[] = [ + { icon: "abacus", label: "Math", value: Category.Math }, + { icon: "archive", label: "History", value: Category.History }, + { icon: "dollar", label: "Finance", value: Category.Finance }, + { icon: "hardHat", label: "Engineering", value: Category.Engineering }, + { icon: "kanban", label: "Management", value: Category.Management }, + { icon: "camera", label: "Media", value: Category.Media }, ]; +type NewAuthor = Omit & { favoriteGenres?: Category[] | null }; + describe("BoundIconCardGroupField", () => { it("shows the label", async () => { - const author = createObjectState(formConfig, { favoriteGenres: ["math"] }); + const author = createObjectState(formConfig, { favoriteGenres: [Category.Math] }); const r = await render(); expect(r.favoriteGenres_label).toHaveTextContent("Favorite Genres"); }); it("triggers 'maybeAutoSave' on change", async () => { const autoSave = jest.fn(); // Given a BoundIconCardGroupField with auto save - const author: ObjectState = createObjectState( + const author: ObjectState = createObjectState( formConfig, {}, { maybeAutoSave: () => autoSave(author.favoriteGenres.value) }, @@ -30,12 +41,12 @@ describe("BoundIconCardGroupField", () => { const r = await render(); // When toggling the checkbox off - click(r.favoriteGenres_math); + click(r.favoriteGenres_Math); // Then the callback should be triggered with the current value - expect(autoSave).toBeCalledWith(["math"]); + expect(autoSave).toBeCalledWith([Category.Math]); }); }); -const formConfig: ObjectConfig = { +const formConfig: ObjectConfig = { favoriteGenres: { type: "value", rules: [required], strictOrder: false }, }; diff --git a/src/forms/BoundIconCardGroupField.tsx b/src/forms/BoundIconCardGroupField.tsx index 928462abe..151bfd410 100644 --- a/src/forms/BoundIconCardGroupField.tsx +++ b/src/forms/BoundIconCardGroupField.tsx @@ -3,16 +3,20 @@ import { Observer } from "mobx-react"; import { IconCardGroup, IconCardGroupProps } from "src/inputs/IconCardGroup"; import { useTestIds } from "src/utils"; import { defaultLabel } from "src/utils/defaultLabel"; +import { Value } from "src/inputs"; -export type BoundIconCardGroupFieldProps = Omit & { - field: FieldState; +export type BoundIconCardGroupFieldProps = Omit< + IconCardGroupProps, + "label" | "values" | "onChange" +> & { + field: FieldState; /** Make optional so that callers can override if they want to. */ - onChange?: (values: string[]) => void; + onChange?: (values: V[]) => void; label?: string; }; /** Wraps `IconCardGroup` and binds it to a form field. */ -export function BoundIconCardGroupField(props: BoundIconCardGroupFieldProps) { +export function BoundIconCardGroupField(props: BoundIconCardGroupFieldProps) { const { field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props; const testId = useTestIds(props, field.key); return ( diff --git a/src/inputs/IconCardGroup.stories.tsx b/src/inputs/IconCardGroup.stories.tsx index 227ee5c9f..4d13949d6 100644 --- a/src/inputs/IconCardGroup.stories.tsx +++ b/src/inputs/IconCardGroup.stories.tsx @@ -1,28 +1,37 @@ import { Meta } from "@storybook/react"; import { IconCardGroup, IconCardGroupItemOption, IconCardGroupProps } from "./IconCardGroup"; -import { Chips } from "src/components"; import { useState } from "react"; import { Css } from "src/Css"; export default { component: IconCardGroup, -} as Meta; +} as Meta>; -const categories: IconCardGroupItemOption[] = [ - { icon: "abacus", label: "Math", value: "math" }, - { icon: "archive", label: "History", value: "history" }, - { icon: "dollar", label: "Finance", value: "finance" }, - { icon: "hardHat", label: "Engineering", value: "engineering" }, - { icon: "kanban", label: "Management", value: "management" }, - { icon: "camera", label: "Media", value: "media" }, +enum Category { + Math, + History, + Finance, + Engineering, + Management, + Media, + Na, +} + +const categories: IconCardGroupItemOption[] = [ + { icon: "abacus", label: "Math", value: Category.Math }, + { icon: "archive", label: "History", value: Category.History }, + { icon: "dollar", label: "Finance", value: Category.Finance }, + { icon: "hardHat", label: "Engineering", value: Category.Engineering }, + { icon: "kanban", label: "Management", value: Category.Management }, + { icon: "camera", label: "Media", value: Category.Media }, + { icon: "remove", label: "N/A", value: Category.Na, exclusive: true }, ]; export function Default() { - const [values, setValues] = useState(["math"]); + const [values, setValues] = useState([Category.Math]); return (
-
{ icon: IconProps["icon"]; label: string; disabled?: boolean; /** The value of the IconCardGroup item, stored in value array in state. */ - value: string; + value: V; + /** Exclusive: if true, this option will override all other options when selected. */ + exclusive?: boolean; } -export interface IconCardGroupProps extends Pick { +export interface IconCardGroupProps extends Pick { label: string; /** Called when a card is selected */ - onChange: (values: string[]) => void; + onChange: (values: V[]) => void; /** Options for the cards contained within the IconCardGroup. */ - options: IconCardGroupItemOption[]; + options: IconCardGroupItemOption[]; /** The values currently selected. */ - values: string[]; + values: V[]; errorMsg?: string; helperText?: string | ReactNode; disabled?: boolean; } -export function IconCardGroup(props: IconCardGroupProps) { +export function IconCardGroup(props: IconCardGroupProps) { const { fieldProps } = usePresentationContext(); const { options, @@ -41,14 +43,51 @@ export function IconCardGroup(props: IconCardGroupProps) { errorMsg, helperText, disabled: isDisabled = false, + onChange, } = props; - const state = useCheckboxGroupState({ ...props, isDisabled, value: values }); - const { groupProps, labelProps } = useCheckboxGroup(props, state); + const [selected, setSelected] = useState(values); + + const exclusiveOptions = useMemo(() => options.filter((o) => o.exclusive), [options]); + + const toggleValue = useCallback( + (value: V) => { + if (isDisabled) return; + + const option = options.find((o) => o.value === value); + if (!option) return; + + let newSelected: V[] = []; + if (selected.includes(value)) { + newSelected = selected.filter((v) => v !== value); + } else { + if (option.exclusive) { + newSelected = [value]; + } else { + newSelected = [...selected, value]; + + // Filter out any exclusive options as a non-exclusive option was selected. + newSelected = newSelected.filter((v) => !exclusiveOptions.some((o) => o.value === v)); + } + } + setSelected(newSelected); + onChange(newSelected); + }, + [exclusiveOptions, isDisabled, onChange, options, selected], + ); + const tid = useTestIds(props); + const { labelProps, fieldProps: fieldPropsAria } = useField(props); + + const groupProps = mergeProps(tid, { + role: "group", + "aria-disabled": isDisabled || undefined, + ...fieldPropsAria, + }); + return ( -
+
{labelStyle !== "hidden" && (