# Linkify

> Small text helpers for user content. Linkify safe links, bidi-isolated UserText, a translation toggle, scroll fades, an optimistic save button and progressive reveal.

Source: https://docs.nasaqui.com/components/text-utilities

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/text-utilities.json
```

Seven small parts for text people wrote. `Linkify` turns http, https, www and email addresses into links. `UserText`
isolates a name or message so its direction never scrambles the sentence around it. `TranslatableText` toggles between
the original and a translation. `ScrollFade` fades the edge of a sideways-scrolling row. `BookmarkButton` saves
optimistically. `ProgressiveReveal` and `ProgressiveList` show more on demand.

## When to use

- Chat messages, comments, notes and titles that may contain links and either script: `UserText` with `linkify`.
- A toggle for machine translation: `TranslatableText`.
- A chip row or tab row wider than its container: `ScrollFade`.

## When not to use

- Rich text or Markdown: render it with a sanitising renderer. `Linkify` only scans plain strings and never parses HTML.

## Import

```tsx
import { Linkify, UserText, TranslatableText, ScrollFade, BookmarkButton, ProgressiveReveal, ProgressiveList } from "@fadymondy/nasaq/web";
```

## Quick start

```tsx
import { UserText, BookmarkButton } from "@fadymondy/nasaq/web";

export function Example({ message }: { message: string }) {
  return (
    <div className="flex items-start gap-2">
      <UserText block lines={3} linkify>{message}</UserText>
      <BookmarkButton onSavedChange={async (saved) => { await save(saved); }} />
    </div>
  );
}
```

## API

| Component | Prop | Description |
| --- | --- | --- |
| `Linkify` | `children: string`, `emails` (true), `linkClassName`, `renderLink` | Only safe schemes. Trailing punctuation is left out of the link. Arabic punctuation ends a URL. Bare domains are not linked. |
| `UserText` | `block`, `lines`, `linkify`, `as` | `dir="auto"`, isolated, aligned to the start edge. `lines` clamps and puts the full text in `title`. |
| `TranslatableText` | `original`, `translation`, `sourceLang`, `targetLang`, `onTranslate`, `defaultShowTranslation`, `lines`, `linkify` | `onTranslate` runs once, the result is kept. Resolve `{ error }` to show a retry. |
| `ScrollFade` | `fadeSize` (32), `label`, `contentClassName` | Fades only the edge with more content. Direction follows the layout. |
| `BookmarkButton` | `saved` / `defaultSaved`, `onSavedChange`, `showLabel` | Flips at once, reverts if the promise rejects or resolves `{ error }`. |
| `ProgressiveReveal` | `collapsedHeight` (96), `expanded` / `defaultExpanded`, `onExpandedChange` | The button appears only when the content is taller than the limit. |
| `ProgressiveList` | `items`, `initial` (3), `step` (3), `onReveal` | Shows the count left. |

All take `labels` (`TextUtilitiesLabels`) to override the en and ar strings. Pure helpers with no React are also
exported: `linkifyText`, `linkifyTrim`, `scrollFadeState`, `scrollFadeMask`, `progressiveNextCount`, `progressiveRemaining`.

## Accessibility

- Links are real anchors with `rel="noopener noreferrer"`.
- `ScrollFade` is a labelled, focusable region while it scrolls, so it can be moved with the arrow keys.
- `BookmarkButton` uses `aria-pressed` and a polite live region for saved, removed and failed.
- `ProgressiveReveal` drives `aria-expanded` on a real button.

## RTL & i18n

URLs and emails are left to right inside Arabic text. `ScrollFade` reads the computed direction and uses the absolute
scroll offset, so the start fade sits on the right in Arabic. Strings are en and ar.

## Styling & tokens

Semantic tokens only. Extend with `className`, `linkClassName` and `contentClassName`.

## Do / Don't

- Do pass plain strings to `Linkify` and `UserText`.
- Do not use `dangerouslySetInnerHTML` to add links.
- Do not link bare domains: people write "file.txt" and "e.g".

## Related

- [BidiText](https://docs.nasaqui.com/components/bidi-text)
- [CopyButton](https://docs.nasaqui.com/components/copy-button)

## Lab

https://docs.nasaqui.com/?path=/docs/components-utilities-text-utilities--docs

## Code

### React

```tsx
import { UserText, BookmarkButton } from "@fadymondy/nasaq/web";

