Skip to content

Commit

Permalink
feat: IconCard & IconCardGroup (#957)
Browse files Browse the repository at this point in the history
* feat: IconCard & IconCardGroup

* feat: bound fields

* fix: PR feedback

* Update index.ts
  • Loading branch information
0ces authored Oct 13, 2023
1 parent dd24eb5 commit d68a193
Show file tree
Hide file tree
Showing 10 changed files with 401 additions and 0 deletions.
58 changes: 58 additions & 0 deletions src/forms/BoundIconCardField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ObjectConfig, ObjectState, createObjectState, required } from "@homebound/form-state";
import { BoundIconCardField } from "./BoundIconCardField";
import { click, render } from "src/utils/rtl";
import { AuthorInput } from "./formStateDomain";

const formConfig: ObjectConfig<AuthorInput> = {
isAvailable: { type: "value", rules: [required] },
};

const formConfigReadOnly: ObjectConfig<AuthorInput> = {
isAvailable: { type: "value", rules: [required], readOnly: true },
};

describe("BoundIconCardField", () => {
it("should be selected", async () => {
// Given a formState with true boolean field
const formState = createObjectState(formConfig, { isAvailable: true });
// When rendered
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);
// Then the BoundCheckboxField should be checked
expect(r.isAvailable_value).toBeChecked();
});

it("should uncheck when clicked", async () => {
// Given a rendered checked BoundCheckboxField
const formState = createObjectState(formConfig, { isAvailable: true });
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);
// When interacting with a BoundCheckboxField
click(r.isAvailable);
// Then expect the checkbox to be unchecked and the formState to reflect that state
expect(r.isAvailable).not.toBeChecked();
expect(formState.isAvailable.value).toBeFalsy();
});

it("triggers 'maybeAutoSave' on change", async () => {
const autoSave = jest.fn();
// Given a BoundCheckboxField with auto save
const formState: ObjectState<AuthorInput> = createObjectState(
formConfig,
{ isAvailable: true },
{ maybeAutoSave: () => autoSave(formState.isAvailable.value) },
);
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);

// When toggling the checkbox off
click(r.isAvailable);
// Then the callback should be triggered with the current value
expect(autoSave).toBeCalledWith(false);
});
it("disables when field is readonly", async () => {
// Given a readOnly BoundCheckboxField
const formState: ObjectState<AuthorInput> = createObjectState(formConfigReadOnly, { isAvailable: true });
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);

// Then the checkbox should be disabled
expect(r.isAvailable).toBeDisabled();
});
});
39 changes: 39 additions & 0 deletions src/forms/BoundIconCardField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FieldState } from "@homebound/form-state";
import { IconCard, IconCardProps } from "src/inputs";
import { defaultLabel } from "src/utils/defaultLabel";
import { useTestIds } from "src/utils";
import { Observer } from "mobx-react";
import { IconProps } from "src/components";

export type BoundIconCardFieldProps = Omit<IconCardProps, "label" | "selected" | "onChange"> & {
field: FieldState<boolean | null | undefined>;
icon: IconProps["icon"];
/** Make optional so that callers can override if they want to. */
onChange?: (values: boolean) => void;
label?: string;
};

/** Wraps `IconCard` and binds it to a form field. */
export function BoundIconCardField(props: BoundIconCardFieldProps) {
const { icon, field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props;
const testId = useTestIds(props, field.key);
return (
<Observer>
{() => (
<IconCard
icon={icon}
label={label}
selected={field.value ?? false}
onChange={(selected) => {
// We are triggering blur manually for checkbox fields due to its transactional nature
onChange(selected);
field.maybeAutoSave();
}}
disabled={field.readOnly}
{...testId}
{...others}
/>
)}
</Observer>
);
}
41 changes: 41 additions & 0 deletions src/forms/BoundIconCardGroupField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { IconCardGroupItemOption } from "src/inputs/IconCardGroup";
import { AuthorInput } from "./formStateDomain";
import { ObjectConfig, ObjectState, createObjectState, required } from "@homebound/form-state";
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" },
];

describe("BoundIconCardGroupField", () => {
it("shows the label", async () => {
const author = createObjectState(formConfig, { favoriteGenres: ["math"] });
const r = await render(<BoundIconCardGroupField field={author.favoriteGenres} options={categories} />);
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<AuthorInput> = createObjectState(
formConfig,
{},
{ maybeAutoSave: () => autoSave(author.favoriteGenres.value) },
);
const r = await render(<BoundIconCardGroupField field={author.favoriteGenres} options={categories} />);

// When toggling the checkbox off
click(r.favoriteGenres_math);
// Then the callback should be triggered with the current value
expect(autoSave).toBeCalledWith(["math"]);
});
});

const formConfig: ObjectConfig<AuthorInput> = {
favoriteGenres: { type: "value", rules: [required], strictOrder: false },
};
36 changes: 36 additions & 0 deletions src/forms/BoundIconCardGroupField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FieldState } from "@homebound/form-state";
import { Observer } from "mobx-react";
import { IconCardGroup, IconCardGroupProps } from "src/inputs/IconCardGroup";
import { useTestIds } from "src/utils";
import { defaultLabel } from "src/utils/defaultLabel";

