# Feature Voting

> A list of ideas, each with an upvote button and its count, or an up and down vote stacked around the count. It shows how this person voted.

- Name: `feature-voting`
- Status: beta
- Categories: forms, voting
- File: the install writes `feature-voting.tsx` to your `ui` alias, `@/components/ui` by default.
- Other registry items: `@arbokui/motion`, `@arbokui/button`, `@arbokui/badge`, `@arbokui/rolling-number`, `@arbokui/tooltip`. The install adds them.
- Gallery: https://arbokui.com/components/feature-voting
- Registry item: https://arbokui.com/r/feature-voting.json

## Install

1. Add the registry to `components.json`. Do this first, once:

   ```bash
   npx shadcn@latest registry add "@arbokui=https://arbokui.com/r/{name}.json"
   ```

   The command writes this entry. You can also add it by hand:

   ```json
   {
     "registries": {
       "@arbokui": "https://arbokui.com/r/{name}.json"
     }
   }
   ```

   Without this step, the shadcn CLI looks up `@arbokui` in a public list that Arbokui does not control.

2. Add the theme once per app. It replaces your color tokens with ones checked for contrast:

   ```bash
   npx shadcn@latest add @arbokui/theme
   ```

3. Add the component:

   ```bash
   npx shadcn@latest add @arbokui/feature-voting
   ```

## Use it for

- Letting people vote on a list of ideas, like feature requests or topics for a meeting.
- People can vote for as many ideas as they like. Pressing a button again takes the vote back.
- Pass layout="stack" to let people vote up or down, like a forum. An up caret, the count, and a down caret stack in a column at the start of each idea, and votes is then ups minus downs, so it can go below zero.
- In the stack layout, pressing the other caret moves the vote in one press. Each caret has a tooltip that says what it does, and the count rolls to its new value.
- Pass each idea with its votes so far. A status like Planned shows as a badge beside the title.
- The list keeps the order you pass. Sort it yourself, like by votes, when you load the data.
- Return a promise from onVote to save each vote. The button and its count change at once, with savingText after the title, until the promise settles. If it rejects, the vote goes back and errorText shows under the idea.
- Leave voted out to let the list keep its own state. It then counts the votes it takes, and defaultVoted marks earlier votes, with defaultDownvoted for down votes. People can press again while a vote saves, and the newest press on each idea wins, even when an older save finishes later.
- Pass voted, and downvoted in the stack layout, to keep the votes in your own state, and include this person's votes in the counts you pass. Once a save succeeds, and before your onVote promise resolves, update them and the counts. Your state then decides, so if saves can finish out of order, skip one that a newer press replaced.
- While a vote saves, its item has data-pending, for your own styles. A vote up sets data-voted, and a vote down data-downvoted.
- For another language, pass upvoteText, downvoteText, savingText, errorText, votesText, and locale.

## Avoid it for

- One question with a few fixed answers, where people pick one. Use Choice Poll instead.
- Rating one thing on a scale, like 1 to 5 stars.
- A single like button on a post. Use a Button with aria-pressed.
- Sorting the list again while someone votes. The idea would move away from their focus.

## Props

- `items`: `{ value: string; title: ReactNode; description?: ReactNode; votes: number; status?: { label: string; tone?: "neutral" | "brand" | "danger" | "success" | "warning" }; disabled?: boolean }[]`. The ideas, in the order to show them. value must be unique. votes is the count so far, this person's included. In the stack layout it is ups minus downs, and it can be below zero. disabled blocks voting on one idea, like one that has shipped.
- `layout`: `"inline" | "stack"`. Default `"inline"`. inline puts one upvote button with its count at the end of each idea. stack puts an up caret, the count, and a down caret in a column at the start, so people can vote up or down.
- `voted`: `string[]`. The values of the ideas this person voted up. Controlled, together with downvoted. The list shows the counts you pass as they are, plus a vote that is still saving.
- `downvoted`: `string[]`. The values of the ideas this person voted down, in the stack layout. Controlled, together with voted.
- `defaultVoted`: `string[]`. Earlier votes up by this person when you do not control the votes. They are already in the counts, so the list does not add them again.
- `defaultDownvoted`: `string[]`. Earlier votes down by this person when you do not control the votes, in the stack layout. They are already in the counts.
- `onVote`: `(value: string, voted: boolean, direction: "up" | "down") => void | Promise<unknown>`. Called when someone votes on an idea (true) or takes the vote back (false), with the direction pressed. Moving a vote from up to down is one call, with true and "down". Return a promise, and the list shows the change at once with savingText until it settles. A rejection puts the earlier vote back and shows errorText under the idea.
- `disabled`: `boolean`. Default `false`. Blocks every vote button. The counts still show.
- `tone`: `"neutral" | "brand" | "danger" | "success" | "warning"`. Default `"neutral"`. Color of the vote buttons. A vote fills its button with it. The rest are outlined, or plain carets in the stack layout.
- `upvoteText`: `string`. Default `"Upvote"`. The word that starts each upvote button's name for screen readers. In the stack layout, the up caret's tooltip shows it too.
- `downvoteText`: `string`. Default `"Downvote"`. The word that starts each down caret's name for screen readers, and its tooltip, in the stack layout.
- `savingText`: `string`. Default `"Saving…"`. Shown after an idea's title while its vote saves. The vote button's aria-describedby points to it.
- `errorText`: `string`. Default `"Could not save your vote. Try again."`. Shown under an idea when its vote does not save. Screen readers announce it.
- `votesText`: `{ zero?: string; one?: string; two?: string; few?: string; many?: string; other: string }`. Default `{ one: "{count} vote", other: "{count} votes" }`. How a count of votes reads in each plural form, with {count} for the number. Screen readers hear it in each button's name, and the button shows only the number. The list picks the form with Intl.PluralRules for locale.
- `locale`: `string`. Default `"en-US"`. A language tag for the counts and the plural forms, like 'de-DE'.
- `className`: `string`. Extra classes for the list. Later classes win, so className='divide-y-0' drops the lines between ideas.

