Duty Slot
betaOne volunteer duty on one day, as a row in a card: its icon and name, what it involves, who is on it, and where it stands, ending in the quickest fix.
Preview
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/duty-slot
Use it for
- One row per duty, in a Card: refreshments, setup, first aid, the front desk, photos.
- The screen keeps who is on what and passes state. Confirmed is quiet. Waiting for a reply ends in Remind, Needs cover in Find cover, and Nobody yet in Find someone.
- Pass suggest with the best person to ask and why. Accepting reads Asked at once, with Undo, and onAsk runs once the window ends. Move the duty to asked, with that person, when it lands.
- Pass out with who dropped out and what they said. It shows in the peek on Needs cover, so the row never repeats it.
- Pass date for a list across days, like a month of Mondays.
Avoid it for
- Your own undo timer for the ask, the reminder, or Withdraw. Each waits out its window in the shared result engine, so ⌘Z takes it back.
- Saying who dropped out in detail as well. The peek already holds it.
- Touch-only screens with no other way to swap or withdraw. A tap on the row shows its hover actions, but offer them on the duty's own page too.
Props
- duty
The duty, like "Refreshments" or "Setup".
string
- icon
The duty's icon, in ink. Default a clipboard.
ReactNode
- detail
What it involves, like "Chairs and screens before 9".
string
- date
A date column, for a list across days, like "Mon 5".
string
- person
Who is on it, or who was asked.
{ person: string; photo?: string } | null
- state
Where it stands. Default confirmed with a person, open without one.
"confirmed" | "asked" | "cover" | "open"
- out
Who dropped out and what they said, for the peek on Needs cover.
{ person: string; photo?: string; said?: string }
- suggest
The best person to ask, why, and the first message they get. Shows as a Suggestion while the duty needs someone.
{ person: string; photo?: string; reason?: string; message?: string }
- onAsk
Asks the suggested person once the undo window ends. A failed promise turns the chip into Retry.
(person: { person: string; photo?: string; message?: string }) => void | Promise<unknown>
- onFindCover
The fix for Needs cover and Nobody yet.
() => void
- onRemind
Reminds the person who has not replied, once the undo window ends.
() => void | Promise<unknown>
- onWithdraw
Withdraws the ask once its undo window ends. Tell the person there.
() => void | Promise<unknown>
- onSwap
A hover action on a confirmed duty.
() => void
- divider
A hairline above the row, inset from the card's edges.
boolean · default false
- index
Place in a list. Rows fade up one after another as they first show.
number
- className
Extra classes. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| duty | string | - | The duty, like "Refreshments" or "Setup". |
| icon | ReactNode | - | The duty's icon, in ink. Default a clipboard. |
| detail | string | - | What it involves, like "Chairs and screens before 9". |
| date | string | - | A date column, for a list across days, like "Mon 5". |
| person | { person: string; photo?: string } | null | - | Who is on it, or who was asked. |
| state | "confirmed" | "asked" | "cover" | "open" | - | Where it stands. Default confirmed with a person, open without one. |
| out | { person: string; photo?: string; said?: string } | - | Who dropped out and what they said, for the peek on Needs cover. |
| suggest | { person: string; photo?: string; reason?: string; message?: string } | - | The best person to ask, why, and the first message they get. Shows as a Suggestion while the duty needs someone. |
| onAsk | (person: { person: string; photo?: string; message?: string }) => void | Promise<unknown> | - | Asks the suggested person once the undo window ends. A failed promise turns the chip into Retry. |
| onFindCover | () => void | - | The fix for Needs cover and Nobody yet. |
| onRemind | () => void | Promise<unknown> | - | Reminds the person who has not replied, once the undo window ends. |
| onWithdraw | () => void | Promise<unknown> | - | Withdraws the ask once its undo window ends. Tell the person there. |
| onSwap | () => void | - | A hover action on a confirmed duty. |
| divider | boolean | false | A hairline above the row, inset from the card's edges. |
| index | number | - | Place in a list. Rows fade up one after another as they first show. |
| className | string | - | Extra classes. Later classes win. |
Accessibility
- The duty, its detail, who is on it, and the status are all text, in reading order.
- Each status is words in its tone's soft text, which the theme holds to 4.5:1. The tones share colors, so the words say which.
- Each fix is a native button named for what it does, like "Remind Tom Hale", with the same words in its tooltip.
- Needs cover is a peek trigger. Hover, focus, or a tap opens who dropped out, and Esc closes it.
- The hover actions stay in the Tab order, and focus inside them shows the bar.
- The ask, the reminder, and Withdraw each say what happened once, through the page's one polite live region.
Examples
Confirmed, waiting for a reply, and needs cover
<Card className="gap-0 py-1.5">
<DutySlot duty="Refreshments" icon={<Coffee />} detail="Morning break and lunch" person={{ person: "Sue Hale" }} onSwap={swap} />
<DutySlot
duty="Setup"
icon={<Monitor />}
detail="Chairs and screens before 9"
person={{ person: "Tom Hale" }}
state="asked"
onRemind={() => remind("setup")}
onWithdraw={() => withdraw("setup")}
divider
/>
<DutySlot
duty="First aid"
icon={<HeartPulse />}
state="cover"
out={{ person: "Mark Clarke", said: "Can't do it · Tue 29 Sep" }}
suggest={{ person: "Cal Nash", reason: "First aid trained, and free on Thursday" }}
onAsk={(who) => ask("first-aid", who)}
onFindCover={() => openCover("first-aid")}
divider
/>
</Card>