Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: IconCard & IconCardGroup #957

Merged
merged 6 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading