# DesktopIconGrid

> Icons on a desktop surface. An auto grid, or free placement with drag and snap. Select, open, context menu and keyboard moves, with RTL built in.

Source: https://docs.nasaqui.com/components/desktop-icons

## Install

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

Shortcuts on a desktop: apps, files or folders, each an icon with a label. Without `onMove` the icons flow into a
grid. With `onMove` they can be dragged anywhere and snap to cells; you keep the positions.

## When to use

- The desktop area of a `DesktopShell`, or any web OS surface with launchable items.
- A "drop files here" desktop where people arrange their own items.

## When not to use

- A list of apps in a menu or a launcher: use `CommandPalette` or `Spotlight`.
- A file browser with columns and sorting: use a `DataTable`.

## Import

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

## Quick start

```tsx
import { DesktopShell, DesktopIconGrid } from "@fadymondy/nasaq/web";

export function Desktop({ apps }: { apps: { id: string; title: string; icon: React.ReactNode }[] }) {
  return (
    <DesktopShell apps={apps}>
      {({ open }) => <DesktopIconGrid items={apps} onOpen={(item) => open(item.id)} />}
    </DesktopShell>
  );
}
```

## Anatomy

```
DesktopIconGrid            data-slot="desktop-icon-grid"  role="group"  data-free in free mode
└─ DesktopIcon             data-slot="desktop-icon"  <button aria-pressed>  (one per item)
   ├─ icon                 aria-hidden
   └─ label                two lines, then clamped
```

## API

`div` props plus:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `DesktopIconItem[]` | required | `{ id, title, icon }`. |
| `onOpen` | `(item) => void` | required | Double-click, Enter, or a tap on touch screens (`openOn="auto"`). |
| `openOn` | `"auto" \| "click" \| "double-click"` | `"auto"` | |
| `positions` / `onMove` | `Record<id, {x, y}>` / `(item, pos) => void` | | `onMove` turns on free placement. Icons without a position fill free cells. |
| `snap` | `boolean` | `true` | Snap dropped icons to cells (`DESKTOP_ICON_CELL`). |
| `hiddenIds` | `string[]` | | Items not shown. |
| `onRemove` | `(item) => void` | | Adds "Remove from desktop" to the context menu. |
| `actions` | `(item) => ContextMenuAction[]` | | More context-menu items, after Open. |
| `selected` / `defaultSelected` / `onSelectedChange` | `string \| null` | | |
| `labels` | `DesktopIconsLabels` | | |

`desktopIconSlot(index, height)` returns the `{x, y}` of a grid cell, for laying out saved positions.

## Accessibility

- The grid is a labelled `group` of buttons; each icon is named by its title and `aria-pressed` shows the selection. Arrow keys move, Enter or Space opens, Escape clears.
- The context menu opens from the keyboard too (Shift+F10 or the Menu key) and its actions are never the only way to do something.
- Dragging starts after 5px, so a click is never a drag. There is no drag-only action.

## RTL & i18n

- Arrow keys and free positions follow the reading direction: `x` is measured from the inline start.
- English and Arabic labels are built in.

## Styling & tokens

- Selection is a primary tint and ring; labels sit on a `bg-background/70` plate so they read on any wallpaper. No shadows.

## Do / Don't

- Do save `positions` per user, so the desktop looks the same next time.
- Don't put more than a few dozen icons on one desktop. Group them in a folder.

## Related

