{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slug-input",
  "type": "registry:ui",
  "title": "Slug Input",
  "description": "A URL slug field that follows the title until edited, previews the full URL and checks availability as you type.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/slug-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** Letters that NFKD does not decompose into a base letter + mark. */\nconst SPECIAL: Record<string, string> = { ß: \"ss\", æ: \"ae\", œ: \"oe\", ø: \"o\", đ: \"d\", ð: \"d\", ł: \"l\", þ: \"th\", ı: \"i\" };\n\n/**\n * Turns any title into a URL slug: NFKD + combining-mark stripping (\"Crème\" → \"creme\"), lowercase,\n * \"&\" → \"and\", every other run of non a–z / 0–9 characters → one hyphen. Long slugs are cut at\n * `maxLength`, backing up to the last hyphen so words are not split.\n */\nexport function slugify(text: string, maxLength = 60): string {\n  const base = text\n    .normalize(\"NFKD\")\n    .replace(/\\p{M}+/gu, \"\")\n    .toLowerCase()\n    .replace(/[ßæœøđðłþı]/g, (ch) => SPECIAL[ch] ?? ch)\n    .replace(/&/g, \" and \")\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n  if (base.length <= maxLength) return base;\n  const cut = base.slice(0, maxLength);\n  const lastHyphen = cut.lastIndexOf(\"-\");\n  return (lastHyphen > maxLength * 0.6 ? cut.slice(0, lastHyphen) : cut).replace(/-+$/, \"\");\n}\n\nexport type SlugAvailability = boolean | { available: boolean; suggestion?: string };\n\nexport type SlugStatus = \"idle\" | \"checking\" | \"available\" | \"taken\" | \"error\";\n\nexport interface SlugInputProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Text the slug is generated from (usually the title field) until the user edits the slug. */\n  source?: string;\n  /** Start with a manual slug instead of auto-generating one. */\n  defaultValue?: string;\n  /** Called whenever the effective slug changes; `auto` is true while it follows `source`. */\n  onChange?: (slug: string, auto: boolean) => void;\n  /**\n   * Async availability check. Receives an AbortSignal that fires when the slug changes again.\n   * Resolve `true`/`false`, or `{ available, suggestion }` to offer an alternative.\n   */\n  checkAvailability?: (slug: string, signal: AbortSignal) => Promise<SlugAvailability>;\n  /** Called with the latest availability status. */\n  onStatusChange?: (status: SlugStatus) => void;\n  /** URL the slug is appended to, e.g. \"https://example.com/blog/\". */\n  prefix?: string;\n  /** Maximum slug length (auto slugs are trimmed at a word boundary). */\n  maxLength?: number;\n  /** Wait this long after typing stops before checking availability (ms). */\n  debounce?: number;\n  /** Show the full URL preview line. */\n  showPreview?: boolean;\n  /** Show the copy-URL button. */\n  showCopy?: boolean;\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  /** Focus ring, slug highlight and status colour. */\n  accent?: string;\n  /** Name of the submitted input. */\n  name?: string;\n  disabled?: boolean;\n  /** id for the text input (generated when omitted). */\n  inputId?: string;\n}\n\ntype CheckResult = { slug: string; status: \"available\" | \"taken\" | \"error\"; suggestion?: string };\n\n/** Returns an error message for a manually entered slug, or \"\" when it is valid. */\nfunction validate(slug: string, maxLength: number): string {\n  if (!slug) return \"Enter a slug.\";\n  const bad = Array.from(new Set(slug.replace(/[a-z0-9-]/g, \"\")));\n  if (bad.length) {\n    const list = bad.slice(0, 3).map((c) => `“${c}”`).join(\", \");\n    return `Remove ${list}. Use lowercase letters, numbers and hyphens only.`;\n  }\n  if (/^-|-$|--/.test(slug)) return \"Hyphens can't start, end or repeat.\";\n  if (slug.length > maxLength) return `Keep it under ${maxLength + 1} characters.`;\n  return \"\";\n}\n\n/**\n * A URL slug field that follows a source title until it is edited by hand, previews the full URL,\n * validates allowed characters and runs a debounced, abortable availability check.\n */\nexport function SlugInput({\n  source = \"\",\n  defaultValue,\n  onChange,\n  checkAvailability,\n  onStatusChange,\n  prefix = \"https://wancraft.my/blog/\",\n  maxLength = 60,\n  debounce = 400,\n  showPreview = true,\n  showCopy = true,\n  label = \"Slug\",\n  hint,\n  accent = \"#FF7448\",\n  name = \"slug\",\n  disabled = false,\n  inputId: inputIdProp,\n  className = \"\",\n  style,\n  ...props\n}: SlugInputProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const inputId = inputIdProp ?? `slug-${uid}`;\n  const hintId = `${inputId}-hint`;\n  const errorId = `${inputId}-error`;\n  const statusId = `${inputId}-status`;\n  const countId = `${inputId}-count`;\n\n  const [manual, setManual] = React.useState<string | null>(defaultValue ?? null);\n  const auto = manual === null;\n  const slug = auto ? slugify(source, maxLength) : manual;\n  const error = auto ? \"\" : validate(slug, maxLength);\n  const valid = Boolean(slug) && !error;\n\n  const [result, setResult] = React.useState<CheckResult | null>(null);\n  const [copied, setCopied] = React.useState(false);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  // Keep the latest callbacks in refs so inline functions don't restart the debounce.\n  const checkRef = React.useRef(checkAvailability);\n  const changeRef = React.useRef(onChange);\n  const statusRef = React.useRef(onStatusChange);\n  React.useEffect(() => {\n    checkRef.current = checkAvailability;\n    changeRef.current = onChange;\n    statusRef.current = onStatusChange;\n  });\n\n  const hasChecker = Boolean(checkAvailability);\n  // \"checking\" is derived: any valid slug without a result for exactly that slug is pending.\n  const status: SlugStatus = !hasChecker || !valid ? \"idle\" : result?.slug === slug ? result.status : \"checking\";\n\n  // Notify the parent of the effective slug (auto slugs change with `source`).\n  React.useEffect(() => {\n    changeRef.current?.(slug, auto);\n  }, [slug, auto]);\n\n  React.useEffect(() => {\n    statusRef.current?.(status);\n  }, [status]);\n\n  // Debounced, abortable availability check; a newer slug aborts the older request.\n  React.useEffect(() => {\n    if (!hasChecker || !valid) return;\n    const controller = new AbortController();\n    const timer = setTimeout(async () => {\n      try {\n        const answer = await checkRef.current!(slug, controller.signal);\n        if (controller.signal.aborted) return;\n        const available = typeof answer === \"boolean\" ? answer : answer.available;\n        const suggestion = typeof answer === \"boolean\" ? undefined : answer.suggestion;\n        setResult({ slug, status: available ? \"available\" : \"taken\", suggestion });\n        setAnnouncement(\n          available ? `${slug} is available.` : `${slug} is taken.${suggestion ? ` Suggestion: ${suggestion}.` : \"\"}`,\n        );\n      } catch {\n        if (controller.signal.aborted) return;\n        setResult({ slug, status: \"error\" });\n        setAnnouncement(\"Couldn't check availability.\");\n      }\n    }, debounce);\n    return () => {\n      clearTimeout(timer);\n      controller.abort();\n    };\n  }, [slug, valid, hasChecker, debounce]);\n\n  React.useEffect(() => () => clearTimeout(copyTimer.current), []);\n\n  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    // Gentle normalisation while typing: lowercase, spaces/underscores become hyphens.\n    // Other characters are kept so the error can point at them instead of silently vanishing.\n    setManual(event.target.value.toLowerCase().replace(/[\\s_]+/g, \"-\"));\n  };\n\n  const resetToAuto = () => {\n    setManual(null);\n    setAnnouncement(`Slug follows the title again: ${slugify(source, maxLength) || \"empty\"}.`);\n    inputRef.current?.focus();\n  };\n\n  const applySuggestion = (suggestion: string) => {\n    setManual(suggestion);\n    inputRef.current?.focus();\n  };\n\n  const displayPrefix = prefix.replace(/^[a-z]+:\\/\\//i, \"\");\n  const fullUrl = `${prefix}${slug}`;\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(fullUrl);\n      setCopied(true);\n      setAnnouncement(\"URL copied to clipboard.\");\n      clearTimeout(copyTimer.current);\n      copyTimer.current = setTimeout(() => setCopied(false), 1600);\n    } catch {\n      setAnnouncement(\"Copy failed. Select the URL and copy it manually.\");\n    }\n  };\n\n  const describedBy = [hint && !error ? hintId : null, error ? errorId : null, countId, hasChecker ? statusId : null, props[\"aria-describedby\"]]\n    .filter(Boolean)\n    .join(\" \");\n  const fade = { duration: reduceMotion ? 0 : 0.18, ease: [0.2, 0.7, 0.2, 1] as const };\n\n  return (\n    <div\n      className={`w-full ${className}`}\n      style={{ [\"--sl-accent\" as string]: accent, ...style }}\n      {...props}\n      aria-describedby={undefined}\n    >\n      <div className=\"mb-2 flex min-h-5 items-center justify-between gap-3\">\n        <div className=\"flex items-center gap-2\">\n          <label htmlFor={inputId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            {label}\n          </label>\n          {auto && (\n            <span className=\"rounded border border-zinc-900/10 px-1 py-px font-mono text-[9px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/10 dark:text-zinc-500\">\n              Auto\n            </span>\n          )}\n        </div>\n        {!auto && (\n          <button\n            type=\"button\"\n            onClick={resetToAuto}\n            disabled={disabled}\n            className=\"inline-flex items-center gap-1 rounded text-[12px] font-medium text-zinc-600 underline decoration-zinc-900/20 underline-offset-[3px] outline-hidden transition-colors hover:text-zinc-950 hover:decoration-[var(--sl-accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sl-accent)] dark:text-zinc-400 dark:decoration-white/20 dark:hover:text-white\"\n          >\n            <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M2.5 5.5a3.5 3.5 0 1 1 1 3\" />\n              <path d=\"M2.5 2.5v3h3\" />\n            </svg>\n            Reset to auto\n          </button>\n        )}\n      </div>\n\n      <div\n        className={`flex h-11 w-full items-stretch overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${\n          error\n            ? \"border-rose-500/80 dark:border-rose-400/80\"\n            : \"border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--sl-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--sl-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30\"\n        } ${disabled ? \"opacity-50\" : \"\"}`}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"flex max-w-[45%] shrink-0 items-center truncate border-r border-zinc-900/10 bg-zinc-900/[0.03] pl-3 pr-2.5 font-mono text-[12px] text-zinc-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-zinc-400\"\n          title={prefix}\n        >\n          <span className=\"truncate\">{displayPrefix}</span>\n        </span>\n        <input\n          ref={inputRef}\n          id={inputId}\n          name={name}\n          type=\"text\"\n          inputMode=\"url\"\n          autoComplete=\"off\"\n          autoCapitalize=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          disabled={disabled}\n          value={slug}\n          maxLength={maxLength}\n          aria-invalid={Boolean(error) || status === \"taken\" || undefined}\n          aria-describedby={describedBy}\n          onChange={handleChange}\n          placeholder=\"your-post-title\"\n          className=\"min-w-0 flex-1 bg-transparent px-2.5 font-mono text-[13px] text-zinc-950 caret-[var(--sl-accent)] outline-hidden placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-600\"\n        />\n        <span aria-hidden=\"true\" className=\"grid w-10 shrink-0 place-items-center\">\n          <StatusGlyph status={status} reduceMotion={reduceMotion} />\n        </span>\n      </div>\n\n      <div className=\"mt-1.5 flex items-start justify-between gap-3\">\n        <div className=\"min-w-0 flex-1 text-[12px] leading-snug\">\n          {/* Only the error is live; the hint is static help text. */}\n          <div aria-live=\"polite\">\n            <AnimatePresence initial={false}>\n              {error && (\n                <motion.p\n                  key=\"error\"\n                  id={errorId}\n                  initial={{ opacity: 0, y: reduceMotion ? 0 : -3 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, transition: { duration: 0 } }}\n                  transition={fade}\n                  className=\"font-medium text-rose-600 dark:text-rose-400\"\n                >\n                  {error}\n                </motion.p>\n              )}\n            </AnimatePresence>\n          </div>\n          {!error && hint && (\n            <p id={hintId} className=\"text-zinc-500 dark:text-zinc-400\">\n              {hint}\n            </p>\n          )}\n        </div>\n        <span\n          id={countId}\n          className={`shrink-0 font-mono text-[10px] tabular-nums tracking-[0.1em] ${\n            slug.length >= maxLength ? \"text-rose-600 dark:text-rose-400\" : \"text-zinc-400 dark:text-zinc-500\"\n          }`}\n        >\n          {slug.length}/{maxLength}\n          <span className=\"sr-only\"> characters</span>\n        </span>\n      </div>\n\n      {hasChecker && (\n        <p id={statusId} className=\"mt-2 flex min-h-5 flex-wrap items-center gap-x-2 gap-y-1 text-[12px] leading-snug\">\n          <StatusText status={status} suggestion={result?.slug === slug ? result.suggestion : undefined} onUse={applySuggestion} disabled={disabled} />\n        </p>\n      )}\n\n      {showPreview && (\n        <div className=\"mt-3 flex items-center gap-2 rounded-lg border border-dashed border-zinc-900/12 px-3 py-2 dark:border-white/12\">\n          <span className=\"sr-only\">Full URL: </span>\n          <p className=\"min-w-0 flex-1 truncate font-mono text-[12px] text-zinc-500 dark:text-zinc-400\" title={fullUrl}>\n            {prefix}\n            <span className=\"text-zinc-950 underline decoration-[var(--sl-accent)] decoration-[1.5px] underline-offset-[3px] dark:text-zinc-50\">\n              {slug || \"…\"}\n            </span>\n          </p>\n          {showCopy && (\n            <button\n              type=\"button\"\n              onClick={copy}\n              disabled={disabled || !valid}\n              className=\"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-zinc-900/12 px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 outline-hidden transition-colors hover:border-zinc-900/30 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sl-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/12 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white\"\n            >\n              {copied ? (\n                <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3 text-[var(--sl-accent)]\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <path d=\"M2.5 6.4l2.3 2.3 4.8-5.2\" />\n                </svg>\n              ) : (\n                <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.3\">\n                  <rect x=\"4\" y=\"4\" width=\"6\" height=\"6\" rx=\"1.2\" />\n                  <path d=\"M8 2.5V2.2A1.2 1.2 0 0 0 6.8 1H2.2A1.2 1.2 0 0 0 1 2.2v4.6A1.2 1.2 0 0 0 2.2 8h.3\" />\n                </svg>\n              )}\n              {copied ? \"Copied\" : \"Copy URL\"}\n            </button>\n          )}\n        </div>\n      )}\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction StatusGlyph({ status, reduceMotion }: { status: SlugStatus; reduceMotion: boolean }) {\n  if (status === \"checking\") {\n    return (\n      <svg viewBox=\"0 0 16 16\" className={`size-4 text-zinc-400 ${reduceMotion ? \"\" : \"animate-spin\"}`} fill=\"none\">\n        <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.6\" />\n        <path d=\"M14 8a6 6 0 0 0-6-6\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n      </svg>\n    );\n  }\n  if (status === \"available\") {\n    return (\n      <svg viewBox=\"0 0 16 16\" className=\"size-4 text-emerald-600 dark:text-emerald-400\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M3.5 8.4l3 3 6-6.6\" />\n      </svg>\n    );\n  }\n  if (status === \"taken\" || status === \"error\") {\n    return (\n      <svg viewBox=\"0 0 16 16\" className=\"size-4 text-rose-600 dark:text-rose-400\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n        <path d=\"M4.5 4.5l7 7M11.5 4.5l-7 7\" />\n      </svg>\n    );\n  }\n  return null;\n}\n\nfunction StatusText({\n  status,\n  suggestion,\n  onUse,\n  disabled,\n}: {\n  status: SlugStatus;\n  suggestion?: string;\n  onUse: (s: string) => void;\n  disabled: boolean;\n}) {\n  const dot = (cls: string) => <span aria-hidden=\"true\" className={`size-1.5 shrink-0 rounded-full ${cls}`} />;\n  switch (status) {\n    case \"checking\":\n      return (\n        <>\n          {dot(\"bg-zinc-400\")}\n          <span className=\"text-zinc-500 dark:text-zinc-400\">Checking availability…</span>\n        </>\n      );\n    case \"available\":\n      return (\n        <>\n          {dot(\"bg-emerald-500\")}\n          <span className=\"text-zinc-700 dark:text-zinc-300\">Available</span>\n        </>\n      );\n    case \"taken\":\n      return (\n        <>\n          {dot(\"bg-rose-500\")}\n          <span className=\"text-zinc-700 dark:text-zinc-300\">Already taken.</span>\n          {suggestion && (\n            <button\n              type=\"button\"\n              disabled={disabled}\n              onClick={() => onUse(suggestion)}\n              className=\"rounded font-medium text-zinc-950 underline decoration-[var(--sl-accent)] decoration-[1.5px] underline-offset-[3px] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sl-accent)] dark:text-white\"\n            >\n              Use <span className=\"font-mono text-[12px]\">{suggestion}</span>\n            </button>\n          )}\n        </>\n      );\n    case \"error\":\n      return (\n        <>\n          {dot(\"bg-amber-500\")}\n          <span className=\"text-zinc-500 dark:text-zinc-400\">Couldn&apos;t check availability. Keep typing to retry.</span>\n        </>\n      );\n    default:\n      return <span className=\"text-zinc-400 dark:text-zinc-500\">Availability is checked as you type.</span>;\n  }\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/slug-input"
}
