diff --git a/src/inputs/IconCard.stories.tsx b/src/inputs/IconCard.stories.tsx new file mode 100644 index 000000000..c5e9b0f7a --- /dev/null +++ b/src/inputs/IconCard.stories.tsx @@ -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; + +export function Default() { + return ( +
+
+

Selected

+ {}} /> +
+
+

Not Selected

+ {}} /> +
+
+

Hover

+ {}} /> +
+
+

Disabled

+ {}} /> +
+
+ ); +} + +/** Hover styled version of the IconButton */ +function HoveredIconCard(args: IconCardProps) { + return ( +
+ +
+ ); +} diff --git a/src/inputs/IconCard.tsx b/src/inputs/IconCard.tsx new file mode 100644 index 000000000..c0fdcc2a5 --- /dev/null +++ b/src/inputs/IconCard.tsx @@ -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; + 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: ( + + ), + }); +} + +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.$; diff --git a/src/inputs/IconCardGroup.stories.tsx b/src/inputs/IconCardGroup.stories.tsx new file mode 100644 index 000000000..9765c81fa --- /dev/null +++ b/src/inputs/IconCardGroup.stories.tsx @@ -0,0 +1,40 @@ +import { Meta } from "@storybook/react"; +import { IconCardGroup, IconCardGroupItemOption, IconCardGroupProps } from "./IconCardGroup"; +import { Chips, IconProps } from "src/components"; +import { useState } from "react"; + +export default { + component: IconCardGroup, +} 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" }, +]; + +export function Default() { + const [values, setValues] = useState(["math"]); + + return ( +
+ + ({ + // icon: valuesToIconMap[value], + // label: value, + // value, + // disabled: i === 2, // disable the third option + // }))} + options={categories} + onChange={(values) => setValues(values)} + values={values} + columns={3} + /> +
+ ); +} diff --git a/src/inputs/IconCardGroup.tsx b/src/inputs/IconCardGroup.tsx new file mode 100644 index 000000000..bc79d015a --- /dev/null +++ b/src/inputs/IconCardGroup.tsx @@ -0,0 +1,81 @@ +import { ReactNode } from "react"; +import { useCheckboxGroup } from "react-aria"; +import { useCheckboxGroupState } from "react-stately"; +import { Css } from "src/Css"; +import { IconProps } from "src/components/Icon"; +import { Label } from "src/components/Label"; +import { PresentationFieldProps, usePresentationContext } from "src/components/PresentationContext"; +import { useTestIds } from "src/utils"; +import { IconCard } from "src/inputs/IconCard"; +import { HelperText } from "src/components/HelperText"; +import { ErrorMessage } from "./ErrorMessage"; + +export interface IconCardGroupItemOption { + icon: IconProps["icon"]; + label: string; + disabled?: boolean; + /** The value of the IconCardGroup item, stored in value array in state. */ + value: string; +} + +export interface IconCardGroupProps extends Pick { + label: string; + /** Called when a card is selected */ + onChange: (values: string[]) => void; + /** Options for the cards contained within the IconCardGroup. */ + options: IconCardGroupItemOption[]; + /** The values currently selected. */ + values: string[]; + errorMsg?: string; + helperText?: string | ReactNode; + columns?: number; + disabled?: boolean; +} + +export function IconCardGroup(props: IconCardGroupProps) { + const { fieldProps } = usePresentationContext(); + const { + options, + label, + labelStyle = fieldProps?.labelStyle ?? "above", + values, + errorMsg, + helperText, + columns = 1, + disabled: isDisabled = false, + } = props; + + const state = useCheckboxGroupState({ ...props, isDisabled, value: values }); + const { groupProps, labelProps } = useCheckboxGroup(props, state); + const tid = useTestIds(props); + + return ( +
+ {labelStyle !== "hidden" && ( +
+
+ )} +
+ {options.map((option) => { + const { icon, label, disabled } = option; + const isSelected = state.isSelected(option.value); + const isDisabled = disabled || state.isDisabled; + return ( + state.toggleValue(option.value)} + {...tid[option.value]} + /> + ); + })} +
+ {errorMsg && } + {helperText && } +
+ ); +} diff --git a/src/inputs/index.ts b/src/inputs/index.ts index 09f9f49f0..0c7b766c4 100644 --- a/src/inputs/index.ts +++ b/src/inputs/index.ts @@ -18,4 +18,5 @@ export type { TextFieldApi } from "./TextField"; export * from "./ToggleButton"; export * from "./ToggleChipGroup"; export * from "./TreeSelectField"; +export * from "./IconCard"; export type { Value } from "./Value";