export type BoundIconCardGroupFieldProps = Omit<IconCardGroupProps, "label" | "values" | "onChange"> & {
field: FieldState<string[] | null | undefined>;
/** Make optional so that callers can override if they want to. */
onChange?: (values: string[]) => void;
label?: string;
};

/** Wraps `IconCardGroup` and binds it to a form field. */
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 (
<Observer>
{() => (
<IconCardGroup
label={label}
values={field.value || []}
onChange={(values) => {
// We are triggering blur manually for checkbox fields due to its transactional nature
onChange(values);
field.maybeAutoSave();
}}
disabled={field.readOnly}
{...testId}
{...others}
/>
)}
</Observer>
);
}
2 changes: 2 additions & 0 deletions src/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export * from "./BoundTextAreaField";
export * from "./BoundTextField";
export * from "./BoundToggleChipGroupField";
export * from "./BoundTreeSelectField";
export * from "./BoundIconCardField";
export * from "./BoundIconCardGroupField";
export * from "./FormHeading";
export * from "./FormLines";
export * from "./StaticField";
39 changes: 39 additions & 0 deletions src/inputs/IconCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Meta } from "@storybook/react";
import { Css, IconCard, IconCardProps } from "src";
import { iconCardStylesHover } from "src/inputs/IconCard";

export default {
component: IconCard,
} as Meta<IconCardProps>;

export function Default() {
return (
<div css={Css.df.gap2.jcfs.$}>
<div>
<h2>Selected</h2>
<IconCard icon="house" label="Structural Design" selected onChange={() => {}} />
</div>
<div>
<h2>Not Selected</h2>
<IconCard icon="house" label="Structural Design" onChange={() => {}} />
</div>
<div>
<h2>Hover</h2>
<HoveredIconCard icon="house" label="Structural Design" onChange={() => {}} />
</div>
<div>
<h2>Disabled</h2>
<IconCard icon="house" label="Structural Design" disabled onChange={() => {}} />
</div>
</div>
);
}

/** Hover styled version of the IconButton */
function HoveredIconCard(args: IconCardProps) {
return (
<div css={{ button: iconCardStylesHover }}>
<IconCard {...args} />
</div>
);
}
70 changes: 70 additions & 0 deletions src/inputs/IconCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { RefObject, useMemo } from "react";
import { useCheckbox, useHover, VisuallyHidden } from "react-aria";
import { useToggleState } from "react-stately";
import { Icon, IconProps, maybeTooltip, resolveTooltip } from "src/components";
import { Css, Palette } from "src/Css";
import { useGetRef } from "src/hooks/useGetRef";
import { noop, useTestIds } from "src/utils";
import { defaultTestId } from "src/utils/defaultTestId";

export interface IconCardProps {
/** The icon to use within the card. */
icon: IconProps["icon"];
label: string;
selected?: boolean;
/** Handler that is called when the element's selection state changes. */
onChange?: (selected: boolean) => void;
cardRef?: RefObject<HTMLInputElement>;
disabled?: boolean;
tooltip?: string;
}

export function IconCard(props: IconCardProps) {
const {
selected: isSelected = false,
disabled: isDisabled = false,
icon,
cardRef,
label,
tooltip,
...otherProps
} = props;
const ref = useGetRef(cardRef);
const ariaProps = { isSelected, isDisabled, ...otherProps };
const checkboxProps = { ...ariaProps, "aria-label": label };

const { hoverProps, isHovered } = useHover({ isDisabled });
const toggleState = useToggleState(ariaProps);
const { inputProps } = useCheckbox(checkboxProps, toggleState, ref);

const styles = useMemo(
() => ({
...baseStyles,
...(isHovered && iconCardStylesHover),
...(isSelected && selectedStyles),
...(isDisabled && disabledStyles),
}),
[isDisabled, isHovered, isSelected],
);

const tid = useTestIds(props, defaultTestId(label));

return maybeTooltip({
title: resolveTooltip(isDisabled, tooltip),
placement: "top",
children: (
<button css={styles} {...hoverProps} onClick={toggleState.toggle} disabled={isDisabled} {...tid}>
<VisuallyHidden>
<input ref={ref} {...inputProps} {...tid.value} />
</VisuallyHidden>
<Icon icon={icon} inc={4} color={isDisabled ? Palette.Gray300 : Palette.Gray900} />
<span css={Css.xsMd.if(isDisabled).gray300.$}>{label}</span>
</button>
),
});
}

const baseStyles = Css.df.fdc.aic.jcc.wPx(130).hPx(114).ba.br8.bGray300.bgWhite.gap("12px").p2.tc.$;
export const selectedStyles = Css.bw2.bBlue500.bgBlue50.$;
const disabledStyles = Css.bGray200.bgGray50.$;
export const iconCardStylesHover = Css.bgGray100.$;
36 changes: 36 additions & 0 deletions src/inputs/IconCardGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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<IconCardGroupProps>;

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" },
];

export function Default() {
const [values, setValues] = useState<string[]>(["math"]);

return (
<div>
<Chips values={values} />
<div css={Css.df.wPx(500).$}>
<IconCardGroup
label="Icon Card Group"
options={categories}
onChange={(values) => setValues(values)}
values={values}
/>
</div>
</div>
);
}
Loading

0 comments on commit d68a193

Please sign in to comment.