- [`desktop-os-shell`](https://docs.nasaqui.com/components/desktop-os-shell)
- [`context-menu`](https://docs.nasaqui.com/components/context-menu)
- [`desktop-locations`](https://docs.nasaqui.com/components/desktop-locations)

## Lab

https://docs.nasaqui.com/?path=/docs/components-apps-platforms-desktop-icons--docs

## Code

### React

```tsx
import { DesktopShell, DesktopIconGrid } from "@fadymondy/nasaq/web";

export function Desktop({ apps }: { apps: { id: string; title: string; icon: React.ReactNode }[] }) {
  return (
    <DesktopShell apps={apps}>
      {({ open }) => <DesktopIconGrid items={apps} onOpen={(item) => open(item.id)} />}
    </DesktopShell>
  );
}
```

### shadcn

```tsx
import { DesktopShell } from "@/components/ui/desktop-os-shell";
import { DesktopIconGrid } from "@/components/ui/desktop-icons";

export function Desktop({ apps }: { apps: { id: string; title: string; icon: React.ReactNode }[] }) {
  return (
    <DesktopShell apps={apps}>
      {({ open }) => <DesktopIconGrid items={apps} onOpen={(item) => open(item.id)} />}
    </DesktopShell>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { Folder } from "lucide-vue-next";
import { h } from "vue";
import { NqDesktopAppIcon, NqDesktopIconGrid, NqDesktopShell, type DesktopApp } from "@fadymondy/nasaq/vue";

const apps: DesktopApp[] = [
  { id: "files", title: "Files", icon: h(NqDesktopAppIcon, null, () => h(Folder)), content: h("p", { class: "p-4" }, "Files"), single: true },
];
</script>

<template>
  <div class="h-96">
    <NqDesktopShell :apps="apps">
      <template #default="{ open }">
        <NqDesktopIconGrid :items="apps" @open="(item) => open(item.id)" />
      </template>
    </NqDesktopShell>
  </div>
</template>
```

### Blade

```blade
<div class="flex flex-col gap-4">
    <div class="h-40 w-full" id="grid-example">
        <x-nq::desktop-icons :items="[['id' => 'files', 'title' => 'Files', 'icon' => 'folder'], ['id' => 'notes', 'title' => 'Notes', 'icon' => 'notebook-pen'], ['id' => 'mail', 'title' => 'Mail', 'icon' => 'mail']]" />
    </div>
    <div class="relative h-72 w-full" id="free-example">
        <x-nq::desktop-icons free :items="[['id' => 'docs', 'title' => 'Documents', 'icon' => 'file-text'], ['id' => 'pics', 'title' => 'Pictures', 'icon' => 'image']]" :positions="['docs' => ['x' => 8, 'y' => 8]]" />
    </div>
</div>
```

### HTML + Alpine

```html
<div class="flex flex-col gap-4">
    <div class="h-40 w-full" id="grid-example">
        <div role="group" aria-label="Desktop" data-slot="desktop-icon-grid"     x-data="nqDesktopIcons(JSON.parse('[{\u0022id\u0022:\u0022files\u0022,\u0022title\u0022:\u0022Files\u0022,\u0022iconHtml\u0022:\u0022\\u003Cspan data-slot=\\u0022desktop-app-icon\\u0022 class=\\u0022grid size-full place-items-center rounded-[22%] bg-card text-primary shadow-sm ring-1 ring-border [\\u0026_svg]:size-1\\\/2\\u0022\\u003E\\n    \\u003Csvg aria-hidden=\\u0022true\\u0022 xmlns=\\u0022http:\\\/\\\/www.w3.org\\\/2000\\\/svg\\u0022 viewBox=\\u00220 0 24 24\\u0022 fill=\\u0022none\\u0022 stroke=\\u0022currentColor\\u0022 stroke-width=\\u00222\\u0022 stroke-linecap=\\u0022round\\u0022 stroke-linejoin=\\u0022round\\u0022\\u003E\\n  \\u003Cpath d=\\u0022M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\\u0022\\\/\\u003E\\n\\u003C\\\/svg\\u003E\\n\\u003C\\\/span\\u003E\\n\u0022},{\u0022id\u0022:\u0022notes\u0022,\u0022title\u0022:\u0022Notes\u0022,\u0022iconHtml\u0022:\u0022\\u003Cspan data-slot=\\u0022desktop-app-icon\\u0022 class=\\u0022grid size-full place-items-center rounded-[22%] bg-card text-primary shadow-sm ring-1 ring-border [\\u0026_svg]:size-1\\\/2\\u0022\\u003E\\n    \\u003Csvg aria-hidden=\\u0022true\\u0022 xmlns=\\u0022http:\\\/\\\/www.w3.org\\\/2000\\\/svg\\u0022 viewBox=\\u00220 0 24 24\\u0022 fill=\\u0022none\\u0022 stroke=\\u0022currentColor\\u0022 stroke-width=\\u00222\\u0022 stroke-linecap=\\u0022round\\u0022 stroke-linejoin=\\u0022round\\u0022\\u003E\\n  \\u003Cpath d=\\u0022M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M2 6h4\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M2 10h4\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M2 14h4\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M2 18h4\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z\\u0022\\\/\\u003E\\n\\u003C\\\/svg\\u003E\\n\\u003C\\\/span\\u003E\\n\u0022},{\u0022id\u0022:\u0022mail\u0022,\u0022title\u0022:\u0022Mail\u0022,\u0022iconHtml\u0022:\u0022\\u003Cspan data-slot=\\u0022desktop-app-icon\\u0022 class=\\u0022grid size-full place-items-center rounded-[22%] bg-card text-primary shadow-sm ring-1 ring-border [\\u0026_svg]:size-1\\\/2\\u0022\\u003E\\n    \\u003Csvg aria-hidden=\\u0022true\\u0022 xmlns=\\u0022http:\\\/\\\/www.w3.org\\\/2000\\\/svg\\u0022 viewBox=\\u00220 0 24 24\\u0022 fill=\\u0022none\\u0022 stroke=\\u0022currentColor\\u0022 stroke-width=\\u00222\\u0022 stroke-linecap=\\u0022round\\u0022 stroke-linejoin=\\u0022round\\u0022\\u003E\\n  \\u003Cpath d=\\u0022m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7\\u0022\\\/\\u003E\\n  \\u003Crect x=\\u00222\\u0022 y=\\u00224\\u0022 width=\\u002220\\u0022 height=\\u002216\\u0022 rx=\\u00222\\u0022\\\/\\u003E\\n\\u003C\\\/svg\\u003E\\n\\u003C\\\/span\\u003E\\n\u0022}]'), {})" x-modelable="selected"
    x-on:keydown="groupKey($event)" x-on:pointerdown="background($event)"
    class="grid auto-rows-max grid-cols-[repeat(auto-fill,6rem)] content-start gap-2 p-2">
    <template x-for="(item, index) in visible()" x-bind:key="item.id">
                    <div class="flex">
                <button type="button" data-slot="desktop-icon" x-bind:data-id="item.id" x-bind:data-selected="selected === item.id ? '' : null" x-bind:aria-pressed="selected === item.id ? 'true' : 'false'"
                    x-on:click="click(item.id)" x-on:dblclick="dblclick(item.id)" x-on:keydown="keydown($event, item.id)"
                    x-bind:class="selected === item.id ? 'bg-primary/15 ring-1 ring-primary/40' : 'hover:bg-nq-hover'" class="group flex w-22 select-none flex-col items-center gap-1.5 rounded-lg p-1.5 text-center outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-nq-focus">
                    <span aria-hidden="true" class="size-12 shrink-0" x-html="item.iconHtml"></span>
                    <span x-text="item.title" x-bind:class="selected === item.id ? 'bg-primary text-primary-foreground' : 'bg-background/70 text-foreground'" class="line-clamp-2 max-w-full rounded-sm px-1 text-caption font-medium break-words"></span>
                </button>
            </div>
            </template>
</div>
    </div>
    <div class="relative h-72 w-full" id="free-example">
        <div role="group" aria-label="Desktop" data-slot="desktop-icon-grid"  data-free     x-data="nqDesktopIcons(JSON.parse('[{\u0022id\u0022:\u0022docs\u0022,\u0022title\u0022:\u0022Documents\u0022,\u0022iconHtml\u0022:\u0022\\u003Cspan data-slot=\\u0022desktop-app-icon\\u0022 class=\\u0022grid size-full place-items-center rounded-[22%] bg-card text-primary shadow-sm ring-1 ring-border [\\u0026_svg]:size-1\\\/2\\u0022\\u003E\\n    \\u003Csvg aria-hidden=\\u0022true\\u0022 xmlns=\\u0022http:\\\/\\\/www.w3.org\\\/2000\\\/svg\\u0022 viewBox=\\u00220 0 24 24\\u0022 fill=\\u0022none\\u0022 stroke=\\u0022currentColor\\u0022 stroke-width=\\u00222\\u0022 stroke-linecap=\\u0022round\\u0022 stroke-linejoin=\\u0022round\\u0022\\u003E\\n  \\u003Cpath d=\\u0022M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M14 2v5a1 1 0 0 0 1 1h5\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M10 9H8\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M16 13H8\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022M16 17H8\\u0022\\\/\\u003E\\n\\u003C\\\/svg\\u003E\\n\\u003C\\\/span\\u003E\\n\u0022},{\u0022id\u0022:\u0022pics\u0022,\u0022title\u0022:\u0022Pictures\u0022,\u0022iconHtml\u0022:\u0022\\u003Cspan data-slot=\\u0022desktop-app-icon\\u0022 class=\\u0022grid size-full place-items-center rounded-[22%] bg-card text-primary shadow-sm ring-1 ring-border [\\u0026_svg]:size-1\\\/2\\u0022\\u003E\\n    \\u003Csvg aria-hidden=\\u0022true\\u0022 xmlns=\\u0022http:\\\/\\\/www.w3.org\\\/2000\\\/svg\\u0022 viewBox=\\u00220 0 24 24\\u0022 fill=\\u0022none\\u0022 stroke=\\u0022currentColor\\u0022 stroke-width=\\u00222\\u0022 stroke-linecap=\\u0022round\\u0022 stroke-linejoin=\\u0022round\\u0022\\u003E\\n  \\u003Crect width=\\u002218\\u0022 height=\\u002218\\u0022 x=\\u00223\\u0022 y=\\u00223\\u0022 rx=\\u00222\\u0022 ry=\\u00222\\u0022\\\/\\u003E\\n  \\u003Ccircle cx=\\u00229\\u0022 cy=\\u00229\\u0022 r=\\u00222\\u0022\\\/\\u003E\\n  \\u003Cpath d=\\u0022m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\\u0022\\\/\\u003E\\n\\u003C\\\/svg\\u003E\\n\\u003C\\\/span\\u003E\\n\u0022}]'), JSON.parse('{\u0022free\u0022:true,\u0022positions\u0022:{\u0022docs\u0022:{\u0022x\u0022:8,\u0022y\u0022:8}}}'))" x-modelable="selected"
    x-on:keydown="groupKey($event)" x-on:pointerdown="background($event)"
    class="relative size-full">
    <template x-for="(item, index) in visible()" x-bind:key="item.id">
                    <div data-slot="desktop-icon-position" x-bind:data-dragging="dragging === item.id ? '' : null" x-bind:style="style(item.id, index)"
                x-bind:class="dragging === item.id ? 'z-10 opacity-85' : ''" class="absolute [touch-action:none]"
                x-on:pointerdown="down($event, item.id, index)" x-on:pointermove="move($event)" x-on:pointerup="up($event)" x-on:pointercancel="up($event)">
                <button type="button" data-slot="desktop-icon" x-bind:data-id="item.id" x-bind:data-selected="selected === item.id ? '' : null" x-bind:aria-pressed="selected === item.id ? 'true' : 'false'"
                    x-on:click.capture="clickCapture($event)" x-on:click="click(item.id)" x-on:dblclick="dblclick(item.id)" x-on:keydown="keydown($event, item.id)"
                    x-bind:class="selected === item.id ? 'bg-primary/15 ring-1 ring-primary/40' : 'hover:bg-nq-hover'" class="group flex w-22 select-none flex-col items-center gap-1.5 rounded-lg p-1.5 text-center outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-nq-focus">
                    <span aria-hidden="true" class="size-12 shrink-0" x-html="item.iconHtml"></span>
                    <span x-text="item.title" x-bind:class="selected === item.id ? 'bg-primary text-primary-foreground' : 'bg-background/70 text-foreground'" class="line-clamp-2 max-w-full rounded-sm px-1 text-caption font-medium break-words"></span>
                </button>
            </div>
            </template>
</div>
    </div>
</div>
```