## Accessibility

- The ideas are a list. Each item holds the title, the details, the status badge, and the vote button.
- Each vote button is a toggle button. Its name reads upvoteText, the title, and the count, like 'Upvote Dark mode 48 votes'. aria-pressed says whether this person voted.
- The name comes from aria-labelledby on ids in the same item, so it is in the server HTML too.
- Space and Enter vote and take the vote back, like any button.
- A vote shows at once, so there is no spinner and no aria-busy. savingText shows after the title until the vote saves, and the button's aria-describedby points to it. Every button stays ready, so people can press again.
- A failed save shows errorText under the idea in a role="alert" message. The button's aria-describedby points to it, and focus stays on the button.
- disabled, on the list or on one idea, takes those buttons out of the tab order.
- A voted button is filled and the rest are outlined, so the vote does not rest on color alone.
- A changed count fades in over --motion-duration-base. data-motion="off" makes it instant. Reduced motion drops the slide but keeps the fade.
- In the stack layout, each caret is a toggle button with its own aria-pressed. Their names read the direction, the title, and the count, like 'Upvote Dark mode 48 votes' and 'Downvote Dark mode 48 votes'. The number between them is hidden from screen readers, since both names hold it.
- Each caret's tooltip says what it does. It opens after the pointer rests 150 ms, at once on keyboard focus, and Escape closes it. Touch shows none.
- The stack layout's count rolls to each new value. Reduced motion and data-motion="off" jump straight to it.
- Every word has a prop with an English default: upvoteText, downvoteText, savingText, errorText, and votesText. locale formats the counts and picks each plural form.

## Examples

### A list that keeps its own state

```tsx
<FeatureVoting
  items={[
    { value: "dark-mode", title: "Dark mode", description: "Easier on the eyes at night.", votes: 48 },
    { value: "export", title: "Export to a spreadsheet", votes: 31 },
    { value: "reminders", title: "Meeting reminders", votes: 17 },
  ]}
  defaultVoted={["dark-mode"]}
/>
```

### Save votes yourself

```tsx
"use client";

const items = [
  { value: "dark-mode", title: "Dark mode", votes: 48 },
  { value: "export", title: "Export to a spreadsheet", votes: 31 },
];

// The list shows each vote at once and counts it. If this throws, it puts the vote back and says so.
// It keeps its own state, so the newest press on an idea wins even when an older save finishes later.
async function save(value: string, voted: boolean) {
  // Replace this with your own request. fetch does not throw on a server error, so check ok.
  const response = await fetch("/api/votes", { method: "POST", body: JSON.stringify({ idea: value, voted }) });
  if (!response.ok) throw new Error("The vote did not save.");
}

function IdeaBoard() {
  return <FeatureVoting items={items} defaultVoted={["export"]} onVote={save} tone="brand" />;
}
```

### Statuses and a shipped idea

```tsx
<FeatureVoting
  items={[
    { value: "reminders", title: "Meeting reminders", votes: 17, status: { label: "In progress", tone: "warning" } },
    { value: "dark-mode", title: "Dark mode", votes: 48, status: { label: "Planned", tone: "brand" } },
    { value: "calendar", title: "Calendar sync", votes: 52, status: { label: "Shipped", tone: "success" }, disabled: true },
  ]}
/>
```

### Vote up or down in a stacked column

```tsx
"use client";

const items = [
  { value: "dark-mode", title: "Dark mode", description: "Easier on the eyes at night.", votes: 48 },
  { value: "sounds", title: "A sound for every message", votes: -4 },
];

// voted is false when the vote is taken back, so the idea then has no vote from this person.
async function save(value: string, voted: boolean, direction: "up" | "down") {
  const response = await fetch("/api/votes", { method: "POST", body: JSON.stringify({ idea: value, vote: voted ? direction : null }) });
  if (!response.ok) throw new Error("The vote did not save.");
}

function IdeaBoard() {
  return <FeatureVoting layout="stack" items={items} defaultVoted={["dark-mode"]} defaultDownvoted={["sounds"]} onVote={save} />;
}
```

### In another language

```tsx
<FeatureVoting
  items={[
    { value: "dark-mode", title: "Dunkler Modus", votes: 1204 },
    { value: "export", title: "Export als Tabelle", votes: 1 },
  ]}
  upvoteText="Dafür stimmen"
  votesText={{ one: "{count} Stimme", other: "{count} Stimmen" }}
  locale="de-DE"
/>
```
