{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-grid",
  "type": "registry:block",
  "title": "Product grid with filters",
  "description": "A product grid with category and finish filters, a sort and an announced count, over cards that are one link each.",
  "author": "Box Kite <https://www.box-kite.dev>",
  "categories": [
    "ecommerce",
    "product"
  ],
  "dependencies": [
    "@box-kite/react"
  ],
  "registryDependencies": [
    "https://www.box-kite.dev/r/store-design.json"
  ],
  "files": [
    {
      "path": "registry/designs/store/product-grid.tsx",
      "content": "'use client';\nimport Box from '@box-kite/react';\nimport Button from '@box-kite/react/components/button';\nimport Checkbox from '@box-kite/react/components/checkbox';\nimport Dropdown from '@box-kite/react/components/dropdown';\nimport Flex from '@box-kite/react/components/flex';\nimport Grid from '@box-kite/react/components/grid';\nimport { H2, P, Section, Span } from '@box-kite/react/components/semantics';\nimport { type ReactNode, useId, useMemo, useState } from 'react';\nimport ProductCard from './product-card';\nimport { categories, finishes, type Product, products as catalogue } from './store-data';\nimport { heading } from './store-design';\n\ntype Sort = 'featured' | 'price-asc' | 'price-desc' | 'name';\n\nconst sorts: { value: Sort; label: string }[] = [\n  { value: 'featured', label: 'Featured' },\n  { value: 'price-asc', label: 'Price, low to high' },\n  { value: 'price-desc', label: 'Price, high to low' },\n  { value: 'name', label: 'Name' },\n];\n\nconst order: Record<Sort, (a: Product, b: Product) => number> = {\n  featured: () => 0,\n  'price-asc': (a, b) => a.price - b.price,\n  'price-desc': (a, b) => b.price - a.price,\n  name: (a, b) => a.name.localeCompare(b.name),\n};\n\nexport interface ProductGridProps {\n  products?: readonly Product[];\n  title?: string;\n}\n\n/**\n * A product grid with filters: categories and finishes as checkboxes, a sort, and a count that is announced\n * as it changes. Filtering is a `useMemo` over the list it is given — hand it the page your API returned.\n */\nexport default function ProductGrid({ products = catalogue, title = 'Shop the collection' }: ProductGridProps) {\n  const [chosen, setChosen] = useState<string[]>([]);\n  const [finish, setFinish] = useState<string[]>([]);\n  const [sort, setSort] = useState<Sort>('featured');\n  const headingId = useId();\n\n  const shown = useMemo(\n    () =>\n      [...products]\n        .filter((product) => !chosen.length || chosen.includes(product.category))\n        .filter((product) => !finish.length || product.finishes.some((id) => finish.includes(id)))\n        .sort(order[sort]),\n    [products, chosen, finish, sort],\n  );\n\n  const toggle = (list: string[], value: string, on: boolean) => (on ? [...list, value] : list.filter((entry) => entry !== value));\n\n  return (\n    <Section props={{ 'aria-labelledby': headingId }}>\n      <Flex jc=\"space-between\" ai=\"end\" gap={4} flexWrap=\"wrap\" mb={6}>\n        <Box>\n          <H2 id={headingId} fontSize={24} {...heading}>\n            {title}\n          </H2>\n          <P mt={1} fontSize={14} color=\"stone-600\" theme={{ dark: { color: 'stone-400' } }} props={{ 'aria-live': 'polite' }}>\n            {shown.length} {shown.length === 1 ? 'object' : 'objects'}\n          </P>\n        </Box>\n        <Dropdown<Sort>\n          label=\"Sort by\"\n          value={sort}\n          onValueChange={(value) => setSort((value as Sort) ?? 'featured')}\n          labelProps={{ minWidth: 52 }}\n        >\n          {sorts.map((option) => (\n            <Dropdown.Item key={option.value} value={option.value}>\n              {option.label}\n            </Dropdown.Item>\n          ))}\n        </Dropdown>\n      </Flex>\n\n      <Grid gridTemplateColumns={1} md={{ css: { gridTemplateColumns: '14rem minmax(0, 1fr)' } }} gap={8}>\n        <Flex d=\"column\" gap={6} fontSize={14}>\n          <Filter legend=\"Category\">\n            {categories.map((category) => (\n              <Checkbox\n                key={category}\n                label={category}\n                checked={chosen.includes(category)}\n                onChange={(event) => setChosen(toggle(chosen, category, event.target.checked))}\n              />\n            ))}\n          </Filter>\n          <Filter legend=\"Finish\">\n            {Object.values(finishes).map((entry) => (\n              <Checkbox\n                key={entry.id}\n                label={entry.name}\n                checked={finish.includes(entry.id)}\n                onChange={(event) => setFinish(toggle(finish, entry.id, event.target.checked))}\n              />\n            ))}\n          </Filter>\n          {(chosen.length > 0 || finish.length > 0) && (\n            <Button\n              variant=\"secondary\"\n              alignSelf=\"start\"\n              py={1.5}\n              px={4}\n              fontSize={13}\n              onClick={() => {\n                setChosen([]);\n                setFinish([]);\n              }}\n            >\n              Clear filters\n            </Button>\n          )}\n        </Flex>\n\n        {shown.length ? (\n          <Grid gridTemplateColumns={2} lg={{ gridTemplateColumns: 3 }} columnGap={5} rowGap={8}>\n            {shown.map((product) => (\n              <ProductCard key={product.slug} product={product} />\n            ))}\n          </Grid>\n        ) : (\n          <Flex\n            d=\"column\"\n            ai=\"center\"\n            jc=\"center\"\n            gap={2}\n            p={10}\n            borderRadius={4}\n            bgColor=\"stone-100\"\n            theme={{ dark: { bgColor: 'stone-900' } }}\n          >\n            <Span fontWeight={500}>Nothing matches those filters.</Span>\n            <Span fontSize={14} color=\"stone-600\" theme={{ dark: { color: 'stone-400' } }}>\n              Try another finish, or clear them all.\n            </Span>\n          </Flex>\n        )}\n      </Grid>\n    </Section>\n  );\n}\n\n/** A group of checkboxes under one caption: a real `<fieldset>`, so the caption is read with each box. */\nfunction Filter({ legend, children }: { legend: string; children: ReactNode }) {\n  return (\n    <Box tag=\"fieldset\" b={0} p={0} m={0}>\n      <Box\n        tag=\"legend\"\n        mb={3}\n        fontSize={12}\n        fontWeight={500}\n        letterSpacing={1}\n        textTransform=\"uppercase\"\n        color=\"stone-600\"\n        theme={{ dark: { color: 'stone-400' } }}\n      >\n        {legend}\n      </Box>\n      <Flex d=\"column\" gap={2.5}>\n        {children}\n      </Flex>\n    </Box>\n  );\n}\n",
      "type": "registry:block",
      "target": "@components/box-kite/store/product-grid.tsx"
    },
    {
      "path": "registry/designs/store/product-card.tsx",
      "content": "'use client';\nimport Box from '@box-kite/react';\nimport Flex from '@box-kite/react/components/flex';\nimport { Article, H2, H3, Link, Span } from '@box-kite/react/components/semantics';\nimport VisuallyHidden from '@box-kite/react/components/visuallyHidden';\nimport ProductArt, { finishSwatch } from './product-art';\nimport { formatPrice, finishes, type Product } from './store-data';\nimport { eyebrow, useStoreLinks } from './store-design';\n\nexport interface ProductCardProps {\n  product: Product;\n  /** The heading level the card's name takes in the page around it. */\n  level?: 'h2' | 'h3';\n}\n\n/**\n * One product in a grid. The whole card is the link, and its name is the link's text: the picture is\n * decoration beside it, and the finish swatches are spelled out for a screen reader in one sentence.\n */\nexport default function ProductCard({ product, level = 'h3' }: ProductCardProps) {\n  const Heading = level === 'h2' ? H2 : H3;\n  const links = useStoreLinks();\n  const [first] = product.finishes;\n\n  return (\n    <Article position=\"relative\" className=\"card\">\n      <Box position=\"relative\" aspectRatio=\"square\" transition=\"opacity\" group={{ 'card/hover': { opacity: 0.8 } }}>\n        <ProductArt shape={product.shape} finish={first} radius={4} />\n        {product.badge && (\n          <Span\n            position=\"absolute\"\n            top={4}\n            insetStart={4}\n            px={2.5}\n            py={1}\n            borderRadius={999}\n            {...eyebrow}\n            fontSize={11}\n            bgColor=\"white\"\n            color=\"stone-900\"\n          >\n            {product.badge}\n          </Span>\n        )}\n      </Box>\n      <Flex jc=\"space-between\" ai=\"baseline\" gap={3} mt={4}>\n        <Heading fontSize={15} fontWeight={500}>\n          <Link\n            props={{ href: links.product(product.slug) }}\n            color=\"currentColor\"\n            textDecoration=\"none\"\n            // The link's box stretched over the card, so the whole card is one target and still one tab stop.\n            after={{ position: 'absolute', inset: 0, borderRadius: 4 }}\n            focusVisible={{ outline: 0, after: { outline: 2, outlineColor: 'stone-900', outlineOffset: 4 } }}\n            theme={{ dark: { focusVisible: { after: { outlineColor: 'stone-100' } } } }}\n          >\n            {product.name}\n          </Link>\n        </Heading>\n        <Span fontSize={15}>{formatPrice(product.price)}</Span>\n      </Flex>\n      <Flex ai=\"center\" gap={1.5} mt={2.5}>\n        {product.finishes.map((id) => (\n          <Box\n            key={id}\n            width={2.5}\n            height={2.5}\n            borderRadius={999}\n            ring={1}\n            ringColor=\"stone-900/15\"\n            theme={{ dark: { ringColor: 'white/20' } }}\n            {...finishSwatch(id)}\n          />\n        ))}\n        <VisuallyHidden>Available in {product.finishes.map((id) => finishes[id].name).join(', ')}.</VisuallyHidden>\n      </Flex>\n    </Article>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/box-kite/store/product-card.tsx"
    }
  ],
  "docs": "Render <ProductGrid products={…} />. Wrap it in <StoreSurface> from store-design.tsx, or render it inside one of the store pages."
}
