{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slug-input",
  "title": "Slug Input",
  "description": "A URL slug field that fills itself in from a title and then gets out of the way. Use it wherever a record needs a URL: the permalink or slug field in a blog post editor or CMS admin form, a page or docs route segment, a product handle, a workspace or team URL, a category or tag slug, a public profile handle. Type a title, watch the slug appear as kebab-case, and edit it whenever you want — this is the part hand-rolled fields get wrong. It keeps deriving only while the field still holds exactly what it generated, so editing the slug by hand, or loading an existing slug from your database, stops the derivation for good: renaming a published post cannot silently change its URL. Leave the field empty and blur, and it goes back to following the title. Every keystroke is sanitised in place — lowercased, spaces and punctuation collapsed to a single hyphen (or underscore), accents folded away — while the caret stays exactly where you were typing, which is what breaks when you naively assign a transformed value back to a controlled input. Unicode is handled rather than mangled: NFKD folding turns \"Café au lait\" into cafe-au-lait, \"Łódź\" into lodz, and the letters decomposition leaves whole are spelled out (\"Straße\" becomes strasse, not strae). Pass allowUnicode to keep the title's own script instead — without it a Japanese, Chinese, Korean, Greek, Cyrillic, Hebrew or Arabic title slugifies to an empty string, and with it combining marks stay attached to their letter, so がっこう does not quietly become かっこう. maxLength cuts a generated slug back to a whole word rather than mid-syllable, apostrophes disappear instead of splitting words (\"don't panic\" becomes dont-panic), and pasting a full URL takes just its last path segment. Zero dependencies, one import, shadcn tokens, optional prefix like example.com/blog/ wired to the input with aria-describedby. Official shadcn/ui has no slug or permalink field — its input is a bare element and input-group is an assembly kit with no logic in it — and a slugify npm package solves the string, not the field: the caret, the do-not-stomp rule and the typing-in-progress state are what this component is.",
  "files": [
    {
      "path": "registry/ui/slug-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Quotes disappear instead of becoming a separator, because they sit inside a\n * word: \"don't panic\" has to slug to `dont-panic`, not `don-t-panic`. Every\n * other character that is not kept turns into a separator, so `foo/bar` never\n * glues itself into `foobar`.\n */\nconst DROPPED = new Set([\n  \"'\",\n  \"‘\",\n  \"’\",\n  \"‚\",\n  \"‛\",\n  \"ʼ\",\n  '\"',\n  \"“\",\n  \"”\",\n  \"„\",\n  \"‟\",\n  \"`\",\n  \"´\",\n])\n\n/**\n * Letters Unicode decomposition leaves whole. NFKD only splits a letter that is\n * defined as \"base + mark\", so a letter with the stroke or the shape baked into\n * it survives normalisation and is then dropped as unknown — which is how\n * hand-rolled slugifiers turn \"straße\" into \"strae\" and \"Łódź\" into \"odz\".\n */\nconst TRANSLITERATE: Record<string, string> = {\n  ß: \"ss\",\n  æ: \"ae\",\n  œ: \"oe\",\n  ø: \"o\",\n  đ: \"d\",\n  ð: \"d\",\n  þ: \"th\",\n  ł: \"l\",\n  ħ: \"h\",\n  ŧ: \"t\",\n  ŋ: \"ng\",\n  ĸ: \"k\",\n  ı: \"i\",\n  ə: \"e\",\n  ƒ: \"f\",\n}\n\nconst MARK = /^\\p{M}$/u\nconst UNICODE_WORD = /^[\\p{L}\\p{N}\\p{M}]$/u\n\ninterface SlugOptions {\n  separator: string\n  allowUnicode: boolean\n  /** 0 or a non-finite value means \"no limit\". */\n  maxLength: number\n}\n\n/**\n * Only these two are accepted: the separator is compared character by character\n * while collapsing runs and trimming ends, so a multi-character or word-shaped\n * separator would round-trip into something that is not a slug at all.\n */\nfunction resolveSeparator(separator: string | undefined) {\n  return separator === \"_\" ? \"_\" : \"-\"\n}\n\n/**\n * Combining marks count as part of a word in Unicode mode on purpose: a\n * Devanagari vowel sign and a Japanese dakuten are marks, and dropping them\n * rewrites the word — が would become か, which is a different reading.\n */\nfunction isKept(ch: string, allowUnicode: boolean) {\n  if (ch >= \"a\" && ch <= \"z\") return true\n  if (ch >= \"0\" && ch <= \"9\") return true\n  return allowUnicode ? UNICODE_WORD.test(ch) : false\n}\n\nfunction limitOf(maxLength: number) {\n  return Number.isFinite(maxLength) && maxLength > 0 ? Math.floor(maxLength) : Infinity\n}\n\n/**\n * The whole transform, as one pure function of a string: lowercase, strip\n * accents, transliterate what will not decompose, collapse everything else into\n * single separators.\n *\n * It also returns `map`, the position each UTF-16 offset of the input landed on\n * in the output. That is what keeps the caret still: rewriting the value of a\n * controlled input on every keystroke otherwise throws the caret to the end, so\n * typing a word into the middle of an existing slug is impossible.\n */\nfunction transform(raw: string, opts: SlugOptions) {\n  const sep = opts.separator\n  const max = limitOf(opts.maxLength)\n  const map = new Array<number>(raw.length + 1).fill(0)\n  let out = \"\"\n  let full = false\n  let i = 0\n\n  while (i < raw.length) {\n    const cp = String.fromCodePoint(raw.codePointAt(i) as number)\n    map[i] = out.length\n    // An offset between the two halves of a surrogate pair is not a place the\n    // caret can be, but it still has to hold a number the next lookup can use.\n    if (cp.length === 2) map[i + 1] = out.length\n\n    // Lowercase first so the transliteration table only needs lowercase keys,\n    // then normalise. Folding to ASCII wants the decomposed form so an accent\n    // can be peeled off its letter; keeping the script wants the composed one,\n    // because 안 and が are single letters that NFKD would break into pieces.\n    // Both forms are compatibility ones, so ﬁ becomes fi and ３ becomes 3.\n    for (const ch of cp.toLowerCase().normalize(opts.allowUnicode ? \"NFKC\" : \"NFKD\")) {\n      if (full || DROPPED.has(ch)) continue\n      // A mark is dropped when it is folding away with its letter, and when\n      // there is no letter for it to sit on.\n      if (MARK.test(ch) && (!opts.allowUnicode || out.length === 0 || out[out.length - 1] === sep))\n        continue\n      // Transliteration belongs to the ASCII fold; in Unicode mode ß is simply\n      // a letter and stays one.\n      const mapped = opts.allowUnicode ? undefined : TRANSLITERATE[ch]\n      const piece = mapped !== undefined ? mapped : isKept(ch, opts.allowUnicode) ? ch : null\n      if (piece === null) {\n        // A separator run collapses to one. A leading separator is allowed to\n        // stand while typing — trimming it here would make it impossible to\n        // type a slug that starts with a word you have not written yet.\n        if (out.length === 0 || out[out.length - 1] !== sep) {\n          if (out.length < max) out += sep\n          else full = true\n        }\n      } else if (out.length + piece.length <= max) {\n        out += piece\n      } else {\n        // Stop at the cap instead of skipping only what does not fit: letting\n        // the next shorter piece through would drop a letter out of the middle\n        // of the word rather than cutting the end off.\n        full = true\n      }\n    }\n    i += cp.length\n  }\n\n  map[raw.length] = out.length\n  return { value: out, map }\n}\n\nfunction trimSeparators(value: string, sep: string) {\n  let start = 0\n  let end = value.length\n  while (start < end && value[start] === sep) start++\n  while (end > start && value[end - 1] === sep) end--\n  return value.slice(start, end)\n}\n\nfunction isHighSurrogate(code: number) {\n  return code >= 0xd800 && code <= 0xdbff\n}\n\n/**\n * Cut to `max`, then back off to the last separator so a generated slug ends on\n * a whole word rather than `…-introducti`. With no separator to back off to,\n * the hard cut is all there is — dropping the only word would leave nothing.\n */\nfunction truncateAtWord(value: string, sep: string, maxLength: number) {\n  const max = limitOf(maxLength)\n  if (value.length <= max) return value\n  let cut = value.slice(0, max)\n  // Half a surrogate pair is not a character; it renders as a replacement box.\n  if (isHighSurrogate(cut.charCodeAt(cut.length - 1))) cut = cut.slice(0, -1)\n  // Only back off when the cut landed inside a word. Falling exactly on a\n  // separator means the last whole word already fits, and dropping it would\n  // throw away a word the limit had room for.\n  if (value[cut.length] !== sep) {\n    const at = cut.lastIndexOf(sep)\n    if (at > 0) cut = cut.slice(0, at)\n  }\n  return trimSeparators(cut, sep)\n}\n\n/** The committed form: no limit while transforming, then trim, then truncate. */\nfunction slugify(source: string, opts: SlugOptions) {\n  const { value } = transform(source, { ...opts, maxLength: 0 })\n  return truncateAtWord(trimSeparators(value, opts.separator), opts.separator, opts.maxLength)\n}\n\n/**\n * The last path segment of a pasted URL, or null when the text is not a URL and\n * should be pasted as-is. Pasting the address of the page you are copying is a\n * normal way to reach for a slug, and `https-example-com-blog-my-post` is not\n * what anyone meant by it.\n */\nfunction urlTail(text: string) {\n  const trimmed = text.trim()\n  if (!/^[a-z][a-z0-9+.-]*:\\/\\//i.test(trimmed)) return null\n  const path = trimmed.replace(/^[a-z][a-z0-9+.-]*:\\/\\/[^/]*/i, \"\").split(/[?#]/)[0]\n  const segments = path.split(\"/\").filter(Boolean)\n  if (!segments.length) return null\n  const last = segments[segments.length - 1]\n  try {\n    return decodeURIComponent(last)\n  } catch {\n    // A stray % is a valid character in a pasted string but not a valid escape.\n    return last\n  }\n}\n\n/**\n * Whether auto-derivation may still write to the field. It may while the field\n * holds exactly what this component last put there, or nothing at all —\n * anything else belongs to the user or to the record being edited. Deriving it\n * from the value rather than tracking an \"is linked\" flag means a slug that\n * arrives late from a fetch stops the derivation just as a keystroke does.\n */\nfunction ownsValue(current: string, derived: string | null) {\n  return current === \"\" || current === derived\n}\n\ninterface SlugInputProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"input\">,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"prefix\" | \"type\"\n  > {\n  /**\n   * The title to derive the slug from. It keeps deriving until the field holds\n   * something this component did not write — an edit by the user, or a slug\n   * loaded from your database — and then never touches it again. That is the\n   * point of the component: renaming a published post must not silently change\n   * its URL.\n   */\n  source?: string\n  /** Controlled slug. */\n  value?: string\n  /** Initial slug for uncontrolled use. A non-empty one stops auto-derivation. */\n  defaultValue?: string\n  /** Fires with the sanitised slug on every keystroke and on blur. */\n  onValueChange?: (value: string) => void\n  /** Static text shown inside the field before the input, e.g. `example.com/blog/`. */\n  prefix?: React.ReactNode\n  /** Word separator, `-` (default) or `_`. */\n  separator?: \"-\" | \"_\"\n  /**\n   * Keep letters and digits from every script instead of only `a-z0-9`\n   * (default false). Without it a title written in Japanese, Chinese, Korean,\n   * Greek, Cyrillic, Hebrew or Arabic slugifies to an empty string.\n   */\n  allowUnicode?: boolean\n  /** Cap on the slug length. Derived slugs are cut back to a whole word. */\n  maxLength?: number\n}\n\nexport const SlugInput = React.forwardRef<HTMLInputElement, SlugInputProps>(\n  function SlugInput(\n    {\n      className,\n      source,\n      value,\n      defaultValue,\n      onValueChange,\n      prefix,\n      separator,\n      allowUnicode = false,\n      maxLength,\n      disabled,\n      onBlur,\n      onPaste,\n      onCompositionStart,\n      onCompositionEnd,\n      ...props\n    },\n    forwardedRef\n  ) {\n    const innerRef = React.useRef<HTMLInputElement>(null)\n    React.useImperativeHandle(forwardedRef, () => innerRef.current as HTMLInputElement)\n\n    const generatedId = React.useId()\n    const prefixId = `${generatedId}-prefix`\n    const hasPrefix = prefix !== undefined && prefix !== null && prefix !== \"\"\n\n    const sep = resolveSeparator(separator)\n    const opts = React.useMemo<SlugOptions>(\n      () => ({ separator: sep, allowUnicode, maxLength: maxLength ?? 0 }),\n      [sep, allowUnicode, maxLength]\n    )\n\n    const isControlled = value !== undefined\n    const [internal, setInternal] = React.useState(() =>\n      defaultValue ? slugify(defaultValue, opts) : \"\"\n    )\n    const current = isControlled ? (value ?? \"\") : internal\n\n    // The value is read inside effects and event handlers that must not re-run\n    // for every keystroke, so it travels through a ref rather than a dependency.\n    const currentRef = React.useRef(current)\n    currentRef.current = current\n\n    const commit = React.useCallback(\n      (next: string) => {\n        if (!isControlled) setInternal(next)\n        onValueChange?.(next)\n      },\n      [isControlled, onValueChange]\n    )\n\n    // What this component last wrote by itself. Auto-derivation continues only\n    // while the field still holds exactly that (or nothing) — no flag to keep in\n    // sync, and a slug arriving late from a fetch stops it just as an edit does.\n    const derivedRef = React.useRef<string | null>(null)\n    const lastSourceRef = React.useRef<string | null>(null)\n\n    React.useEffect(() => {\n      const src = source ?? \"\"\n      if (lastSourceRef.current === src) return\n      lastSourceRef.current = src\n      const cur = currentRef.current\n      if (!ownsValue(cur, derivedRef.current)) return\n      const next = slugify(src, opts)\n      if (next === cur) return\n      derivedRef.current = next\n      commit(next)\n    }, [source, opts, commit])\n\n    // Text mid-composition, shown raw. Sanitising each keystroke of an IME —\n    // Japanese, Korean, Vietnamese Telex, pinyin — rewrites the half-finished\n    // syllable the IME is still holding and the word comes out mangled, so the\n    // field shows exactly what the IME put there until composition ends.\n    const [composing, setComposing] = React.useState<string | null>(null)\n\n    // Where the caret has to go after the value the user typed is rewritten.\n    const caretRef = React.useRef<number | null>(null)\n\n    React.useLayoutEffect(() => {\n      const el = innerRef.current\n      const caret = caretRef.current\n      caretRef.current = null\n      if (!el || caret === null) return\n      if (el.value !== current) el.value = current\n      el.setSelectionRange(caret, caret)\n    })\n\n    function apply(raw: string, caretAt: number) {\n      const { value: next, map } = transform(raw, opts)\n      const caret = map[Math.min(Math.max(caretAt, 0), map.length - 1)]\n      if (next === current) {\n        // Nothing changed for React, so no render is coming and the DOM would\n        // keep the rejected character on screen. Put it back by hand.\n        const el = innerRef.current\n        if (el) {\n          if (el.value !== next) el.value = next\n          el.setSelectionRange(caret, caret)\n        }\n        return\n      }\n      caretRef.current = caret\n      commit(next)\n    }\n\n    function handleChange(e: React.ChangeEvent<HTMLInputElement>) {\n      const el = e.currentTarget\n      if (composing !== null) {\n        // Keep the controlled value equal to what the IME wrote, or React puts\n        // the old text back and the composition is lost.\n        setComposing(el.value)\n        return\n      }\n      apply(el.value, el.selectionStart ?? el.value.length)\n    }\n\n    function handleCompositionStart(e: React.CompositionEvent<HTMLInputElement>) {\n      setComposing(e.currentTarget.value)\n      onCompositionStart?.(e)\n    }\n\n    function handleCompositionEnd(e: React.CompositionEvent<HTMLInputElement>) {\n      const el = e.currentTarget\n      setComposing(null)\n      apply(el.value, el.selectionStart ?? el.value.length)\n      onCompositionEnd?.(e)\n    }\n\n    function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {\n      onPaste?.(e)\n      if (e.defaultPrevented) return\n      const tail = urlTail(e.clipboardData.getData(\"text\"))\n      if (tail === null) return\n      e.preventDefault()\n      const el = e.currentTarget\n      const start = el.selectionStart ?? el.value.length\n      const end = el.selectionEnd ?? start\n      apply(el.value.slice(0, start) + tail + el.value.slice(end), start + tail.length)\n    }\n\n    function handleBlur(e: React.FocusEvent<HTMLInputElement>) {\n      // Leaving the field mid-composition still has to commit what is on\n      // screen: not every engine fires compositionend before blur.\n      const shown = composing ?? current\n      if (composing !== null) setComposing(null)\n      // The same commit the derived path uses, so a hand-typed slug ends up\n      // under the same rules: separators trimmed off both ends, and a length\n      // limit that arrived after the typing did still applied.\n      const cleaned = slugify(shown, opts)\n      // An empty field means \"use the title\" — leaving it blank is how you ask\n      // for the derived slug back after editing it into a corner.\n      const next = cleaned === \"\" ? slugify(source ?? \"\", opts) : cleaned\n      if (cleaned === \"\") derivedRef.current = next\n      if (next !== current) commit(next)\n      onBlur?.(e)\n    }\n\n    const describedBy =\n      [props[\"aria-describedby\"], hasPrefix ? prefixId : null].filter(Boolean).join(\" \") ||\n      undefined\n\n    return (\n      <div\n        className={cn(\n          \"flex h-9 w-full items-center rounded-md border border-input bg-transparent text-sm shadow-sm transition-colors\",\n          \"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n      >\n        {hasPrefix ? (\n          <span\n            id={prefixId}\n            className=\"select-none whitespace-nowrap pl-3 text-muted-foreground\"\n          >\n            {prefix}\n          </span>\n        ) : null}\n        <input\n          {...props}\n          ref={innerRef}\n          type=\"text\"\n          value={composing ?? current}\n          onChange={handleChange}\n          onCompositionStart={handleCompositionStart}\n          onCompositionEnd={handleCompositionEnd}\n          onPaste={handlePaste}\n          onBlur={handleBlur}\n          disabled={disabled}\n          autoCapitalize=\"none\"\n          autoCorrect=\"off\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          aria-describedby={describedBy}\n          className={cn(\n            \"h-full w-full min-w-0 flex-1 rounded-md bg-transparent px-3 py-1 outline-none\",\n            \"placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n            hasPrefix && \"pl-1\"\n          )}\n        />\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}