{"version":1,"id":"pinned-item-list","framework":"next","kind":"block","files":[{"path":"src/blocks/gfa/pinned-item-list.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Pin, PinOff } from \"lucide-react\";\nimport { AnimatePresence, LazyMotion, LayoutGroup, m } from \"motion/react\";\nimport { Button } from \"@/components/gfa/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PinnedListItem = {\n  id: string;\n  title: string;\n  description?: string;\n};\n\nfunction toSet(ids: readonly string[] | ReadonlySet<string> | undefined): Set<string> {\n  if (!ids) return new Set();\n  return ids instanceof Set ? new Set(ids) : new Set(ids);\n}\n\nfunction mergeStackFromTarget(prev: string[], target: Set<string>): string[] {\n  const kept = prev.filter((id) => target.has(id));\n  const added = [...target].filter((id) => !prev.includes(id));\n  return [...added, ...kept];\n}\n\nconst layoutSpring = { type: \"spring\" as const, stiffness: 180, damping: 26, mass: 0.95 };\n\nconst iconEase = [0.22, 1, 0.36, 1] as const;\nconst iconTransition = { duration: 0.42, ease: iconEase };\n\nfunction loadPinnedListMotionFeatures() {\n  return import(\"motion/react\").then((mod) => mod.domMax);\n}\n\nexport type PinnedItemListProps = {\n  items: readonly PinnedListItem[];\n  defaultPinnedIds?: readonly string[];\n  pinnedIds?: ReadonlySet<string> | readonly string[];\n  onPinnedChange?: (pinned: Set<string>) => void;\n  className?: string;\n  emptyLabel?: string;\n  pinAriaLabel?: (pinned: boolean) => string;\n  pinnedSectionTitle?: string;\n  allSectionTitle?: string;\n  pinnedEmptyLabel?: string;\n  allEmptyLabel?: string;\n};\n\nexport function PinnedItemList({\n  items,\n  defaultPinnedIds,\n  pinnedIds: controlledPinned,\n  onPinnedChange,\n  className,\n  emptyLabel = \"No items yet.\",\n  pinAriaLabel = (p) => (p ? \"Unpin\" : \"Pin to top\"),\n  pinnedSectionTitle = \"PINNED ITEMS\",\n  allSectionTitle = \"ALL ITEMS\",\n  pinnedEmptyLabel = \"No pinned items\",\n  allEmptyLabel = \"Everything is pinned\",\n}: PinnedItemListProps) {\n  const isControlled = controlledPinned !== undefined;\n\n  const [pinStack, setPinStack] = React.useState<string[]>(() => {\n    if (isControlled) {\n      return mergeStackFromTarget([], toSet([...controlledPinned!]));\n    }\n    return [...(defaultPinnedIds ?? [])].reverse();\n  });\n\n  React.useEffect(() => {\n    if (!isControlled) return;\n    const target = toSet([...controlledPinned!]);\n    setPinStack((prev) => mergeStackFromTarget(prev, target));\n  }, [controlledPinned, isControlled]);\n\n  const pinned = React.useMemo(() => new Set(pinStack), [pinStack]);\n\n  const itemById = React.useMemo(() => new Map(items.map((i) => [i.id, i] as const)), [items]);\n\n  const pinnedItems = React.useMemo(\n    () => pinStack.map((id) => itemById.get(id)).filter((x): x is PinnedListItem => x != null),\n    [pinStack, itemById],\n  );\n\n  const allItems = React.useMemo(\n    () => items.filter((i) => !pinned.has(i.id)),\n    [items, pinned],\n  );\n\n  const applyStack = React.useCallback(\n    (next: string[]) => {\n      setPinStack(next);\n      onPinnedChange?.(new Set(next));\n    },\n    [onPinnedChange],\n  );\n\n  const toggle = React.useCallback(\n    (id: string) => {\n      if (pinned.has(id)) {\n        applyStack(pinStack.filter((x) => x !== id));\n      } else {\n        applyStack([id, ...pinStack.filter((x) => x !== id)]);\n      }\n    },\n    [pinned, pinStack, applyStack],\n  );\n\n  const renderRow = (item: PinnedListItem, isPinnedColumn: boolean) => {\n    const isPinned = isPinnedColumn;\n    return (\n      <m.li\n        key={item.id}\n        role=\"listitem\"\n        layout\n        layoutId={item.id}\n        transition={layoutSpring}\n        className=\"list-none\"\n        style={{ willChange: \"transform\" }}\n      >\n        <m.div\n          layout\n          transition={layoutSpring}\n          className={cn(\n            \"flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors duration-200 ease-out\",\n            isPinned\n              ? \"bg-primary text-primary-foreground hover:bg-primary/90\"\n              : \"bg-muted/12 hover:bg-muted/72 dark:bg-muted/8 dark:hover:bg-white/14\",\n          )}\n        >\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-xs\"\n            className={cn(\n              \"size-7 shrink-0 text-muted-foreground dark:text-secondary-foreground dark:hover:text-secondary-foreground\",\n              isPinned &&\n                \"text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground dark:hover:bg-primary-foreground/15\",\n            )}\n            aria-pressed={isPinned}\n            aria-label={pinAriaLabel(isPinned)}\n            onClick={() => toggle(item.id)}\n          >\n            <span className=\"relative flex size-3.5 items-center justify-center overflow-visible\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <m.span\n                  key={isPinned ? \"pinned\" : \"unpinned\"}\n                  initial={{ opacity: 0, scale: 0.88, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, scale: 0.88, filter: \"blur(2px)\" }}\n                  transition={iconTransition}\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center\",\n                    isPinned\n                      ? \"text-primary-foreground\"\n                      : \"text-muted-foreground dark:text-secondary-foreground\",\n                  )}\n                >\n                  {isPinned ? (\n                    <Pin className=\"size-3.5 fill-current text-inherit\" aria-hidden />\n                  ) : (\n                    <PinOff className=\"size-3.5 opacity-80\" aria-hidden />\n                  )}\n                </m.span>\n              </AnimatePresence>\n            </span>\n          </Button>\n          <div\n            className={cn(\n              \"min-w-0 flex-1 leading-tight\",\n              isPinned && \"text-primary-foreground\",\n            )}\n          >\n            <div\n              className={cn(\n                \"truncate text-[13px] font-medium\",\n                isPinned ? \"text-inherit\" : \"text-foreground\",\n              )}\n            >\n              {item.title}\n            </div>\n            {item.description ? (\n              <p\n                className={cn(\n                  \"truncate text-[11px]\",\n                  isPinned ? \"text-inherit opacity-80\" : \"text-muted-foreground\",\n                )}\n              >\n                {item.description}\n              </p>\n            ) : null}\n          </div>\n        </m.div>\n      </m.li>\n    );\n  };\n\n  if (items.length === 0) {\n    return <p className=\"text-xs text-muted-foreground\">{emptyLabel}</p>;\n  }\n\n  return (\n    <LazyMotion features={loadPinnedListMotionFeatures} strict>\n      <LayoutGroup id=\"pinned-item-list-block\">\n        <div data-slot=\"pinned-item-list-block\" className={cn(\"flex flex-col gap-7\", className)}>\n          <section aria-labelledby=\"pinned-item-list-block-pinned-heading\">\n            <h2\n              id=\"pinned-item-list-block-pinned-heading\"\n              className=\"mb-2 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase\"\n            >\n              {pinnedSectionTitle}\n            </h2>\n            <ul className=\"flex min-h-12 flex-col gap-1.5\">\n              {pinnedItems.length === 0 ? (\n                <li className=\"list-none\">\n                  <div className=\"flex min-h-11 items-center justify-center rounded-lg bg-muted/10 px-2 py-3 text-center text-[11px] text-muted-foreground transition-colors hover:bg-muted/30\">\n                    {pinnedEmptyLabel}\n                  </div>\n                </li>\n              ) : (\n                pinnedItems.map((item) => renderRow(item, true))\n              )}\n            </ul>\n          </section>\n\n          <section aria-labelledby=\"pinned-item-list-block-all-heading\">\n            <h2\n              id=\"pinned-item-list-block-all-heading\"\n              className=\"mb-2 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase\"\n            >\n              {allSectionTitle}\n            </h2>\n            <ul className=\"flex flex-col gap-1.5\">\n              {allItems.length === 0 ? (\n                <li className=\"list-none\">\n                  <div className=\"flex min-h-11 items-center justify-center rounded-lg bg-muted/10 px-2 py-3 text-center text-[11px] text-muted-foreground transition-colors hover:bg-muted/30\">\n                    {allEmptyLabel}\n                  </div>\n                </li>\n              ) : (\n                allItems.map((item) => renderRow(item, false))\n              )}\n            </ul>\n          </section>\n        </div>\n      </LayoutGroup>\n    </LazyMotion>\n  );\n}\n\nexport { PinnedItemList as PinnedItemListBlock };\nexport type { PinnedItemListProps as PinnedItemListBlockProps };\n"}],"meta":{"studioName":"GFA Development & Design Studio","siteUrl":"https://ui.gkhn.dev"}}