{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"link","type":"registry:ui","title":"Link","description":"Inline text link with a muted variant, configurable underline, and a safe external-link mode with a new-tab arrow","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["actions"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/link/Link.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport type { AnchorHTMLAttributes, MouseEvent, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./link.css\";\n\nexport interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"className\"> {\n\t/** Destination URL */\n\thref: string;\n\t/** Visual weight: `default` reads as inline copy, `muted` recedes into supporting text */\n\tvariant?: \"default\" | \"muted\";\n\t/** Marks the destination as off-site: appends an arrow glyph and defaults `target`\n\t * to `_blank`, which in turn brings the safe `rel` and the \"opens in a new tab\"\n\t * announcement. Override `target` with `_self`, `_parent` or `_top` and both drop\n\t * away — the resolved target decides those, not this flag */\n\texternal?: boolean;\n\t/** When the underline shows: only on hover/focus (`hover`, the default), always, or never */\n\tunderline?: \"hover\" | \"always\" | \"none\";\n\t/** Anchor `target`. `external` fills this in as `_blank` when left unset */\n\ttarget?: string;\n\t/** Anchor `rel`. Merged with, never replaced by, the `noopener noreferrer` tokens\n\t * required whenever `target` opens a new browsing context — any value other than\n\t * `_self`, `_parent`, or `_top` (case-insensitively), not just `_blank` */\n\trel?: string;\n\t/** Link content */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off\n\t * by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n// Per the HTML standard, *any* target naming a browsing context that doesn't\n// already exist opens a new top-level one — identical to `_blank`,\n// `window.opener` intact — not just the literal string `\"_blank\"`. Only\n// these three keywords are guaranteed to stay in the current context, and\n// the standard matches them ASCII-case-insensitively.\nconst SAME_TAB_TARGETS = new Set([\"_self\", \"_parent\", \"_top\"]);\n\nexport const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(\n\t{\n\t\thref,\n\t\tvariant = \"default\",\n\t\texternal = false,\n\t\tunderline = \"hover\",\n\t\ttarget,\n\t\trel,\n\t\tchildren,\n\t\tclassName,\n\t\tsound = false,\n\t\tonClick,\n\t\t...rest\n\t},\n\tref\n) {\n\tconst playCue = useSoundCue(sound);\n\n\t// The single call site for the cue: never a second listener, and never\n\t// `preventDefault` — navigation is untouched, this only rides alongside it.\n\tfunction handleClick(event: MouseEvent<HTMLAnchorElement>) {\n\t\tplayCue(\"press\");\n\t\tonClick?.(event);\n\t}\n\n\t// `external` only *defaults* the tab target — a caller who passes both\n\t// `external` and an explicit `target` (e.g. a named frame) still wins.\n\tconst computedTarget = external ? (target ?? \"_blank\") : target;\n\n\t// A new context that keeps a handle on `window.opener` can repaint the tab\n\t// it came from, so the safe tokens are non-negotiable whenever the link\n\t// opens one — the `_blank` that `external` defaults to, an explicit\n\t// `target=\"_blank\"`, or any other named target that isn't one of the three\n\t// same-tab keywords above. Existing tokens are preserved with a Set so a\n\t// caller-supplied `rel` is extended, never dropped.\n\t//\n\t// The predicate reads the RESOLVED target and nothing else. `external`\n\t// alone already resolves to `_blank`, so it needs no separate clause — and\n\t// a caller who passes `external` with `target=\"_self\"` genuinely stays in\n\t// this tab, where forcing `noreferrer` would strip a referrer the\n\t// destination may rely on for no safety gain.\n\tconst opensNewContext = !!computedTarget && !SAME_TAB_TARGETS.has(computedTarget.toLowerCase());\n\tlet computedRel = rel;\n\tif (opensNewContext) {\n\t\tconst tokens = new Set(rel?.split(/\\s+/).filter(Boolean));\n\t\ttokens.add(\"noopener\");\n\t\ttokens.add(\"noreferrer\");\n\t\tcomputedRel = [...tokens].join(\" \");\n\t}\n\n\tconst classes = cn(\n\t\t\"ft-link inline-flex w-fit items-center gap-1 no-underline transition-colors focus-visible:outline-none\",\n\t\tvariant === \"default\" ? \"ft-link--default text-sm\" : \"text-muted-foreground text-[13px]\",\n\t\tunderline === \"always\" && \"underline underline-offset-[3px]\",\n\t\tunderline === \"hover\" &&\n\t\t\t\"hover:underline focus-visible:underline hover:underline-offset-[3px] focus-visible:underline-offset-[3px]\",\n\t\tclassName\n\t);\n\n\treturn (\n\t\t<a\n\t\t\t{...rest}\n\t\t\tref={ref}\n\t\t\thref={href}\n\t\t\ttarget={computedTarget}\n\t\t\trel={computedRel}\n\t\t\tclassName={classes}\n\t\t\tonClick={handleClick}\n\t\t>\n\t\t\t{children}\n\t\t\t{external && (\n\t\t\t\t<>\n\t\t\t\t\t<svg\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\twidth=\"0.7em\"\n\t\t\t\t\t\theight=\"0.7em\"\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\tstrokeWidth=\"2.5\"\n\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\tclassName=\"ft-link-icon shrink-0\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<path d=\"M7 17 17 7\" />\n\t\t\t\t\t\t<path d=\"M8 7h9v9\" />\n\t\t\t\t\t</svg>\n\t\t\t\t\t{/*\n\t\t\t\t\t\tThe arrow says \"off-site\", which `external` alone settles.\n\t\t\t\t\t\tThis sentence says \"and it will open somewhere else\", which\n\t\t\t\t\t\tonly the resolved target can settle — an `external` link\n\t\t\t\t\t\tpointed back at `_self` stays right here, and announcing a\n\t\t\t\t\t\tnew tab that never opens is a lie a screen-reader user acts\n\t\t\t\t\t\ton.\n\t\t\t\t\t*/}\n\t\t\t\t\t{opensNewContext && <span className=\"sr-only\"> (opens in a new tab)</span>}\n\t\t\t\t</>\n\t\t\t)}\n\t\t</a>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/link/Link.tsx"},{"path":"react/src/components/link/index.ts","content":"\"use client\";\n\nexport { Link } from \"./Link\";\nexport type { LinkProps } from \"./Link\";\n","type":"registry:ui","target":"@ui/fancy/components/link/index.ts"},{"path":"react/src/components/link/link.css","content":"/*\n * `--ft-accent` is the brand purple shared, by name only, across every\n * component in the Actions family — none of them can reach a real shared\n * token file, so each declares the same fallback locally under its own\n * name (`--ft-link-accent`) and *reads* `--ft-accent` through `var()`\n * rather than redeclaring it. A flat `--ft-accent: …` here would shadow\n * whatever an ancestor set, since a property declared on a closer element\n * always wins regardless of its value — that would make retinting the\n * family from a shared ancestor silently stop working for this component\n * alone.\n */\n.ft-link {\n\t--ft-link-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n\n/*\n * The muted variant intentionally skips these two custom properties and\n * uses the ordinary `text-muted-foreground` token instead — it is supporting\n * copy, not a call to action, so it does not carry the brand hue.\n */\n.ft-link--default {\n\tcolor: var(--ft-link, light-dark(oklch(0.45 0.1013 293.57), oklch(0.8112 0.1013 293.57)));\n}\n\n.ft-link--default:hover {\n\tcolor: var(--ft-link-hover, light-dark(oklch(0.36 0.1013 293.28), oklch(0.8943 0.0549 293.28)));\n}\n\n.ft-link:focus-visible {\n\tborder-radius: 3px;\n\tbox-shadow: 0 0 0 3px color-mix(in oklch, var(--ft-link-accent) 35%, transparent);\n}\n\n/*\n * The external-link arrow nudges a pixel up and to the right on\n * interaction — the direction it points, so the gesture reads as \"this\n * leaves the page\" rather than as decoration. `--ft-ease-out` because the\n * arrow arrives; it does not toggle back and forth in place.\n *\n * `box-shadow` is deliberately absent from the transition list: it is the\n * focus ring above, and a focus ring must never animate.\n *\n * 150ms = tokens.DURATIONS.fast, cubic-bezier(0.16, 1, 0.3, 1) = tokens.EASINGS.out\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-link-icon {\n\t\ttransition: transform var(--ft-link-icon-duration, var(--ft-duration-fast, 150ms))\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));\n\t}\n\n\t/* Keyboard parity is not optional, and it is not free-standing:\n\t   `:focus-visible` is declared unconditionally while `:hover` is gated\n\t   behind `(hover: hover)`, so a touch device that synthesises a sticky\n\t   hover never leaves the arrow parked off-centre. */\n\t.ft-link:focus-visible .ft-link-icon {\n\t\ttransform: translate(1px, -1px);\n\t}\n\n\t@media (hover: hover) {\n\t\t.ft-link:hover .ft-link-icon {\n\t\t\ttransform: translate(1px, -1px);\n\t\t}\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/link/link.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/link?framework=react"}}