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