choice-box

PreviousNext

choice-box

Docs
intentuiui

Preview

Loading preview…
components/ui/choice-box.tsx
import { createContext, use } from "react"
import type { GridListItemProps, GridListProps, TextProps } from "react-aria-components"
import { composeRenderProps, GridList, GridListItem, Text } from "react-aria-components"
import { twMerge } from "tailwind-merge"
import type { VariantProps } from "tailwind-variants"
import { tv } from "tailwind-variants"
import { cx } from "@/lib/primitive"
import { Checkbox } from "./checkbox"

const choiceBoxStyles = tv({
  base: "grid [--gutter:--spacing(4)]",
  variants: {
    columns: {
      1: "col-span-full grid-cols-[auto_1fr]",
      2: "sm:grid-cols-2",
      3: "sm:grid-cols-3",
      4: "sm:grid-cols-4",
      5: "sm:grid-cols-5",
      6: "sm:grid-cols-6",
    },
    gap: {
      0: "gap-0",
      1: "gap-1",
      2: "gap-2",
      3: "gap-3",
      4: "gap-4",
      5: "gap-5",
      6: "gap-6",
    },
  },
  defaultVariants: {
    columns: 1,
    gap: 0,
  },
  compoundVariants: [
    {
      gap: 0,
      columns: 1,
      className:
        "rounded-lg *:data-[slot=choice-box-item]:inset-ring *:data-[slot=choice-box-item]:-mt-px *:data-[slot=choice-box-item]:rounded-none *:data-[slot=choice-box-item]:last:rounded-b-[calc(var(--radius-lg)-1px)] *:data-[slot=choice-box-item]:first:rounded-t-[calc(var(--radius-lg)-1px)]",
    },
  ],
})

const ChoiceBoxContext = createContext<{ columns?: number; gap?: number; isReadOnly?: boolean }>({})

const useChoiceBoxContext = () => use(ChoiceBoxContext)

interface ChoiceBoxProps<T extends object>
  extends GridListProps<T>,
    VariantProps<typeof choiceBoxStyles> {
  isReadOnly?: boolean
}

const ChoiceBox = <T extends object>({
  columns = 1,
  gap = 0,
  className,
  selectionMode = "single",
  isReadOnly,
  ...props
}: ChoiceBoxProps<T>) => {
  return (
    <ChoiceBoxContext value={{ columns, gap, isReadOnly }}>
      <GridList
        data-slot="control"
        layout={columns === 1 ? "stack" : "grid"}
        selectionMode={selectionMode}
        className={cx(
          choiceBoxStyles({
            columns,
            gap,
          }),
          className,
        )}
        {...props}
      />
    </ChoiceBoxContext>
  )
}

const choiceBoxItemStyles = tv({
  base: [
    "group outline-hidden",
    "[--choice-box-fg:var(--color-primary-subtle-fg)] [--choice-box:var(--color-primary-subtle)]",
    "[--choice-box-selected-hovered:var(--color-primary-subtle)]/90",
    "inset-ring inset-ring-border rounded-lg p-(--gutter) **:data-[slot=label]:font-medium",
    "**:data-[slot=avatar]:row-span-2 **:data-[slot=avatar]:mt-0.5 **:data-[slot=avatar]:shrink-0",
    "**:data-[slot=icon]:row-span-2 **:data-[slot=icon]:h-[1.1lh] **:data-[slot=icon]:w-5 **:data-[slot=icon]:shrink-0",
    "has-data-[slot=avatar]:grid-cols-[auto_1fr_auto] has-data-[slot=icon]:grid-cols-[auto_1fr_auto]",
    "grid grid-cols-[1fr_auto] content-start items-start gap-x-[calc(var(--gutter)-(--spacing(1)))] gap-y-1",
    "[--choice-box-active-ring:var(--color-ring)]/70 [--choice-box-ring:var(--color-ring)]/20",
    "has-[[slot=description]]:**:data-[slot=label]:font-medium",
  ],
  variants: {
    isLink: {
      true: "cursor-pointer",
      false: "cursor-default",
    },
    isHovered: {
      true: "not-data-readonly:not-data-focus-visible:not-selected:inset-ring-muted-fg/30",
    },
    isFocused: {
      true: "inset-ring-(--choice-box-active-ring) ring-(--choice-box-ring) ring-3 invalid:ring-danger-subtle-fg/20",
    },
    isInvalid: { true: "ring-3 ring-danger-subtle-fg/20" },
    isOneColumn: {
      true: "col-span-full",
    },
    isActive: {
      true: [
        "bg-(--choice-box) text-(--choice-box-fg)",
        "inset-ring-(--choice-box-active-ring) z-20 hover:bg-(--choice-box-selected-hovered)",
        "**:data-[slot=label]:text-(--choice-box-fg)",
        "**:[[slot=description]]:text-(--choice-box-fg)",
      ],
    },
    isDisabled: {
      true: "z-10 opacity-50 **:data-[slot=label]:text-muted-fg forced-colors:text-[GrayText] **:[[slot=description]]:text-muted-fg/70",
    },
  },
})

