{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "GlideSelect-TS-TW",
	"title": "GlideSelect",
	"description": "Select chip whose menu pops from its corner and whose highlight glides between rows.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "GlideSelect/GlideSelect.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';\nimport { HugeiconsIcon } from '@hugeicons/react';\nimport { ArrowDown01Icon, Tick02Icon } from '@hugeicons/core-free-icons';\n\nexport interface GlideSelectOption {\n  value: string;\n  label: ReactNode;\n  tag?: string;\n}\n\nexport interface GlideSelectProps {\n  options?: (string | GlideSelectOption)[];\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string, option: GlideSelectOption) => void;\n  placeholder?: string;\n  showTags?: boolean;\n  accentColor?: string;\n  surfaceColor?: string;\n  highlightColor?: string;\n  textColor?: string;\n  size?: 'sm' | 'md' | 'lg';\n  radius?: number;\n  menuWidth?: number;\n  placement?: 'top' | 'bottom';\n  align?: 'left' | 'right';\n  popDuration?: number;\n  glideDuration?: number;\n  rememberPosition?: boolean;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n}\n\ntype Phase = 'closed' | 'open' | 'closing';\n\nconst SIZES: Record<string, { chip: number; row: number; font: number }> = {\n  sm: { chip: 28, row: 26, font: 12 },\n  md: { chip: 32, row: 30, font: 13 },\n  lg: { chip: 44, row: 40, font: 14 }\n};\nconst PAD = 4;\nconst GAP = 1;\nconst MENU_GAP = 6;\nconst DEFAULT_OPTIONS: (string | GlideSelectOption)[] = ['One', 'Two', 'Three'];\nconst STYLE = '@keyframes gs-swap{from{opacity:.6;filter:blur(2px)}}';\n\nconst norm = (o: string | GlideSelectOption): GlideSelectOption => (typeof o === 'string' ? { value: o, label: o } : o);\nconst textOf = (it: GlideSelectOption) => (typeof it.label === 'string' ? it.label : it.value);\nconst typeaheadIndex = (items: GlideSelectOption[], from: number, ch: string) => {\n  const c = ch.toLowerCase();\n  const n = items.length;\n  for (let k = 1; k <= n; k++) {\n    const i = (from + k) % n;\n    if (textOf(items[i]).toLowerCase().startsWith(c)) return i;\n  }\n  return from;\n};\n\nconst GlideSelect: React.FC<GlideSelectProps> = ({\n  options = DEFAULT_OPTIONS,\n  value,\n  defaultValue,\n  onChange,\n  placeholder = 'Select…',\n  showTags = true,\n  accentColor = '#f5f5f5',\n  surfaceColor = '#27272a',\n  highlightColor = '#3f3f46',\n  textColor = '#f5f5f5',\n  size = 'md',\n  radius = 10,\n  menuWidth = 176,\n  placement = 'bottom',\n  align = 'left',\n  popDuration = 180,\n  glideDuration = 220,\n  rememberPosition = true,\n  disabled = false,\n  ariaLabel = 'Select',\n  className = ''\n}) => {\n  const items = options.map(norm);\n  const [inner, setInner] = useState(defaultValue ?? '');\n  const current = value ?? inner;\n  const selected = items.findIndex(it => it.value === current);\n  const [phase, setPhase] = useState<Phase>('closed');\n  const [active, setActive] = useState<number | null>(null);\n  const [side, setSide] = useState<'top' | 'bottom'>(placement);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const pillRef = useRef<HTMLSpanElement>(null);\n  const instant = useRef(false);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const scrub = useRef<{ id: number; top: number } | null>(null);\n  const id = useId();\n  const S = SIZES[size] ?? SIZES.md;\n  const step = S.row + GAP;\n  const popOut = Math.round((popDuration * 2) / 3);\n\n  useLayoutEffect(() => {\n    if (phase !== 'open') return;\n    const el = menuRef.current;\n    const root = rootRef.current;\n    if (!el || !root) return;\n    const r = root.getBoundingClientRect();\n    const need = el.offsetHeight + MENU_GAP;\n    setSide(\n      placement === 'bottom' && r.bottom + need > window.innerHeight\n        ? 'top'\n        : placement === 'top' && r.top - need < 0\n          ? 'bottom'\n          : placement\n    );\n    el.style.transitionDuration = instant.current ? '0ms' : '';\n    el.dataset.state = 'closed';\n    void el.offsetHeight;\n    el.dataset.state = 'open';\n    const p = pillRef.current;\n    if (p) {\n      p.style.transition = 'none';\n      p.style.transform = `translateY(${Math.max(0, selected) * step}px)`;\n      p.style.opacity = '0';\n      void p.offsetHeight;\n      p.style.transition = '';\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [phase]);\n\n  useLayoutEffect(() => {\n    const p = pillRef.current;\n    if (!p || phase !== 'open') return;\n    if (active === null) {\n      p.style.opacity = '0';\n      return;\n    }\n    const jump = instant.current || p.style.opacity !== '1';\n    p.style.transitionDuration = jump ? '0ms, 150ms' : '';\n    p.style.transform = `translateY(${active * step}px)`;\n    p.style.opacity = '1';\n    instant.current = false;\n  }, [active, phase, step]);\n\n  const open = (viaKey: boolean) => {\n    if (disabled) return;\n    clearTimeout(closeTimer.current);\n    instant.current = true;\n    setActive(selected >= 0 ? selected : viaKey ? 0 : null);\n    setPhase('open');\n  };\n  const close = (mode: 'instant' | 'pop') => {\n    setActive(null);\n    clearTimeout(closeTimer.current);\n    const el = menuRef.current;\n    if (mode === 'instant' || !el) {\n      setPhase('closed');\n      return;\n    }\n    el.style.transitionDuration = '';\n    el.dataset.state = 'closed';\n    setPhase('closing');\n    closeTimer.current = setTimeout(() => setPhase('closed'), popOut + 20);\n  };\n  const pick = (i: number, viaKey: boolean) => {\n    const it = items[i];\n    if (!it) {\n      close('instant');\n      return;\n    }\n    if (it.value !== current) {\n      if (value === undefined) setInner(it.value);\n      onChange?.(it.value, it);\n      if (!viaKey && rootRef.current) rootRef.current.dataset.swap = '';\n    }\n    close('instant');\n    triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const onTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    const k = e.key;\n    const n = items.length;\n    const cur = active ?? Math.max(0, selected);\n    if (phase !== 'open') {\n      if (k === 'Enter' || k === ' ' || k === 'ArrowDown' || k === 'ArrowUp') {\n        e.preventDefault();\n        open(true);\n      }\n      return;\n    }\n    const go = (i: number) => {\n      e.preventDefault();\n      instant.current = true;\n      setActive(Math.min(n - 1, Math.max(0, i)));\n    };\n    if (k === 'ArrowDown' || k === 'ArrowUp') go(active === null ? cur : cur + (k === 'ArrowDown' ? 1 : -1));\n    else if (k === 'Home' || k === 'End') go(k === 'Home' ? 0 : n - 1);\n    else if (k === 'Enter' || k === ' ') {\n      e.preventDefault();\n      pick(cur, true);\n    } else if (k === 'Escape' || k === 'Tab') {\n      if (k === 'Escape') e.preventDefault();\n      close('instant');\n    } else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) go(typeaheadIndex(items, cur, k));\n  };\n\n  useEffect(() => {\n    if (phase === 'closed') return undefined;\n    const onDown = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) close('pop');\n    };\n    document.addEventListener('pointerdown', onDown, true);\n    return () => document.removeEventListener('pointerdown', onDown, true);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [phase]);\n  useEffect(() => {\n    if (disabled && phase !== 'closed') close('instant');\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [disabled]);\n  useEffect(() => () => clearTimeout(closeTimer.current), []);\n\n  const rowAt = (y: number) => {\n    const s = scrub.current;\n    if (!s) return null;\n    const i = Math.floor((y - s.top - PAD) / step);\n    return i >= 0 && i < items.length ? i : null;\n  };\n  const onListDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (scrub.current) return;\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    scrub.current = { id: e.pointerId, top: e.currentTarget.getBoundingClientRect().top };\n    instant.current = true;\n    setActive(rowAt(e.clientY));\n  };\n  const onListMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!scrub.current || scrub.current.id !== e.pointerId) return;\n    const i = rowAt(e.clientY);\n    if (i !== active) setActive(i);\n  };\n  const onListUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!scrub.current || scrub.current.id !== e.pointerId) return;\n    const i = e.type === 'pointerup' ? rowAt(e.clientY) : null;\n    scrub.current = null;\n    if (i !== null) pick(i, false);\n    else if (!rememberPosition) setActive(null);\n  };\n  const onListOver = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType === 'touch' || scrub.current) return;\n    const row = (e.target as HTMLElement).closest<HTMLElement>('[data-index]');\n    if (!row) return;\n    const i = Number(row.dataset.index);\n    if (i !== active) setActive(i);\n  };\n\n  const origin = `${side === 'bottom' ? 'top' : 'bottom'} ${align}`;\n  return (\n    <div\n      ref={rootRef}\n      className={`group relative inline-block data-[disabled]:opacity-50${className ? ` ${className}` : ''}`}\n      data-size={size}\n      data-disabled={disabled ? '' : undefined}\n      style={\n        {\n          '--gs-accent': accentColor,\n          '--gs-surface': surfaceColor,\n          '--gs-highlight': highlightColor,\n          '--gs-text': textColor,\n          '--gs-radius': `${radius}px`,\n          '--gs-inner-radius': `${Math.max(3, radius - 4)}px`,\n          '--gs-chip': `${S.chip}px`,\n          '--gs-row': `${S.row}px`,\n          '--gs-font': `${S.font}px`,\n          '--gs-menu-w': `${menuWidth}px`,\n          '--gs-pop': `${popDuration}ms`,\n          '--gs-pop-out': `${popOut}ms`,\n          '--gs-glide': `${glideDuration}ms`,\n          '--gs-origin': origin\n        } as CSSProperties\n      }\n      onAnimationEnd={e => {\n        if (e.animationName === 'gs-swap' && rootRef.current) delete rootRef.current.dataset.swap;\n      }}\n    >\n      <style>{STYLE}</style>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        role=\"combobox\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={phase === 'open'}\n        aria-controls={`${id}-list`}\n        aria-activedescendant={active !== null ? `${id}-${active}` : undefined}\n        aria-label={ariaLabel}\n        disabled={disabled}\n        className=\"group/trigger relative m-0 inline-flex cursor-pointer touch-manipulation items-center gap-1.5 border-0 pr-2 pl-2.5 leading-none font-medium outline-none select-none [-webkit-tap-highlight-color:transparent] [font-family:inherit] [height:var(--gs-chip)] [border-radius:var(--gs-inner-radius)] [background:var(--gs-surface)] [color:var(--gs-text)] [font-size:var(--gs-font)] [transition:background-color_100ms_ease,transform_160ms_cubic-bezier(0.23,1,0.32,1)] disabled:cursor-default enabled:active:scale-[0.97] motion-reduce:enabled:active:scale-100 motion-reduce:[transition:background-color_100ms_ease] aria-expanded:[background:color-mix(in_srgb,var(--gs-highlight)_60%,var(--gs-surface))] [@media(hover:hover)_and_(pointer:fine)]:enabled:hover:[background:color-mix(in_srgb,var(--gs-highlight)_60%,var(--gs-surface))]\"\n        onPointerDown={e => {\n          if (e.button !== 0 || disabled) return;\n          e.currentTarget.focus({ preventScroll: true });\n          if (phase === 'open') close('pop');\n          else open(false);\n        }}\n        onKeyDown={onTriggerKey}\n      >\n        <span\n          className=\"data-[empty]:opacity-60 group-data-[swap]:animate-[gs-swap_160ms_ease] motion-reduce:group-data-[swap]:animate-none\"\n          key={current}\n          data-empty={selected < 0 ? '' : undefined}\n        >\n          {selected >= 0 ? items[selected].label : placeholder}\n        </span>\n        <span\n          className=\"inline-flex [color:color-mix(in_srgb,var(--gs-text)_55%,transparent)] [transition:transform_200ms_cubic-bezier(0.23,1,0.32,1)] group-aria-expanded/trigger:rotate-180 motion-reduce:transition-none\"\n          aria-hidden=\"true\"\n        >\n          <HugeiconsIcon icon={ArrowDown01Icon} size={12} strokeWidth={2.5} />\n        </span>\n      </button>\n      {phase !== 'closed' ? (\n        <div\n          ref={menuRef}\n          className=\"absolute z-20 min-w-full scale-95 p-1 opacity-0 [width:var(--gs-menu-w)] [border-radius:var(--gs-radius)] [background:var(--gs-surface)] [box-shadow:0_4px_16px_color-mix(in_srgb,#000_22%,transparent)] [transform-origin:var(--gs-origin)] [transition:opacity_var(--gs-pop)_cubic-bezier(0.23,1,0.32,1),transform_var(--gs-pop)_cubic-bezier(0.23,1,0.32,1)] data-[state=open]:scale-100 data-[state=open]:opacity-100 data-[state=closed]:pointer-events-none data-[state=closed]:[transition-duration:var(--gs-pop-out)] data-[side=bottom]:top-[calc(100%+6px)] data-[side=top]:bottom-[calc(100%+6px)] data-[align=left]:left-0 data-[align=right]:right-0 motion-reduce:[transform:none]! motion-reduce:[transition:opacity_var(--gs-pop)_ease] motion-reduce:data-[state=closed]:[transition-duration:var(--gs-pop-out)]\"\n          data-state=\"open\"\n          data-side={side}\n          data-align={align}\n        >\n          <div\n            id={`${id}-list`}\n            role=\"listbox\"\n            aria-label={ariaLabel}\n            className=\"group/list relative grid touch-none gap-px\"\n            data-live={active !== null ? '' : undefined}\n            onPointerOver={onListOver}\n            onPointerLeave={() => {\n              if (!scrub.current && !rememberPosition) setActive(null);\n            }}\n            onPointerDown={onListDown}\n            onPointerMove={onListMove}\n            onPointerUp={onListUp}\n            onPointerCancel={onListUp}\n            onLostPointerCapture={onListUp}\n          >\n            <span\n              ref={pillRef}\n              className=\"pointer-events-none absolute top-0 right-0 left-0 opacity-0 [height:var(--gs-row)] [border-radius:var(--gs-inner-radius)] [background:var(--gs-highlight)] [transition:transform_var(--gs-glide)_cubic-bezier(0.23,1,0.32,1),opacity_150ms_ease] motion-reduce:[transition:opacity_150ms_ease]\"\n              aria-hidden=\"true\"\n            />\n            {items.map((it, i) => (\n              <div\n                key={it.value}\n                id={`${id}-${i}`}\n                role=\"option\"\n                aria-selected={i === selected}\n                data-index={i}\n                className=\"relative z-[1] flex cursor-pointer items-center gap-2 pr-2 pl-2.5 select-none [-webkit-tap-highlight-color:transparent] [height:var(--gs-row)] [border-radius:var(--gs-inner-radius)] [color:var(--gs-text)] [font-size:var(--gs-font)] [transition:background-color_150ms_ease] aria-selected:[background:color-mix(in_srgb,var(--gs-highlight)_60%,transparent)] group-data-[live]/list:aria-selected:bg-transparent\"\n              >\n                <span className=\"min-w-0 flex-1 truncate font-medium\">{it.label}</span>\n                {showTags && it.tag ? (\n                  <span className=\"shrink-0 [color:color-mix(in_srgb,var(--gs-text)_55%,transparent)] [font-size:calc(var(--gs-font)_-_2px)]\">\n                    {it.tag}\n                  </span>\n                ) : null}\n                <span\n                  className=\"inline-flex shrink-0 invisible [color:var(--gs-accent)] data-[on]:visible\"\n                  data-on={i === selected ? '' : undefined}\n                  aria-hidden=\"true\"\n                >\n                  <HugeiconsIcon icon={Tick02Icon} size={13} strokeWidth={2.5} />\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n      ) : null}\n    </div>\n  );\n};\n\nexport default GlideSelect;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@hugeicons/react@^1.1.10",
		"@hugeicons/core-free-icons@^4.3.3"
	]
}