export function Example({ message }: { message: string }) {
  return (
    <div className="flex items-start gap-2">
      <UserText block lines={3} linkify>{message}</UserText>
      <BookmarkButton onSavedChange={async (saved) => { await save(saved); }} />
    </div>
  );
}
```

### shadcn

```tsx
import { UserText, BookmarkButton } from "@/components/ui/text-utilities";

export function Example({ message }: { message: string }) {
  return (
    <div className="flex items-start gap-2">
      <UserText block lines={3} linkify>{message}</UserText>
      <BookmarkButton onSavedChange={async (saved) => { await save(saved); }} />
    </div>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqBookmarkButton, NqLinkify, NqProgressiveList, NqScrollFade, NqTranslatableText, NqUserText } from "@fadymondy/nasaq/vue";

const translate = () => new Promise<string>((resolve) => setTimeout(() => resolve("Hello, and welcome to the team."), 500));
const save = () => new Promise<void>((resolve) => setTimeout(resolve, 300));
</script>

<template>
  <div class="flex max-w-md flex-col gap-4">
    <NqLinkify text="Docs at https://nasaq-ui.fadymondy.com or write to hello@example.com." />
    <NqUserText block :lines="2" linkify>مرحبا Sara, see www.example.com for the details of the plan we discussed on Sunday.</NqUserText>
    <NqTranslatableText original="مرحبًا بك في الفريق" source-lang="ar" :on-translate="translate" />
    <NqScrollFade>
      <span v-for="n in 12" :key="n" class="shrink-0 rounded-control border px-3 py-1">Chip {{ n }}</span>
    </NqScrollFade>
    <NqBookmarkButton show-label :on-saved-change="save" />
    <NqProgressiveList :items="['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven']" />
  </div>
</template>
```

### Blade

```blade
<div class="flex max-w-md flex-col gap-4">
    <x-nq::text-utilities.linkify text="Docs at https://nasaq-ui.fadymondy.com or write to hello@example.com." />
    <x-nq::text-utilities.user-text block :lines="2" linkify>مرحبا Sara, see www.example.com for the details of the plan we discussed on Sunday.</x-nq::text-utilities.user-text>
    <x-nq::text-utilities.translatable-text original="مرحبًا بك في الفريق" source-lang="ar" target-lang="en" fetch />
    <x-nq::text-utilities.scroll-fade>
        @foreach (range(1, 12) as $n)<span class="shrink-0 rounded-control border px-3 py-1">Chip {{ $n }}</span>@endforeach
    </x-nq::text-utilities.scroll-fade>
    <x-nq::text-utilities.bookmark-button show-label />
    <x-nq::text-utilities.progressive-reveal :collapsed-height="48"><p>One long paragraph that would be clamped in a real browser because it is taller than the limit.</p></x-nq::text-utilities.progressive-reveal>
    <x-nq::text-utilities.progressive-list :items="['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven']" />
</div>
```

### HTML + Alpine

```html
<div class="flex max-w-md flex-col gap-4">
    <span data-slot="linkify" >Docs at <bdi dir="ltr"><a href="https://nasaq-ui.fadymondy.com"  target="_blank" rel="noopener noreferrer nofollow ugc"  class="[overflow-wrap:anywhere] text-foreground underline decoration-nq-line underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">https://nasaq-ui.fadymondy.com</a></bdi> or write to <bdi dir="ltr"><a href="mailto:hello@example.com"  class="[overflow-wrap:anywhere] text-foreground underline decoration-nq-line underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">hello@example.com</a></bdi>.</span>
    <p data-slot="user-text" dir="auto"  title="مرحبا Sara, see www.example.com for the details of the plan we discussed on Sunday."   style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"     class="[unicode-bidi:isolate] text-start block [unicode-bidi:plaintext] overflow-hidden break-words">
    <span data-slot="linkify" >مرحبا Sara, see <bdi dir="ltr"><a href="https://www.example.com"  target="_blank" rel="noopener noreferrer nofollow ugc"  class="[overflow-wrap:anywhere] text-foreground underline decoration-nq-line underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">www.example.com</a></bdi> for the details of the plan we discussed on Sunday.</span>
</p>
    <div data-slot="translatable-text" data-showing="original" x-data="nqTranslatable(JSON.parse('{\u0022original\u0022:\u0022\\u0645\\u0631\\u062d\\u0628\\u064b\\u0627 \\u0628\\u0643 \\u0641\\u064a \\u0627\\u0644\\u0641\\u0631\\u064a\\u0642\u0022,\u0022translation\u0022:null,\u0022source\u0022:\u0022ar\u0022,\u0022target\u0022:\u0022en\u0022,\u0022show\u0022:false,\u0022fetch\u0022:true}'))" x-bind="root" class="flex min-w-0 flex-col gap-1.5">
    <p data-slot="user-text" dir="auto" lang="ar" x-bind="body"         class="block min-w-0 text-start [unicode-bidi:plaintext] text-body text-foreground ">مرحبًا بك في الفريق</p>
            <div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-muted-foreground">
            <button type="button" x-bind="toggleButton" aria-pressed="false"
                class="inline-flex items-center gap-1.5 rounded-control text-muted-foreground underline decoration-nq-line underline-offset-4 outline-none hover:text-foreground hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:opacity-60">
                <svg data-slot="spinner" aria-hidden="true" style="display: none;" x-show="pending" x-cloak="1" class="animate-spin motion-reduce:animate-none size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>                <svg x-show="!pending" aria-hidden="true" class="size-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m5 8 6 6"/>
  <path d="m4 14 6-6 2-3"/>
  <path d="M2 5h12"/>
  <path d="M7 2h1"/>
  <path d="m22 22-5-10-5 10"/>
  <path d="M14 18h6"/>
</svg>                <span x-text="toggleText">Show translation</span>
            </button>
            <span x-text="note">Original in Arabic</span>
        </div>
        <p role="alert" x-show="error" x-cloak style="display: none" class="flex flex-wrap items-center gap-2 text-caption text-nq-danger-text">
        <span x-text="error"></span>
        <button type="button" x-on:click="load()" class="inline-flex items-center gap-1 underline underline-offset-4 outline-none focus-visible:outline-2 focus-visible:outline-nq-focus">
            <svg aria-hidden="true" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
  <path d="M3 3v5h5"/>
</svg>            Try again
        </button>
    </p>
</div>
    <div data-slot="scroll-fade" role="region" aria-label="Scrollable content" x-data="nqScrollFade(32)" x-bind="root"
    class="overflow-x-auto overscroll-x-contain outline-none [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus [&::-webkit-scrollbar]:hidden">
    <div class="flex w-max min-w-full items-center gap-2"><span class="shrink-0 rounded-control border px-3 py-1">Chip 1</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 2</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 3</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 4</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 5</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 6</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 7</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 8</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 9</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 10</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 11</span><span class="shrink-0 rounded-control border px-3 py-1">Chip 12</span></div>
</div>
    <span class="contents" x-data="nqBookmark(false, true)" x-modelable="saved">
    <button data-slot="bookmark-button"
     type="button"                         aria-pressed="false" x-bind="button" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover h-control px-[var(--nq-control-pad)]">
        <svg aria-hidden="true" x-bind:class="saved ? 'fill-current' : ''" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z"/>
</svg>        <span x-text="text">Save</span></button>
    <span role="status" x-bind="status" class="sr-only"></span>
</span>
    <div data-slot="progressive-reveal" x-data="nqReveal(48, false)" x-modelable="expanded" x-bind="root"
    class="flex flex-col items-start gap-2">
    <div x-ref="body" x-bind="body" class="w-full overflow-hidden"  style="max-height: 48px" ><p>One long paragraph that would be clamped in a real browser because it is taller than the limit.</p></div>
    <button data-slot="button"
     type="button"                         x-show="overflows || expanded" x-cloak="x-cloak" style="display: none" x-bind="button" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover h-control-sm px-2.5">
        <svg x-show="expanded" x-cloak="1" style="display: none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m18 15-6-6-6 6"/>
</svg>        <svg x-show="!expanded" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m6 9 6 6 6-6"/>
</svg>        <span x-text="label">Show more</span></button>
</div>
    <div data-slot="progressive-list" x-data="nqProgressiveList(3, 3, 7)" class="flex flex-col items-start gap-2">
    <ul x-ref="list" class="flex w-full flex-col gap-2">
                    <li class="min-w-0" >One</li><li class="min-w-0" >Two</li><li class="min-w-0" >Three</li><li class="min-w-0"  hidden >Four</li><li class="min-w-0"  hidden >Five</li><li class="min-w-0"  hidden >Six</li><li class="min-w-0"  hidden >Seven</li>            </ul>
    <button data-slot="button"
     type="button"                         x-bind="button" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover h-control-sm px-2.5">
        <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m6 9 6 6 6-6"/>
</svg>        <span x-text="moreText">Show 3 more</span>
        <span class="text-muted-foreground" x-text="leftText">4 left</span></button>
</div>
</div>
```
