익스클루시브

Whereabouts <mv-whereabouts>

바늘이 시간 대신 사람들이 하고 있는 일을 가리키는 시계 모양의 팀 상태 보드입니다.

문화적 레퍼런스

해리 포터와 비밀의 방, J. K. 롤링 (1998, 도서). 부엌에 있는 가족 시계에는 가족 한 명당 바늘이 하나씩 있고, 바늘은 시간 대신 각자 있는 곳을 가리킵니다. 집, 직장, 여행 중, 생명의 위협. UI는 이를 팀 상태 보드로 바꿉니다. 각 팀원은 상태(Focus, In a meeting, Away…)를 가리키는 바늘이며, 같은 상태를 공유하면 부채꼴로 펼쳐지고 상태가 바뀌면 휙 넘어갑니다.

작동 방식

시간 대신 사람들이 무엇을 하고 있는지 바늘이 가리키는, 문자판 모양의 팀 상태 보드입니다. 상태는 테두리를 따라 레이블이 붙은 부채꼴 영역으로 배치되고(아이콘, 레이블, 인원수: Available, Focus, In a meeting, On call, Commuting, Away, Out of office, 그리고 앱이 정의한 모든 상태), 각 사람은 아바타나 이니셜 원판으로 끝나는 가느다란 바늘입니다. 같은 상태를 가리키는 바늘은 부채꼴로 펼쳐지다가 더 짧은 줄로 쌓이므로 원판이 절대 겹치지 않으며, 누군가 상태를 바꾸면 바늘이 스프링처럼 휙 넘어갑니다(동작 줄이기 설정에서는 바로 이동합니다). 바늘에 호버하거나 포커스하거나 클릭하면 누구인지, 상태, 언제부터 언제까지인지(“Until 3:30 PM · in 40 min”), 다음 일정, 그리고 다른 시간대에 있는 팀원의 현지 시각이 표시됩니다. set(id, state, { until, then })은 사람을 이동시키고 자동 복귀를 예약하며(회의가 끝나면 Available로 돌아갑니다), mv-state-change는 취소할 수 있고, 부채꼴 영역을 클릭하면 보드가 필터링됩니다. 컨테이너가 좁거나 한 영역이 문자판에 담기에 너무 붐비면 같은 데이터가 자동으로 그룹화된 목록으로 전환되며, 사람과 상태는 속성 또는 JavaScript 없이도 읽을 수 있는 일반 자식 마크업에서 가져옵니다.

카테고리데이터 표시
유형Web Component (<mv-whereabouts>)
상태안정
Keywordsexclusive, culture, presence, status, team, availability, who-is-around, dial, radial, avatars, time-zones, remote, on-call, dashboard, filter, spring, roving-tabindex, live-region

When to use

  • A team page or dashboard should show at a glance who is available, heads-down, in a meeting or away right now
  • A distributed team needs each person's status together with when it ends and their local time
  • An ops or support rota must make on-call, off-shift and available people obvious in one small panel
  • Presence updates arrive live and the board should show who moved without a jarring re-sort

Avoid when

  • Only one person's presence is shown, next to their name or picture → use Avatar instead
  • The roster is long (dozens of people) and must be sorted, searched and paginated → use Data table instead
  • Items move through workflow stages that people drag between, rather than showing live presence → use Kanban instead

설치

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

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

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

사용법

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

