# QuickCapture

> A capture window opened by a global shortcut to jot a note or clip a page, with tags, a destination and save with Ctrl or Cmd plus Enter.

Source: https://docs.nasaqui.com/components/quick-capture

## Install

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

Get a thought or a page into the inbox in seconds. A keyboard shortcut opens a small dialog with a focused text box; Ctrl or Cmd plus Enter saves and Escape closes. Pass a `page` and it becomes the web-clipper popup, showing the page and saving it with your note. Pass `presentation="panel"` to render only the form, for a floating window, side panel or extension popup.

## When to use

- A notes or task app where capturing must be faster than opening the app and finding the right place.
- A browser extension popup that saves the current page.
- A desktop app's floating capture window (render the panel in it).

## When not to use

- Writing a full document: use the editor ([rich-text-editor](https://docs.nasaqui.com/components/rich-text-editor)).
- A general command list: use [command-palette](https://docs.nasaqui.com/components/command-palette).
- A form with many fields: use [dialog](https://docs.nasaqui.com/components/dialog) with a form.

## Import

```tsx
import { QuickCapture } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { QuickCapture } from "@fadymondy/nasaq/web";

export function Capture() {
  return (
    <QuickCapture
      shortcut="Mod+Shift+K"
      suggestedTags={["idea", "todo"]}
      onCapture={async (capture) => {
        await api.inbox.add(capture);
      }}
    />
  );
}
```

Press Ctrl+Shift+K (Cmd+Shift+K on a Mac) anywhere in the page to open it.

## Anatomy

```
QuickCapture              data-slot="quick-capture"  data-presentation="dialog" | "panel"
├─ header                 title and description
├─ page card              data-slot="quick-capture-page"  (when `page` is set)
├─ text box               autofocused, dir="auto"
├─ tags                   #tags found in the text, suggested tag toggles
├─ destinations           radio group (when there are two or more)
├─ status                 "Saved to Inbox" (panel) 
└─ footer                 key hints, Cancel, Save
```

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onCapture` | `(capture: CaptureValue) => void \| { error?: string } \| Promise<...>` | required | Save the capture. Return `{ error }` or throw to keep the text. The dialog closes, or the panel clears, only after it resolves. |
| `open`, `defaultOpen`, `onOpenChange` | `boolean`, `boolean`, `(boolean) => void` | uncontrolled, closed | Dialog state. The shortcut toggles it. |
| `presentation` | `"dialog" \| "panel"` | `"dialog"` | A dialog over the page, or the bare form. The panel has no shortcut. |
| `shortcut` | `string \| null` | `"Mod+Shift+K"` | Global toggle. `Mod` is Cmd on Apple and Ctrl elsewhere. A modifier is required. `null` turns it off. |
| `shortcutEnabled` | `boolean` | `true` | Turn the shortcut off without unmounting. |
| `page` | `{ title?, url, selection? }` | none | The page being clipped. The capture becomes a `clip`. |
| `destinations`, `defaultDestinationId` | `{ id, label, icon? }[]`, `string` | none | Where captures go. A picker appears for two or more. |
| `suggestedTags` | `string[]` | none | Tags shown as toggles. |
| `initialText`, `placeholder` | `string` | `""` | Starting text and placeholder. |
| `showHints` | `boolean` | `true` | The key hints in the footer. |
| `labels` | `QuickCaptureLabels` | English or Arabic | String overrides. |

`CaptureValue` is `{ kind: "note" \| "link" \| "clip", text, title, url?, pageTitle?, selection?, tags, destinationId?, capturedAt }`. Tags come from the toggles and from `#tags` typed in the text (Latin or Arabic).

### Helpers

`parseCaptureShortcut`, `matchesCaptureShortcut`, `captureShortcutKeys`, `isCaptureSaveKey`, `buildCapture`, `canSaveCapture`, `extractCaptureTags`, `extractCaptureUrl`. All are pure.

## Examples

Web clipper popup:

```tsx
<QuickCapture
  presentation="panel"
  page={{ title: document.title, url: location.href, selection: String(getSelection()) }}
  destinations={[{ id: "inbox", label: "Inbox" }, { id: "read", label: "Read later" }]}
  onCapture={save}
  onOpenChange={(open) => !open && window.close()}
/>
```

Controlled, with another trigger:

```tsx
const [open, setOpen] = useState(false);
<QuickCapture open={open} onOpenChange={setOpen} shortcut={null} onCapture={save} />;
```

Arabic copy:

```tsx
<QuickCapture onCapture={save} labels={{ title: "التقاط سريع", placeholder: "ما الذي يدور في ذهنك؟" }} />
```

## Accessibility

| Key | Action |
| --- | --- |
| Shortcut (default Ctrl or Cmd + Shift + K) | Open or close the dialog from anywhere. |
| Ctrl or Cmd + Enter | Save. |
| Escape | Close the dialog, or call `onOpenChange(false)` in the panel. |
| Tab | Move between the text, tags, destinations and buttons. |

- The dialog is a modal with a title and description; focus starts in the text box and returns to where it was on close.
- Errors are `role="alert"` and tied to the text box; the saved message in the panel is a polite status.
- The shortcut matches the physical key (`event.code`), so it works on Arabic keyboards. Choose one that does not clash with the browser.

## RTL & i18n

- The text box uses `dir="auto"`. URLs and page addresses are shown left-to-right.
- Tags accept Arabic letters (`#عمل`). Key hints stay left-to-right.
- The dialog and footer mirror with the page direction.

## Styling & tokens

Uses the dialog, control and `--nq-*` selected and focus tokens. Target `data-slot="quick-capture"` and `data-presentation`. Pass `className` to change the width.

## Do / Don't

- Do keep the shortcut listener tied to the mounted component. It is removed on unmount, so it never leaks between pages.
- Do keep the text when saving fails so nothing typed is lost.
- Don't take a bare letter as the shortcut. A modifier is required.
- Don't ask for a title or a folder before saving. Sort later.

## Related

- [dialog](https://docs.nasaqui.com/components/dialog), [command-palette](https://docs.nasaqui.com/components/command-palette), [notes](https://docs.nasaqui.com/components/notes)

## Lab

https://docs.nasaqui.com/?path=/docs/components-productivity-quick-capture--docs

## Code

### React

```tsx
import { QuickCapture } from "@fadymondy/nasaq/web";

export function Capture() {
  return (
    <QuickCapture
      shortcut="Mod+Shift+K"
      suggestedTags={["idea", "todo"]}
      onCapture={async (capture) => {
        await api.inbox.add(capture);
      }}
    />
  );
}
```

### shadcn

```tsx
import { QuickCapture } from "@/components/ui/quick-capture";

export function Capture() {
  return (
    <QuickCapture
      shortcut="Mod+Shift+K"
      suggestedTags={["idea", "todo"]}
      onCapture={async (capture) => {
        await api.inbox.add(capture);
      }}
    />
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqQuickCapture } from "@fadymondy/nasaq/vue";

const save = async (capture: unknown) => {
  console.log("captured", capture);
};
</script>

<template>
  <NqQuickCapture presentation="panel" shortcut="Mod+Shift+K" :suggested-tags="['idea', 'todo']" :on-capture="save" />
</template>
```

### Blade

```blade
<x-nq::quick-capture shortcut="Mod+Shift+K" :suggested-tags="['idea', 'todo']" />
```

### HTML + Alpine

```html
<div x-data="nqQuickCapture(JSON.parse('{\u0022presentation\u0022:\u0022dialog\u0022,\u0022shortcut\u0022:\u0022Mod+Shift+K\u0022,\u0022suggestedTags\u0022:[\u0022idea\u0022,\u0022todo\u0022]}'))" x-modelable="open" x-id="['nq-qc']" class="contents">
        <template x-teleport="body">
            <div data-slot="quick-capture-portal">
                <div data-slot="dialog-backdrop" x-nq-presence="open" x-on:click="close()" class="fixed inset-0 z-50 bg-nq-fg/15 dark:bg-nq-bg/60 transition-opacity duration-150 ease-nq data-starting-style:opacity-0 data-ending-style:opacity-0"></div>
                <div data-slot="quick-capture" data-presentation="dialog" x-bind="popup" x-nq-presence="open" x-trap.noscroll="open"
                    class="fixed inset-0 z-50 m-auto grid h-fit w-[calc(100%-2rem)] gap-4 rounded-floating border border-border bg-popover p-6 text-popover-foreground outline-none max-h-[calc(100dvh-2rem)] overflow-y-auto transition-opacity duration-150 ease-nq data-starting-style:opacity-0 data-ending-style:opacity-0 max-w-xl">
                    <div x-bind="form" class="flex min-w-0 flex-col gap-4">
    <div class="flex flex-col gap-1">
                    <h2 data-slot="dialog-title" :id="$id('nq-qc', 'title')" class="text-h3 flex items-center gap-2 text-h4 font-semibold">
                <svg data-slot="icon" aria-hidden="true" class="size-4 text-muted-foreground" 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="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z"/>
</svg>Quick capture
            </h2>
            <p data-slot="dialog-description" :id="$id('nq-qc', 'description')" class="text-body-sm text-muted-foreground">Jot it down now, sort it later.</p>
            </div>
    <div class="flex flex-col gap-1.5">
        <textarea data-slot="textarea"
                x-ref="area" x-model="text" autofocus="autofocus" rows="5" dir="auto" x-on:input="edited()" :readonly="pending" placeholder="What is on your mind? Use #tags to file it." aria-label="Quick capture" :aria-invalid="error ? &#039;true&#039; : null" :aria-describedby="error ? $id(&#039;nq-qc&#039;, &#039;error&#039;) : null" class="w-full min-w-0 rounded-control border border-input bg-card px-3 text-body text-foreground transition-colors duration-150 ease-nq outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] py-2 min-h-0 resize-none"></textarea>
        <p x-show="error" x-cloak style="display: none" :id="$id('nq-qc', 'error')" role="alert" x-text="error" class="text-caption text-nq-danger-text"></p>
    </div>
    <div x-show="typedTags.length || offeredTags.length"  class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Tags">
        <svg data-slot="icon" aria-hidden="true" class="size-3.5 text-muted-foreground" 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">
  <line x1="4" x2="20" y1="9" y2="9"/>
  <line x1="4" x2="20" y1="15" y2="15"/>
  <line x1="10" x2="8" y1="3" y2="21"/>
  <line x1="16" x2="14" y1="3" y2="21"/>
</svg>        <template x-for="tag in typedTags" :key="'typed-' + tag"><span data-slot="badge"  style="--tag-solid: var(--nq-tag-gray); --tag-soft: var(--nq-tag-gray-soft)"     class="inline-flex h-5 shrink-0 items-center gap-1 whitespace-nowrap rounded-[4px] border px-1.5 text-caption font-medium [&_svg]:size-3 border-transparent bg-[var(--tag-soft)] text-[var(--tag-solid)]"><bdi x-text="'#' + tag"></bdi></span>
</template>
        <template x-for="tag in offeredTags" :key="tag">
            <button type="button" :aria-pressed="picked.includes(tag)" x-on:click="toggleTag(tag)"
                :class="picked.includes(tag) ? 'border-transparent bg-nq-selected text-foreground' : 'border-border text-muted-foreground hover:bg-nq-hover hover:text-foreground'"
                class="inline-flex h-6 items-center gap-1 rounded-full border px-2.5 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-nq-focus">
                <svg x-show="picked.includes(tag)" x-cloak="1" style="display: none" 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="M20 6 9 17l-5-5"/>
</svg>                <bdi x-text="'#' + tag"></bdi>
            </button>
        </template>
    </div>
    <p role="status" x-text="saved ?? ''" :class="saved ? '' : 'sr-only'" class="text-caption text-nq-success-text sr-only"></p>
    <div class="flex flex-wrap items-center gap-2 ">
                    <p class="me-auto flex flex-wrap items-center gap-x-1.5 text-caption text-muted-foreground" dir="ltr">
                <span class="inline-flex items-center gap-0.5"><kbd data-slot="kbd"  dir="ltr"
    x-text="modKey" class="inline-flex h-5 min-w-5 items-center justify-center rounded-[4px] border border-border bg-card px-1 font-mono text-[11px] text-muted-foreground">Ctrl</kbd>
<kbd data-slot="kbd"  dir="ltr"
    class="inline-flex h-5 min-w-5 items-center justify-center rounded-[4px] border border-border bg-card px-1 font-mono text-[11px] text-muted-foreground">Enter</kbd>
</span>
                <span>to save</span>
                <span aria-hidden="true">·</span>
                <kbd data-slot="kbd"  dir="ltr"
    class="inline-flex h-5 min-w-5 items-center justify-center rounded-[4px] border border-border bg-card px-1 font-mono text-[11px] text-muted-foreground">Esc</kbd>
                <span>to close</span>
                <template x-if="hintKeys.length">
                    <span class="inline-flex items-center gap-1.5"><span aria-hidden="true">·</span>
                        <span class="inline-flex items-center gap-0.5" title="Opens from anywhere with">
                            <template x-for="k in hintKeys" :key="k"><kbd data-slot="kbd"  dir="ltr"
    x-text="k" class="inline-flex h-5 min-w-5 items-center justify-center rounded-[4px] border border-border bg-card px-1 font-mono text-[11px] text-muted-foreground"></kbd>
</template>
                        </span>
                    </span>
                </template>
            </p>
                <div class="flex gap-2 ">
            <button data-slot="button"
     type="button"                         x-on:click="cancel()" :disabled="pending" 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)]">
        Cancel</button>
            <button data-slot="button"
     type="button"                         x-on:click="save()" :disabled="!canSave" :aria-busy="pending ? &#039;true&#039; : null" :data-disabled="(pending || !canSave) ? &#039;&#039; : null" 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 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--nq-action)_88%,var(--nq-fg))] h-control px-[var(--nq-control-pad)]">
        <svg data-slot="spinner" aria-hidden="true" style="display: none;" x-show="pending" x-cloak="1" class="size-4 animate-spin motion-reduce:animate-none" 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>                <span x-text="pending ? 'Saving' : 'Save'">Save</span></button>
        </div>
    </div>
</div>
                    <button type="button" data-slot="dialog-close" x-on:click="close()" aria-label="Close"
                        class="absolute end-3 top-3 inline-flex size-8 items-center justify-center rounded-control text-muted-foreground transition-colors duration-150 hover:bg-nq-hover hover:text-foreground focus-visible:outline-2 focus-visible:outline-nq-focus [&_svg]:size-4"><svg 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 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg></button>
                </div>
            </div>
        </template>
    </div>
```