interface ChoiceBoxItemProps extends GridListItemProps, VariantProps<typeof choiceBoxItemStyles> {
  label?: string
  description?: string
}

const ChoiceBoxItem = ({
  className,
  label,
  description,
  children,
  ...props
}: ChoiceBoxItemProps) => {
  const textValue = typeof children === "string" ? children : undefined
  const { columns, isReadOnly } = useChoiceBoxContext()
  return (
    <GridListItem
      textValue={textValue}
      data-readonly={isReadOnly}
      data-slot="choice-box-item"
      {...props}
      className={composeRenderProps(
        className,
        (className, { isFocusVisible, isSelected, ...renderProps }) =>
          choiceBoxItemStyles({
            ...renderProps,
            isOneColumn: columns === 1,
            isLink: "href" in props,
            isFocused: !isReadOnly && renderProps.isFocused,
            isActive: (!isReadOnly && isSelected) || isFocusVisible,
            className,
          }),
      )}
    >
      {composeRenderProps(children, (children, { selectionMode }) => {
        const isStringChild = typeof children === "string"
        const hasCustomChildren = typeof children !== "undefined"

        const content = hasCustomChildren ? (
          isStringChild ? (
            <ChoiceBoxLabel>{children}</ChoiceBoxLabel>
          ) : (
            children
          )
        ) : (
          <>
            {label && <ChoiceBoxLabel>{label}</ChoiceBoxLabel>}
            {description && <ChoiceBoxDescription>{description}</ChoiceBoxDescription>}
          </>
        )
        return (
          <>
            {content}
            {selectionMode === "multiple" && (
              <Checkbox
                className="col-start-2 self-start group-has-data-[slot=avatar]:col-start-3 group-has-data-[slot=icon]:col-start-3 sm:mt-0.5"
                slot="selection"
              />
            )}
          </>
        )
      })}
    </GridListItem>
  )
}

interface ChoiceBoxLabelProps extends TextProps {
  ref?: React.Ref<HTMLDivElement>
}

const ChoiceBoxLabel = ({ className, ref, ...props }: ChoiceBoxLabelProps) => {
  return (
    <Text
      data-slot="label"
      ref={ref}
      className={twMerge(
        "select-none text-base/6 text-fg group-disabled:opacity-50 sm:text-sm/6",
        "col-start-1 row-start-1",
        "group-has-data-[slot=icon]:col-start-2",
        "group-has-data-[slot=avatar]:col-start-2",
        className,
      )}
      {...props}
    />
  )
}

type ChoiceBoxDescriptionProps = ChoiceBoxLabelProps

const ChoiceBoxDescription = ({ className, ref, ...props }: ChoiceBoxDescriptionProps) => {
  return (
    <Text
      slot="description"
      ref={ref}
      className={twMerge(
        "col-start-1 row-start-2",
        "group-has-data-[slot=icon]:col-start-2",
        "group-has-data-[slot=avatar]:col-start-2",
        "text-base/6 text-muted-fg sm:text-sm/6",
        "group-disabled:opacity-50",
        className,
      )}
      {...props}
    />
  )
}

export type { ChoiceBoxProps, ChoiceBoxItemProps }
export { ChoiceBox, ChoiceBoxItem, ChoiceBoxLabel, ChoiceBoxDescription }

Installation

npx shadcn@latest add @intentui/choice-box

Usage

import { ChoiceBox } from "@/components/ui/choice-box"
<ChoiceBox />