<div id="wa-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #wa-demo { display:grid; grid-template-columns:minmax(0,1fr) 18rem; gap:1rem; align-items:start }
    #wa-demo .wa-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #wa-demo .wa-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1rem .75rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #wa-demo .wa-title { display:grid; gap:.125rem; min-width:0 }
    #wa-demo .wa-title strong { font-size:.9375rem; letter-spacing:-.01em }
    #wa-demo .wa-title span { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo .wa-body { padding:1.25rem 1rem 1rem }
    #wa-demo .wa-foot { display:flex; align-items:center; justify-content:space-between; gap:.625rem 1rem; flex-wrap:wrap; padding:.75rem 1rem .75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
    #wa-demo .wa-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
    #wa-demo .wa-live { display:inline-flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem; cursor:pointer }
    #wa-demo .wa-log { min-height:1.1rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1 1 14rem; min-width:0 }
    #wa-demo .wa-side .wa-body { padding:.5rem .5rem .75rem }
    #wa-demo .wa-side .wa-head { padding:.75rem .875rem .625rem }
    @media (max-width:52rem) { #wa-demo { grid-template-columns:minmax(0,1fr) } }
  </style>

  <!-- Main board: the dial -->
  <section class="wa-card" aria-labelledby="wa-main-title">
    <header class="wa-head">
      <span class="wa-title">
        <strong id="wa-main-title">Platform team</strong>
        <span>12 people · 9 time zones · click a state to filter</span>
      </span>
      <mv-segmented id="wa-layout" value="auto" aria-label="Layout" data-size="sm">
        <button value="auto">Auto</button>
        <button value="dial">Dial</button>
        <button value="list">List</button>
      </mv-segmented>
    </header>
    <div class="wa-body">
      <mv-whereabouts id="wa-main" label="Platform team whereabouts"></mv-whereabouts>
    </div>
    <footer class="wa-foot">
      <span class="wa-actions">
        <button type="button" class="mv-button" data-size="sm" id="wa-standup">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><path d="M15.5 5.8a3 3 0 0 1 0 5.4"/><path d="M17.5 14.2a5.5 5.5 0 0 1 3 5.3"/></svg>
          Start standup
        </button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wa-shuffle">Simulate an update</button>
        <label class="wa-live"><input type="checkbox" role="switch" class="mv-switch" id="wa-auto"> Live updates</label>
      </span>
      <p class="wa-log" id="wa-log" aria-hidden="true">Ben’s 1:1 ends in a few seconds…</p>
    </footer>
  </section>

  <!-- Same data in a narrow sidebar: switches to the list layout by itself -->
  <aside class="wa-card wa-side" aria-labelledby="wa-side-title">
    <header class="wa-head">
      <span class="wa-title">
        <strong id="wa-side-title">Who’s around</strong>
        <span>Same data, sidebar width</span>
      </span>
    </header>
    <div class="wa-body">
      <mv-whereabouts id="wa-side" label="Who’s around" quiet></mv-whereabouts>
    </div>
  </aside>

  <script type="module">
    const main = document.getElementById("wa-main");
    const side = document.getElementById("wa-side");
    const log = document.getElementById("wa-log");
    const boards = [main, side];
    const now = Date.now();
    const min = 60_000;
    const at = (m) => new Date(now + m * min);
    const tomorrowAt = (h, m = 0) => { const d = new Date(now + 864e5); d.setHours(h, m, 0, 0); return d; };

    const states = [
      ...main.constructor.defaultStates,
      { id: "pairing", label: "Pairing", icon: "link", tone: "light-dark(oklch(0.56 0.16 330), oklch(0.74 0.14 330))" },
    ];
    const people = [
      { id: "ben", name: "Ben Carter", state: "meeting", since: at(-26), until: new Date(now + 9_000), then: "available", note: "Customer call", timeZone: "America/New_York" },
      { id: "priya", name: "Priya Raman", state: "focus", since: at(-35), until: at(55), timeZone: "Asia/Kolkata" },
      { id: "mateo", name: "Mateo Rossi", state: "meeting", since: at(-20), until: at(25), note: "Sprint planning", timeZone: "Europe/Rome" },
      { id: "aiko", name: "Aiko Tanaka", state: "ooo", since: at(-60 * 26), until: tomorrowAt(9), then: "available", note: "Back tomorrow", timeZone: "Asia/Tokyo" },
      { id: "lucas", name: "Lucas Oliveira", state: "available", since: at(-12), timeZone: "America/Sao_Paulo" },
      { id: "amara", name: "Amara Okafor", state: "available", since: at(-48), timeZone: "Africa/Lagos" },
      { id: "jonas", name: "Jonas Weber", state: "commuting", since: at(-14), until: at(20), then: "available", timeZone: "Europe/Berlin" },
      { id: "sofia", name: "Sofía Hernández", state: "on-call", since: at(-180), until: tomorrowAt(9), note: "Primary for payments", timeZone: "America/Mexico_City" },
      { id: "noah", name: "Noah Kim", state: "pairing", since: at(-30), until: at(30), note: "With Elena on the billing migration", timeZone: "America/Los_Angeles" },
      { id: "elena", name: "Elena Petrova", state: "pairing", since: at(-30), until: at(30), note: "With Noah on the billing migration", timeZone: "Europe/Sofia" },
      { id: "omar", name: "Omar Haddad", state: "away", since: at(-9), until: at(15), then: "available", note: "Lunch", timeZone: "Asia/Dubai" },
      { id: "chloe", name: "Chloé Martin", state: "focus", since: at(-70), until: at(40), timeZone: "Europe/Paris" },
    ];
    for (const b of boards) { b.states = states; b.people = people; }

    const time = (d) => d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
    const apply = (id, state, opts) => boards.every((b) => b.set(id, state, opts));

    main.addEventListener("mv-state-change", (e) => {
      const { person, to, until, source } = e.detail;
      const label = states.find((s) => s.id === to)?.label ?? to;
      log.textContent = `${time(new Date())} · ${person.name} → ${label}${until ? ` until ${time(until)}` : ""}${source === "schedule" ? " (scheduled)" : ""}`;
    });
    main.addEventListener("mv-filter", (e) => {
      log.textContent = e.detail.state ? `Filter: ${states.find((s) => s.id === e.detail.state)?.label}` : "Filter cleared";
    });

    document.getElementById("wa-layout").addEventListener("mv-change", (e) => { main.layout = e.detail.value; });

    document.getElementById("wa-standup").addEventListener("click", () => {
      const until = new Date(Date.now() + 15 * min);
      for (const p of main.people) {
        if (["available", "focus"].includes(p.state)) apply(p.id, "meeting", { until, then: p.state, note: "Daily standup" });
      }
    });

    const moves = [
      ["focus", 45, "available"], ["meeting", 30, "available"], ["available"], ["away", 10, "available"],
      ["commuting", 25, "available"], ["on-call", 240], ["pairing", 60, "focus"],
    ];
    const shuffle = () => {
      const list = main.people.filter((p) => p.state !== "ooo");
      const p = list[Math.floor(Math.random() * list.length)];
      const options = moves.filter(([s]) => s !== p.state);
      const [state, mins, then] = options[Math.floor(Math.random() * options.length)];
      apply(p.id, state, mins ? { until: new Date(Date.now() + mins * min), then } : {});
    };
    document.getElementById("wa-shuffle").addEventListener("click", shuffle);

    let timer = 0;
    document.getElementById("wa-auto").addEventListener("change", (e) => {
      clearInterval(timer);
      if (e.target.checked) { shuffle(); timer = setInterval(() => { if (!main.isConnected) clearInterval(timer); else shuffle(); }, 4000); }
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
layoutauto | dial | listautoauto shows the dial unless the component is narrower than breakpoint or a sector cannot hold its people without overlap (even with smaller discs), then the grouped list. The effective layout is exposed as data-layout on the element.
breakpointnumber (px)360Container width under which auto switches to the list (measured with a ResizeObserver, so it follows the container, not the viewport).
sizenumber (px)440Maximum dial diameter. The dial shrinks with its container; disc size follows (26 to 34 px) and drops further when a sector is crowded.
filterstate idOnly people in this state stay active: other hands fade and leave the tab order (inert), other list groups are hidden, and a “Showing 2 · Focus / Show everyone” bar appears. Set by clicking a sector or a list group heading; reflected.
labelstringTeam whereaboutsAccessible name of the dial and of the list.
sectorscomma-separated state idsWhich states get a sector and in what order, clockwise from the top (e.g. "available, focus, meeting, away"). A person in a state that is not listed still gets a sector: nobody is ever dropped.
time-zoneIANA time zonethe browser'sZone used to display since / until times and to decide who is remote (a teammate is remote when their current UTC offset differs from this one).
localeBCP 47 tagen-USLocale for times and dates ("3:30 PM", "tomorrow 9:00 AM", "Mon, Sep 28").
quietbooleanNo live announcements (use it on a second board that mirrors the same data, so changes are not spoken twice).
data-person / data-state / data-since / data-until / data-then / data-note / data-avatar / data-time-zone / data-name / data-initialschild markupAny descendant with data-person="id" is a person (its text is the name unless data-name is set). Read on connection and re-read when the markup changes; ignored once the people property is set. set() writes the new state back to these attributes.
data-state-id / data-icon / data-tonechild markupAny descendant with data-state-id adds a state to the built-in ones (or overrides one with the same id); its text is the label.

Properties

Name유형Description
peopleArray<{ id, name, state, since?, until?, then?, note?, avatar?, timeZone?, initials? }>The people on the board. since / until accept a Date, an ISO string or epoch ms; then is the state applied automatically at until; avatar is an image URL (initials are shown underneath and whenever the image fails); timeZone is an IANA zone. Assigning a new array diffs it with the current one: people whose state changed swing to their new sector and emit mv-state-change with source "data". Reading it returns copies reflecting every change made with set().
statesArray<{ id, label, icon?, tone? }>Replaces the built-in states. icon: available, focus, meeting, on-call, commuting, away, ooo, home, moon, pin, link, headphones, phone, flask, star, globe, dot, or raw SVG path data on a 24×24 grid. tone: success, accent, danger, warning, info, neutral, or any CSS color (light-dark() works). Start from MvWhereabouts.defaultStates to add your own.
MvWhereabouts.defaultStatesstatic, read-onlyCopies of the built-in states, to spread before app-defined ones.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (label, people, filterGroup, person, persons, showing ({count}, {state}), nobody, showEveryone, since, until, then, sinceLong, untilLong, thenLong ({time}, {duration}, {state}), local, ahead, behind, localSpoken ({time}, {city}, {diff}), tomorrow, summary, announceMany, more). English defaults.
layout / breakpoint / size / filter / label / sectors / timeZone / locale / quietreflectedMirror the attributes.

Methods

NameDescription
set(personId, state, { until?, then?, since?, note? })Moves someone to a state. Emits the cancelable mv-state-change first; returns false if it was canceled or the person is unknown. since defaults to now when the state changes; note is cleared on a state change unless given; then is applied automatically when until passes (source "schedule").
person(id)Returns a copy of one person, or null.
focusPerson(id)Moves keyboard focus to that person's hand (or list row). Returns false if they are unknown or faded out by the filter.

Events

NameDescription
mv-state-changeSomeone's state (or end time) changes. detail: { id, person (copy, before the change), from, to, since, until, then, source: "api" | "schedule" | "data" }. Cancelable for api and schedule: preventDefault() keeps the current state (a vetoed schedule is dropped, not retried). data changes are reported after the fact.
mv-person-focusA hand or list row received focus. detail: { id, person, layout }.
mv-person-activateA hand or list row was clicked or activated with Enter / Space (open a profile, start a chat…). On the dial this also pins the detail card. detail: { id, person, layout }.
mv-filterThe user changed the filter from a sector, a list heading, the Show everyone button or Escape. detail: { state (null when cleared), previous, source: "sector" | "list" | "keyboard" | "button" }.

Content structure

NameDescription
(content)Optional source markup: elements with data-person (people) and data-state-id (extra states), e.g. a <ul> of names. It is the no-JavaScript fallback and is hidden once the component is ready; the board is rendered after it.

CSS classes

NameDescription
mv-whereabouts-dialThe dial (role=group): .mv-whereabouts-ring (SVG: -bezel, -face, -guides, one .mv-whereabouts-sector per state with -wedge, -band, -label, -sector-icon, -count), .mv-whereabouts-stems (-stem), .mv-whereabouts-pivot, .mv-whereabouts-hands (-hand buttons with an .mv-whereabouts-avatar), .mv-whereabouts-card. data-filtered and data-spotlight are set on the dial; data-active, data-dim and data-state on hands.
mv-whereabouts-cardDetail card: -card-head, -card-avatar, -card-name, -card-state, -card-line, -card-note, -card-local, -card-rel. data-open, data-pinned, data-side (right | left | top | bottom: placed outward along the hand first, kept inside the component and the viewport).
mv-whereabouts-listGrouped list: .mv-whereabouts-group (per state) with -group-head (filter button: -group-icon, -group-name, -group-count), -items and .mv-whereabouts-row buttons (-avatar, -row-name, -row-detail, -row-local with -row-time, -row-city; data-night after 10 PM and before 7 AM local).
mv-whereabouts-filterbarShown while filtered: -filterbar-text and the .mv-whereabouts-clear button.

CSS variables

Name유형DefaultDescription
--mv-whereabouts-success / -accent / -danger / -warning / -info / -neutralthe matching --mv-* token (neutral: --mv-fg-subtle)Color of each named tone, used by sectors, disc rings, stems and list icons.
--_toneoutputSet on every sector, hand, stem, row and group: the state's resolved color, usable in your own styles.

Accessibility

The dial is a labelled group preceded by a complete text equivalent, a visually hidden list read in browse mode (“12 people on the board. Available: Lucas Oliveira, Amara Okafor. In a meeting: Mateo Rossi (until 3:30 PM)…”). Every hand is a real <button> whose accessible name is a full sentence (“Mateo Rossi. In a meeting, until 3:30 PM, since 2:45 PM. Sprint planning. Local time 9:50 PM in Rome.”); hands share one Tab stop (roving tabindex) in clockwise order: arrow keys move to the next or previous person, Home / End to the first or last, Page Down / Page Up to the next or previous state, and typing a name jumps to it. The sectors are a second group of toggle buttons (role=button, aria-pressed, name “Focus, 2 people”) with their own Tab stop and arrow keys; Enter or Space filters, Escape clears the filter or unpins the card. Filtered-out hands are made inert, and focus moves to the first remaining person if it was on one of them. The detail card is aria-hidden because it repeats the accessible name. Changes are announced in a polite live region, batched and throttled to one message every 3 seconds (“Mateo Rossi: In a meeting, until 3:30 PM.” or “3 status updates. …”), never on first render, and not at all with quiet. States are never conveyed by color alone: each sector has an icon, a label and a count, the list groups repeat them, and hands point into the labeled sector. The list layout is a labelled group of headings-as-filter-buttons (aria-pressed) and rows with the same full-sentence names, and focus is kept on the same person when the layout switches. Reduced motion (OS setting or data-motion="reduce"): hands jump to their new position with no swing, the entrance sweep and hover scaling are skipped. Forced colors: sectors, discs, stems and focus rings use system colors, and the active filter uses Highlight. Known limits: statuses and times come from the people you pass (the component detects nothing itself); layout="auto" switches to the list when the dial is too narrow or cannot fit everyone without overlap; set layout="dial" only for teams small enough to fit it.

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