익스클루시브

Attache <mv-attache>

모양을 인식하는 그리드 패킹 편집기입니다. 각 자식 요소가 고정 그리드에서 w × h 칸을 차지하며(대시보드 위젯, 인쇄 시트의 스티커, 선반의 상자, 방 안의 테이블), 무엇이 들어가고 무엇이 들어가지 않는지 그리드에서 한눈에 확인할 수 있습니다.

문화적 레퍼런스

바이오하자드 4, Capcom (감독 미카미 신지) (2005, 게임). 어태치 케이스 인벤토리는 고정된 그리드로, 모든 아이템이 셀을 차지하는 형태를 가집니다. 아이템을 회전하고 배치해 전부 들어가게 만들며, 무엇이 들어가고 무엇이 안 들어가는지 한눈에 볼 수 있습니다. 여기서는 크기가 제각각인 항목(위젯, 스티커, 상자)을 고정 그리드에 배치하며, 셀 단위로 들어맞음이나 충돌을 보여 주는 실시간 점유 영역, 드래그 중 회전, 클릭 한 번의 자동 배치, 들어가지 않는 항목을 위한 트레이를 제공합니다.

작동 방식

형태를 인식하는 그리드 패킹 편집기: 모든 자식 요소가 고정 그리드의 w × h 셀을 차지하며(대시보드 위젯, 인쇄 시트의 스티커, 선반 위의 상자, 방 안의 테이블), 그리드는 무엇이 들어가고 무엇이 안 들어가는지 한눈에 보여 줍니다. 항목을 드래그하면 그 점유 영역이 셀 단위로 그리드에 놓입니다: 들어맞는 곳은 깨끗한 셀, 다른 항목과 겹치거나 경계를 벗어나는 곳은 빗금과 외곽선이 있는 셀로 표시되고, 이를 말로 알려 주는 태그(“Fits · col 3, row 1”, “Overlaps Support queue”)가 붙으며, 방해가 되는 항목에는 외곽선이 그려집니다. R 키, 오른쪽 클릭 또는 두 번째 손가락으로 드래그 중에 항목을 회전합니다(w와 h가 바뀌고, 잡은 지점도 함께 회전). 거부된 드롭은 원래 자리로 날아갑니다. Auto-arrange는 결정적인 first-fit-decreasing 패커(여러 정렬 순서, 회전 허용, 최선의 결과 유지)로 잠긴 항목 주위에 모든 항목을 배치합니다. 들어가지 못한 항목은 오버플로 트레이(“2 items don't fit”)에서 기다리며, 각 항목은 지금 들어갈 자리가 있는지 알려 주고 더블 클릭하면 배치됩니다. 키보드로 완전히 조작할 수 있고, 레이아웃은 데이터로 노출되며(layout 속성, mv-layout-change, 취소 가능한 mv-drop), 잠긴 항목은 제자리에 머물고, 셀은 요소의 너비에 맞춰 크기가 조정되며, packLayout()은 서버 측 레이아웃을 위한 순수 함수로 내보내집니다.

카테고리레이아웃
유형Web Component (<mv-attache>)
상태안정
함께 설치되는 항목button
Keywordsexclusive, culture, grid, packing, bin-packing, layout-editor, dashboard, widgets, drag-and-drop, rotate, footprint, collision, tetris, inventory, print-sheet, seating-plan, slotting, keyboard, overflow

When to use

  • Users arrange dashboard widgets of different sizes on a fixed grid and must see what fits before they drop
  • A print sheet, label run or storage slot plan must use a fixed number of cells as fully as possible
  • Some items are pinned by an admin and the rest must be repacked around them in one click
  • The app must know, and be able to veto, every placement as structured data (x, y, w, h, rotated)

Avoid when

  • The layout is fixed by the designer and users never rearrange it → use Bento Grid instead
  • Items move between workflow stages in ordered lists rather than occupying 2D cells → use Kanban instead
  • Items are freely positioned at any pixel, rotated at any angle or overlap on purpose (a whiteboard, a free canvas)

설치

node scripts/add.mjs attache --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["attache"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, components/attache/attache.js, components/attache/attache.css.

사용법

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="at-demo" style="width:min(100%,62rem);margin-inline:auto">
  <style>
    #at-demo { display:grid; gap:1.25rem }
    #at-demo .at-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
    #at-demo .at-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #at-demo .at-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #at-demo .at-controls { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
    #at-demo .mv-choice { font-size:.8125rem }
    #at-demo #at-board { --mv-attache-ratio:.6 }

    /* Widgets: the content adapts to the footprint through data-w / data-h. */
    #at-demo .dw { display:flex; flex-direction:column; gap:.25rem; padding:.625rem .75rem; font-size:.75rem; line-height:1.3 }
    #at-demo .dw-k { display:flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #at-demo .dw-k svg { flex:none; width:.875rem; height:.875rem }
    #at-demo .dw-v { font-size:1.25rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums; color:var(--mv-fg) }
    #at-demo .dw[data-w="1"][data-h="1"] .dw-v { font-size:1rem }
    #at-demo .dw-d { color:var(--mv-fg-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #at-demo .dw-up { color:var(--mv-success) }
    #at-demo .dw-bars { flex:1; display:flex; align-items:flex-end; gap:4px; min-height:0; padding-top:.25rem }
    #at-demo .dw-bars i { flex:1; height:var(--v); border-radius:3px 3px 1px 1px; background:color-mix(in oklab, var(--mv-accent) 70%, transparent) }
    #at-demo .dw-bars i:last-child { background:var(--mv-accent) }
    #at-demo .dw-rows { display:grid; gap:.3125rem; margin-top:auto }
    #at-demo .dw-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.125rem .5rem; align-items:center }
    #at-demo .dw-row span:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #at-demo .dw-row span:nth-child(2) { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
    #at-demo .dw-row b { grid-column:1 / -1; height:4px; border-radius:2px; background:var(--mv-bg-muted); overflow:hidden }
    #at-demo .dw-row b::before { content:""; display:block; width:var(--v); height:100%; background:var(--mv-accent) }
    #at-demo .dw-list { list-style:none; margin:auto 0 0; padding:0; display:grid; gap:.25rem }
    #at-demo .dw-list li { display:flex; justify-content:space-between; gap:.5rem; color:var(--mv-fg-muted) }
    #at-demo .dw-list li span:first-child { color:var(--mv-fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #at-demo .dw-list li span:last-child { font-variant-numeric:tabular-nums; white-space:nowrap }
    #at-demo .dw-spark { width:100%; height:1.5rem; margin-top:auto; color:var(--mv-accent) }
    #at-demo .dw-prog { height:6px; margin-top:auto; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
    #at-demo .dw-prog::before { content:""; display:block; width:60%; height:100%; background:var(--mv-success) }
    #at-demo .dw[data-h="1"] .dw-extra { display:none }
    #at-demo .dw[data-w="1"] .dw-wide { display:none }
    #at-demo .dw[data-attache-orient="landscape"] .dw-list { grid-auto-flow:column; grid-auto-columns:minmax(0,1fr); gap:.75rem }
    #at-demo .dw[data-attache-orient="landscape"] .dw-list li { flex-direction:column; gap:0 }
    #at-demo .dw[data-attache-orient="portrait"] .dw-d { white-space:normal }

    #at-demo .at-lower { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1.25rem; align-items:start }
    #at-demo .at-panel { display:grid; gap:.75rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #at-demo .at-panel h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.8125rem; font-weight:600 }
    #at-demo .at-panel p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
    #at-demo .at-json { margin:0; max-height:13.5rem; overflow:auto; padding:.625rem .75rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font:.6875rem/1.55 var(--mv-font-mono); color:var(--mv-fg-muted); white-space:pre }
    #at-demo .at-json b { color:var(--mv-fg); font-weight:500 }
    #at-demo .at-log { min-height:1.1rem; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
    #at-demo .at-log[data-veto] { color:var(--mv-danger) }

    /* Second scenario: a sheet of stickers laid out for print. */
    #at-demo #at-sheet { --mv-attache-ratio:1; --mv-attache-board:var(--mv-surface) }
    #at-demo .st { display:grid; place-items:center; padding:.25rem; text-align:center; font-size:.6875rem; font-weight:600; line-height:1.2; border-width:1.5px }
    #at-demo .st small { display:block; font-weight:400; color:var(--mv-fg-muted) }
    #at-demo .st[data-shape="round"] { border-radius:50% }
    #at-demo .st-a { background:color-mix(in oklab, var(--mv-accent) 12%, var(--mv-surface-raised)) }
    #at-demo .st-b { background:color-mix(in oklab, var(--mv-success) 12%, var(--mv-surface-raised)) }
    #at-demo .st-c { background:color-mix(in oklab, var(--mv-warning) 14%, var(--mv-surface-raised)) }
    #at-demo .at-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    @media (max-width:48rem) { #at-demo .at-lower { grid-template-columns:minmax(0,1fr) } }
    /* Tiny tiles (phones): keep the title only, then the key figure. */
    #at-demo .dw { container-type:size }
    @media (max-width:40rem) {
      @container (max-width:5.5rem) { #at-demo .dw-d, #at-demo .dw-list, #at-demo .dw-rows, #at-demo .dw-bars, #at-demo .dw-k svg, #at-demo .dw-k:has(+ .dw-v) { display:none } #at-demo .dw-v { font-size:.6875rem; letter-spacing:-.03em } }
      @container (max-height:3.25rem) { #at-demo .dw-d, #at-demo .dw-prog, #at-demo .dw-v small { display:none } #at-demo .dw-v { font-size:.875rem } }
    }
    @media (max-width:40rem) { #at-demo .dw { padding:.3125rem .4375rem; font-size:.6875rem } }
    @media (max-width:40rem) { #at-demo #at-board { --mv-attache-ratio:1.25; --mv-attache-gap:4px; --mv-attache-padding:4px } }
  </style>

  <div class="at-head">
    <div>
      <h3>Home dashboard</h3>
      <p>Tarnwick Labs · drag widgets, press R or right-click mid-drag to rotate</p>
    </div>
    <div class="at-controls">
      <label class="mv-choice" data-control="end">
        <input type="checkbox" role="switch" class="mv-switch" id="at-edit" checked>
        <span class="mv-choice-text"><span class="mv-choice-title">Edit layout</span></span>
      </label>
      <label class="mv-choice" data-control="end">
        <input type="checkbox" role="switch" class="mv-switch" id="at-rotate" checked>
        <span class="mv-choice-text"><span class="mv-choice-title">Rotation</span></span>
      </label>
      <button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="at-reset">Reset</button>
    </div>
  </div>

  <mv-attache id="at-board" cols="8" rows="4" label="Dashboard layout">
    <div class="dw" data-id="revenue" data-w="4" data-h="2" data-x="0" data-y="0" data-rotatable="false">
      <span class="dw-k"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg><strong>Revenue</strong></span>
      <span class="dw-v">$482,190</span>
      <span class="dw-d"><span class="dw-up">+12.4%</span> vs. August</span>
      <span class="dw-bars dw-extra" aria-hidden="true"><i style="--v:38%"></i><i style="--v:52%"></i><i style="--v:44%"></i><i style="--v:61%"></i><i style="--v:57%"></i><i style="--v:70%"></i><i style="--v:64%"></i><i style="--v:78%"></i><i style="--v:73%"></i><i style="--v:86%"></i><i style="--v:81%"></i><i style="--v:96%"></i></span>
    </div>
    <div class="dw" data-id="traffic" data-w="2" data-h="2" data-x="4" data-y="0">
      <span class="dw-k"><strong>Traffic by region</strong></span>
      <div class="dw-rows">
        <div class="dw-row"><span>North America</span><span>42%</span><b style="--v:42%"></b></div>
        <div class="dw-row"><span>Europe</span><span>31%</span><b style="--v:31%"></b></div>
        <div class="dw-row dw-extra"><span>Asia Pacific</span><span>19%</span><b style="--v:19%"></b></div>
      </div>
    </div>
    <div class="dw" data-id="goals" data-w="2" data-h="1" data-x="6" data-y="0" data-locked data-label="Q3 goals">
      <span class="dw-k"><strong>Q3 goals</strong> · pinned by admin</span>
      <span class="dw-d">3 of 5 on track</span>
      <span class="dw-prog" aria-hidden="true"></span>
    </div>
    <div class="dw" data-id="conversion" data-w="2" data-h="1" data-x="6" data-y="1">
      <span class="dw-k"><strong>Conversion</strong></span>
      <span class="dw-v">3.8% <small class="dw-up" style="font-size:.6875rem;font-weight:500">+0.4 pt</small></span>
    </div>
    <div class="dw" data-id="users" data-w="2" data-h="1" data-x="1" data-y="2">
      <span class="dw-k"><strong>Active users</strong></span>
      <span class="dw-v">18,204</span>
    </div>
    <div class="dw" data-id="support" data-w="2" data-h="2" data-x="4" data-y="2">
      <span class="dw-k"><strong>Support queue</strong></span>
      <span class="dw-v">14 open</span>
      <ul class="dw-list dw-extra">
        <li><span>Refund for #48213</span><span>2h</span></li>
        <li><span>SSO login loop</span><span>5h</span></li>
      </ul>
    </div>
    <div class="dw" data-id="uptime" data-w="1" data-h="1" data-x="6" data-y="2">
      <span class="dw-k"><strong>Uptime</strong></span>
      <span class="dw-v">99.98%</span>
    </div>
    <div class="dw" data-id="orders" data-w="2" data-h="1" data-x="1" data-y="3">
      <span class="dw-k"><strong>Orders today</strong></span>
      <span class="dw-v">1,284</span>
    </div>
    <div class="dw" data-id="nps" data-w="1" data-h="1" data-x="7" data-y="3">
      <span class="dw-k"><strong>NPS</strong></span>
      <span class="dw-v">62</span>
    </div>
    <div class="dw" data-id="deploys" data-w="1" data-h="3">
      <span class="dw-k"><strong>Deploys</strong></span>
      <ul class="dw-list">
        <li><span>api 2.14</span><span>09:12</span></li>
        <li><span>web 8.3</span><span>Sep 22</span></li>
        <li><span>jobs 1.9</span><span>Sep 21</span></li>
      </ul>
    </div>
    <div class="dw" data-id="oncall" data-w="2" data-h="1">
      <span class="dw-k"><strong>On call</strong></span>
      <span class="dw-d">Priya Raman · until 6 PM</span>
    </div>
  </mv-attache>

  <div class="at-lower">
    <section class="at-panel" aria-label="Layout data">
      <h4>layout <span class="mv-badge" data-variant="secondary" data-shape="pill" id="at-count">11 items</span></h4>
      <pre class="at-json" id="at-json"></pre>
      <div class="at-log" id="at-log" aria-live="polite">Revenue is kept in the top row by an mv-drop handler.</div>
    </section>
    <section class="at-panel" aria-label="Sticker sheet">
      <h4>Sticker sheet <span class="mv-badge" data-variant="secondary" data-shape="pill">6 × 4 · print</span></h4>
      <p>Same component, square cells: lay out a print run, rotate labels to squeeze in one more.</p>
      <mv-attache id="at-sheet" cols="6" rows="4" label="Sticker sheet">
        <div class="st st-a" data-id="logo-1" data-w="2" data-h="2" data-x="0" data-y="0" data-shape="round" data-rotatable="false">Tarnwick<small>logo, 2 in</small></div>
        <div class="st st-b" data-id="ship-1" data-w="3" data-h="2" data-x="2" data-y="0">Ship to<small>address label</small></div>
        <div class="st st-c" data-id="fragile" data-w="1" data-h="2" data-x="5" data-y="0">Fragile</div>
        <div class="st" data-id="qr-1" data-w="1" data-h="1" data-x="0" data-y="2">QR</div>
        <div class="st" data-id="qr-2" data-w="1" data-h="1" data-x="1" data-y="2">QR</div>
        <div class="st st-b" data-id="thanks" data-w="3" data-h="1" data-x="2" data-y="2">Thank you!</div>
        <div class="st st-a" data-id="logo-2" data-w="2" data-h="2" data-shape="round" data-rotatable="false">Tarnwick<small>logo, 2 in</small></div>
        <div class="st st-c" data-id="this-side" data-w="2" data-h="1">This side up</div>
      </mv-attache>
    </section>
  </div>
  <p class="at-hint">Keyboard: Tab to a widget, Space to pick up, arrows to move, R to rotate, Space to drop, Escape to cancel, Delete to set aside</p>

  <script type="module">
    const board = document.getElementById("at-board");
    await customElements.whenDefined("mv-attache");
    const $ = (id) => document.getElementById(id);
    const initial = board.layout;
    const log = $("at-log");
    const names = { revenue: "Revenue", traffic: "Traffic by region", goals: "Q3 goals", conversion: "Conversion", users: "Active users", support: "Support queue", uptime: "Uptime", orders: "Orders today", nps: "NPS", deploys: "Deploys", oncall: "On call" };

    const paint = () => {
      const pre = $("at-json");
      pre.replaceChildren();
      for (const e of board.layout) {
        const b = document.createElement("b");
        b.textContent = `"${e.id}"`.padEnd(13);
        const pos = e.x === null ? "tray        " : `x ${e.x}, y ${e.y}`.padEnd(12);
        pre.append("{ id ", b, ` ${pos} ${e.w}×${e.h}${e.rotated ? " rotated" : ""}${e.locked ? " locked" : ""} }\n`);
      }
      const n = board.overflow.length;
      $("at-count").textContent = n ? `${n} in tray` : "all placed";
      $("at-count").dataset.variant = n ? "warning" : "success";
    };
    paint();

    board.addEventListener("mv-drop", (e) => {
      const { id, to } = e.detail;
      // App rule: the revenue chart stays in the top row.
      if (id === "revenue" && (to.tray || to.y !== 0)) {
        e.preventDefault();
        log.textContent = "mv-drop vetoed: Revenue stays in the top row.";
        log.dataset.veto = "";
        return;
      }
      delete log.dataset.veto;
      log.textContent = to.tray ? `mv-drop · ${names[id]} → tray` : `mv-drop · ${names[id]} → x ${to.x}, y ${to.y}${to.rotated ? " (rotated)" : ""}`;
    });
    board.addEventListener("mv-layout-change", (e) => {
      paint();
      if (e.detail.reason === "arrange") {
        delete log.dataset.veto;
        log.textContent = `mv-layout-change · arrange · ${e.detail.overflow.length ? `${e.detail.overflow.length} don't fit` : "everything fits"}`;
      }
    });
    $("at-edit").addEventListener("change", (e) => { board.disabled = !e.target.checked; });
    $("at-rotate").addEventListener("change", (e) => { board.noRotate = !e.target.checked; });
    $("at-reset").addEventListener("click", () => {
      board.layout = initial;
      paint();
      delete log.dataset.veto;
      log.textContent = "Layout reset.";
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
colsnumber (1-64)8Number of columns. Changing it (or rows) keeps every item that still fits, moves the others to the first free spot and sends the rest to the tray (mv-layout-change with reason "resize").
rowsnumber (1-64)4Number of rows. The grid never grows: capacity is the point.
labelstringLayoutAccessible name of the grid (“Dashboard layout, 8 columns by 4 rows”).
no-rotatebooleanDisables rotation everywhere (dragging, keyboard, auto-arrange, room check in the tray).
disabledbooleanView mode: the layout is shown as is, nothing can be picked up, items leave the tab order and the empty cells fade out.
bar"true" | "false"true"false" hides the status bar (usage meter and the built-in Auto-arrange button); call autoArrange() from your own control.
data-w / data-hnumber (cells)1On an item: its current footprint. Kept up to date by the component (swapped on rotation), so [data-w="1"] or [data-attache-orient="portrait"] can adapt its content. Changing them from the app re-validates the item.
data-x / data-ynumber (0-based)On an item: initial position. Items without one, or whose spot is taken, go to the first free spot without rotation, else to the tray. Reflected while the item is on the grid, removed while it is in the tray.
data-idstringOn an item: id used in layout and events (else the element id, else item-N).
data-labelstringOn an item: name used in announcements (else the first heading, strong or [data-attache-title], else its text).
data-lockedbooleanOn an item: cannot be dragged, set aside or moved by Auto-arrange (the others are packed around it). Shows a padlock.
data-rotatable"false"On an item: never rotated (a chart that must stay landscape, a round sticker).
data-rotatedbooleanOn an item: set by the component when its footprint is swapped relative to its natural orientation.
data-attache-handlemarkerOn an element inside an item: only this handle starts a drag. Without one, the whole item drags except links, buttons, fields and [data-attache-nodrag].
data-attache-orient / data-attache-state / data-attache-roomset by the componentItems get landscape | portrait | square, picked (pointer drag origin) | grabbed (keyboard), and yes | no in the tray. The host gets data-dragging while something is being placed and data-overflow while an item has no room.

Properties

Name유형Description
layoutArray<{ id, x, y, w, h, rotated, locked }>Current layout in reading order, tray items last with x and y null. Set it to restore a saved layout: listed items go where you say if it is free, unlisted ones keep their spot or take the first free one, the rest go to the tray. Setting it does not emit mv-layout-change. Can be set before the element is defined.
overflowstring[]Ids of the items waiting in the tray (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (grid, help, item, itemTray, arrange, usage, trayAll, trayAllOne, traySome, trayDrop, fits, overlaps, out, picked, moved, rotated, dropped, returned, vetoed, cancelled, setAside, arranged…). English defaults, {placeholders} filled in.
cols / rows / label / noRotate / disabled / barreflectedMirror the attributes.

Methods

NameDescription
autoArrange({ rotate? })Packs every unlocked item (rotation follows no-rotate unless given). Items glide to their new spot. Returns { placed, overflow }. Emits mv-layout-change with reason "arrange".
fits(item, x, y, { rotated? })Would this item (element or id) fit there? Returns { fits, out, blockers: [id] }.
moveItem(item, x, y, { rotated? })Moves an item from code (no mv-drop). Returns false if it does not fit or is locked.
setAside(item)Sends an item to the tray (no mv-drop). Returns false if it is locked or already there.
refresh()Re-reads the items. Rarely needed: added and removed children and changes of data-w, data-h, data-locked, data-label or data-rotatable are picked up automatically.
packLayout(items, { cols, rows, rotate }) (named export)The packer as a pure function, without DOM: items [{ id, w, h, x?, y?, locked?, rotatable?, rotated? }] → { layout, overflow }. Deterministic, so it can run on the server or in a worker.

Events

NameDescription
mv-dropCancelable, before a user placement is committed (pointer, keyboard or double click in the tray). detail: { id, item, from, to, source: "pointer" | "keyboard", layout (the layout if accepted) }; from and to are { tray, x, y, w, h, rotated }. preventDefault() refuses it: the item goes back and “Move refused” is announced.
mv-layout-changeAfter every committed change. detail: { layout, overflow, reason: "drop" | "tray" | "arrange" | "api" | "resize" | "dom" }. Save the layout here.

Content structure

NameDescription
(content)The items, as direct children with data-w / data-h. They are moved into the grid (.mv-attache-board) or the tray, and sorted in reading order; render them once and drive later changes through layout or DOM additions and removals. The floating copy used while dragging is a clone appended to the element, so style items with a class, not through .mv-attache-board.

CSS classes

NameDescription
mv-attache-itemAdded to every item: a zero-specificity tile (border, radius, raised surface) you can restyle freely. .mv-attache-proxy is the floating clone while dragging.
mv-attache-board / -cells / -cellThe grid and its decorative empty cells (aria-hidden).
mv-attache-ghostFootprint preview (aria-hidden): .mv-attache-ghost-cell[data-cell="fit | blocked | out"] and .mv-attache-ghost-tag (icon, verdict, position). data-state="fit | blocked | out".
mv-attache-barStatus bar: .mv-attache-meter / -meter-fill, .mv-attache-usage and the .mv-attache-arrange button (.mv-button).
mv-attache-trayOverflow tray: .mv-attache-tray-head (-icon, -text), .mv-attache-tray-items, .mv-attache-tray-empty. data-state="empty | aside | overflow", data-over while a drag hovers it.

CSS variables

NameDefaultDescription
--mv-attache-ratio1Row height as a fraction of the column width (0.6 for dashboard tiles, 1 for square cells).
--mv-attache-gapvar(--mv-space-2)Gap between cells.
--mv-attache-paddingvar(--mv-space-2)Inner padding of the grid and the tray.
--mv-attache-fitvar(--mv-success)Color of cells where the item fits.
--mv-attache-blockedvar(--mv-danger)Color of hatched cells (overlap, out of bounds) and of items in the way.
--mv-attache-boardvar(--mv-bg-subtle)Grid background.
--mv-attache-metervar(--mv-accent)Usage meter fill.

Accessibility

The grid is a labelled group (“Dashboard layout, 8 columns by 4 rows”), not role="grid" or "application": items span several cells, so cell-by-cell grid navigation would misrepresent them, and application mode would take browse mode away from the widgets' own content. Instead every item is a focusable group with aria-roledescription “movable tile” whose name states its size and position (“Revenue chart, 2 by 2, at column 3 row 1”, “…, set aside, room available”, “…, locked”), described by the instructions; items are kept in DOM reading order (rows, then columns) after every change so Tab follows the visual order, and focus is preserved when they move (moveBefore where supported). Keyboard: Space or Enter picks up (a set-aside item starts at the first spot where it fits), arrow keys move the footprint one cell (Shift: by the item's own size), R rotates, Space or Enter drops, Escape or Tab cancels, Delete or Backspace sets the item aside; each step is announced assertively (“Column 4, row 2. Overlaps Map.”), and a blocked drop is refused with its reason while the item stays picked up. Pointer drops, refusals, vetoes and Auto-arrange results (“Arranged 11 items. Everything fits.”) are announced politely. The footprint verdict never relies on color: blocked and out-of-bounds cells are hatched and outlined dashed, the tag names the verdict and the blocking items, and items in the way are outlined; the tray's “Fits / No room” chips are words too. Links, buttons and fields inside an item keep working and never start a drag. Touch: drag with one finger, tap with a second to rotate. Reduced motion (OS or data-motion="reduce"): no glide, landing, fly-back, settle ring or rotation wiggle; positions change instantly. Forced colors: fit cells are outlined in Highlight, blocked ones dashed in CanvasText, icons use CanvasText.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고