Files

19 KiB
Raw Permalink Blame History

Token-Styled Select Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the four raw <select> elements with a token-styled ui/Select (Base UI Select) that matches ui/Input and has a visible focus ring.

Architecture: A new ui/select.tsx wraps Base UI Select (Portal+Positioner+Popup) styled like Input. object-form visibility moves to a react-hook-form Controller; field-form's three selects use value/onValueChange directly. Base UI Select is not a native <select>, so the affected tests are rewritten from userEvent.selectOptions to open-trigger + click-option.

Tech Stack: React 19 + TS + pnpm, Base UI (@base-ui/react/select, namespace Select), react-hook-form (object-form), lucide-react (Chevron/Check), Vitest + RTL + Storybook. Test runner: pnpm test (single pass).

Conventions: pnpm; no any/eslint-disable/@ts-ignore; no codename; en/sv parity (no new keys expected); ui/ files = no-semicolon (match ui/combobox.tsx/ui/menu.tsx); app source = double-quote+semicolon; stories single-quote/no-semicolon; token classes only; this repo enforces react-hooks/refs + react-refresh/only-export-components — refactor cleanly, never disable.

Spec: docs/superpowers/specs/2026-06-08-token-select-design.md

Key facts:

  • Base UI: import { Select as SelectPrimitive } from "@base-ui/react/select" — parts Root, Trigger, Value, Icon, Portal, Positioner, Popup, List, Item, ItemIndicator, ItemText. Mirror ui/combobox.tsx/ui/menu.tsx wrapper style. Novel → validate by running.
  • Input className to match (ui/input.tsx): h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 … focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:… aria-invalid:border-destructive dark:bg-input/30.
  • object-form.tsx: visibility <select {...register("visibility")}> (create-mode only), <Label htmlFor="visibility">, options draft/internal, default "draft".
  • field-form.tsx: useState selects — dataType (id="field-type", TYPES, disabled on edit), vocabularyId (id="field-vocab", when term, placeholder + useVocabularies() {id,key}, disabled on edit, required), authorityKind (id="field-kind", when authority, "Any"="" + KINDS, disabled on edit).
  • Tests: object-form.test.tsx (visibility options + edit-mode-absent) and fields.test.tsx (selectOptions for type/kind/vocab) — must be rewritten.

Task 1: ui/select.tsx + story (validate by running)

Files: web/src/components/ui/select.tsx (new), web/src/components/ui/select.stories.tsx (new).

  • Step 1: Study the references. Read web/src/components/ui/combobox.tsx and web/src/components/ui/menu.tsx for the exact house pattern (namespace import, data-slot, cn(), NO semicolons, Portal+Positioner+Popup, token classes). Read web/src/components/ui/input.tsx for the trigger className to match. Confirm the real Base UI Select part names/props by inspecting web/node_modules/@base-ui/react/select/ types — especially Trigger, Value (placeholder prop), Positioner (sideOffset/anchor), Item (value prop), ItemIndicator, ItemText.

  • Step 2: Implement web/src/components/ui/select.tsx (no-semicolon). Export Select, SelectTrigger, SelectValue, SelectContent, SelectItem. Skeleton — adapt every prop/part to what the installed Base UI actually exposes (validate in Step 4):

import { Select as SelectPrimitive } from "@base-ui/react/select"
import { Check, ChevronDown } from "lucide-react"

import { cn } from "@/lib/utils"

function Select<Value>(props: SelectPrimitive.Root.Props<Value>) {
  return <SelectPrimitive.Root {...props} />
}

function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      className={cn(
        "flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none",
        "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
        "disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50",
        "aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
        "dark:bg-input/30 data-[popup-open]:border-ring",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon className="text-muted-foreground">
        <ChevronDown className="h-4 w-4" aria-hidden />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  )
}

function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
  return <SelectPrimitive.Value data-slot="select-value" className={cn("truncate", className)} {...props} />
}

function SelectContent({ className, sideOffset = 6, ...props }: SelectPrimitive.Popup.Props & { sideOffset?: number }) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Positioner sideOffset={sideOffset} className="z-50" alignItemWithTrigger={false}>
        <SelectPrimitive.Popup
          data-slot="select-content"
          className={cn(
            "max-h-[min(24rem,var(--available-height))] min-w-[var(--anchor-width)] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
            "data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
            className
          )}
          {...props}
        />
      </SelectPrimitive.Positioner>
    </SelectPrimitive.Portal>
  )
}

function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "flex cursor-default items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
        "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
        className
      )}
      {...props}
    >
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
      <SelectPrimitive.ItemIndicator>
        <Check className="h-4 w-4" aria-hidden />
      </SelectPrimitive.ItemIndicator>
    </SelectPrimitive.Item>
  )
}

export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }

IMPORTANT: --anchor-width/--available-height, alignItemWithTrigger, data-[popup-open], the Value placeholder API, and whether Trigger is generic — all MUST be confirmed against the installed types/runtime. If a class/prop doesn't exist, drop/replace it. Token classes only (no raw palette). The STORY passing is the proof.

  • Step 3: Story web/src/components/ui/select.stories.tsx (single-quote, no-semicolon; match menu.stories.tsx). A controlled Select with a SelectTrigger(+SelectValue placeholder) and a SelectContent of 3 SelectItems; a play test that clicks the trigger, clicks an option (queried via within(document.body) — Base UI Select options render in a portal with role option), and asserts the trigger now shows the chosen label.
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { expect, within } from 'storybook/test'

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'

function Controlled() {
  const [value, setValue] = useState('')
  return (
    <Select value={value} onValueChange={setValue}>
      <SelectTrigger aria-label="Fruit"><SelectValue placeholder="Pick one" /></SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="pear">Pear</SelectItem>
        <SelectItem value="plum">Plum</SelectItem>
      </SelectContent>
    </Select>
  )
}

const meta = { component: Select, tags: ['ai-generated'], render: () => <Controlled /> } satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  play: async ({ canvas, userEvent }) => {
    await userEvent.click(canvas.getByRole('combobox', { name: 'Fruit' }))
    await userEvent.click(await within(document.body).findByRole('option', { name: 'Pear' }))
    await expect(canvas.getByRole('combobox', { name: 'Fruit' })).toHaveTextContent('Pear')
  },
}

(If the Base UI Select trigger isn't role combobox, adjust the query to what it actually is — discover by running. If onValueChange/value prop names differ, fix. The story passing IS the validation; report the final working API.)

  • Step 4: Validate by running (vitest ONCE): cd web && pnpm vitest run src/components/ui/select.stories.tsx && pnpm typecheck && pnpm lint Iterate the wrapper until the story play test passes. Report the FINAL working API (exports, the trigger role/accessible-name mechanism, value/onValueChange names, placeholder mechanism) — Tasks 2/3 depend on it.

  • Step 5: Commit

git add web/src/components/ui/select.tsx web/src/components/ui/select.stories.tsx
git commit -m "feat(web): ui/select Base UI Select wrapper matching Input + story (#51)"

Task 2: object-form visibility → ui/Select

Files: web/src/objects/object-form.tsx, web/src/objects/object-form.test.tsx.

  • Step 1: Replace the visibility <select> (create-mode block). Add imports: import { Controller } from "react-hook-form"; (if not present) and import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";. Replace:
{mode === "create" && (
  <div className="space-y-1">
    <Label htmlFor="visibility">{t("form.visibility")}</Label>
    <Controller
      control={form.control}
      name="visibility"
      render={({ field }) => (
        <Select value={field.value} onValueChange={field.onChange}>
          <SelectTrigger id="visibility">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="draft">{t("form.draft")}</SelectItem>
            <SelectItem value="internal">{t("form.internal")}</SelectItem>
          </SelectContent>
        </Select>
      )}
    />
  </div>
)}

(Use the exact value/onValueChange/trigger-id API confirmed in Task 1. form.control is available from useForm; destructure control or use form.control. The default value stays "draft" from defaultValues.) Keep <Label htmlFor="visibility"> so the trigger is labelled (the trigger carries id="visibility").

  • Step 2: Rewrite the visibility test in object-form.test.tsx (the first test, lines 826). Replace the HTMLSelectElement.options inspection with open-and-assert:
// after typing object number/name/inscription:
await userEvent.click(screen.getByLabelText(/visibility/i)); // opens the Select
expect(await within(document.body).findByRole("option", { name: /draft/i })).toBeInTheDocument();
expect(within(document.body).getByRole("option", { name: /internal/i })).toBeInTheDocument();
expect(within(document.body).queryByRole("option", { name: /public/i })).not.toBeInTheDocument();
// close without changing (Escape) so default "draft" stays, then submit:
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
const values = onSubmit.mock.calls[0][0];
expect(values.core.object_number).toBe("A-9");
expect(values.visibility).toBe("draft");
expect(values.fields.inscription).toBe("To the gods");

Add within to the testing-library import. NOTE: screen.getByLabelText(/visibility/i) must resolve the Select trigger — if the Label→trigger association doesn't make getByLabelText work (Task 1 should ensure it via id), fall back to screen.getByRole("combobox", { name: /visibility/i }); use whichever the Task-1 validation showed works, consistently. Do NOT weaken the draft/internal/not-public assertions.

  • Step 3: Confirm the "edit mode: no visibility control" test (lines 6882) still passes — it queries queryByLabelText(/visibility/i) which returns null in edit mode (the block isn't rendered). Should pass unchanged. If the query mechanism changed in Step 2, mirror it here (e.g. queryByRole("combobox", { name: /visibility/i })).

  • Step 4: Verify (vitest ONCE): cd web && pnpm vitest run src/objects/object-form.test.tsx && pnpm typecheck && pnpm lint. PASS (the other object-form tests — Cmd+Enter, required, minCount, edit, pruneFields — must stay green).

  • Step 5: Commit

git add web/src/objects/object-form.tsx web/src/objects/object-form.test.tsx
git commit -m "feat(web): object-form visibility uses ui/Select (#51)"

Task 3: field-form's three selects → ui/Select + gate

Files: web/src/fields/field-form.tsx, web/src/fields/fields.test.tsx.

  • Step 1: Replace the three <select>s in field-form.tsx. Add import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";.
    • data_type (lines 118133):
<div className="space-y-1">
  <Label htmlFor="field-type">{t("fields.type")}</Label>
  <Select value={dataType} onValueChange={setDataType} disabled={isEdit}>
    <SelectTrigger id="field-type"><SelectValue /></SelectTrigger>
    <SelectContent>
      {TYPES.map((type) => (
        <SelectItem key={type} value={type}>{t(`fields.types.${type}`)}</SelectItem>
      ))}
    </SelectContent>
  </Select>
</div>
  • vocabulary (the dataType === "term" block): <Select value={vocabularyId} onValueChange={setVocabularyId} disabled={isEdit}> with <SelectTrigger id="field-vocab"><SelectValue placeholder={t("form.selectPlaceholder")} /></SelectTrigger> and SelectItems from vocabularies?.map((v) => <SelectItem key={v.id} value={v.id}>{v.key}</SelectItem>). (No empty "" item — the placeholder shows when vocabularyId===""; the required check !vocabularyId still blocks submit.)

  • authority_kind (the dataType === "authority" block): <Select value={authorityKind} onValueChange={setAuthorityKind} disabled={isEdit}> with <SelectTrigger id="field-kind"><SelectValue /></SelectTrigger>, an <SelectItem value="">{t("fields.anyKind")}</SelectItem> then KINDS.map((k) => <SelectItem key={k} value={k}>{t(authorities.${k})}</SelectItem>). Keep every <Label htmlFor> and the surrounding div.space-y-1. No change to submit/validation/reset logic.

  • NOTE on Base UI Select value "": confirm the authority_kind "" "Any" item selects/displays correctly, and that vocabulary's "" shows the placeholder (Task 1 validated the placeholder). If Base UI Select disallows an empty-string item value, use a sentinel (e.g. "__any__") mapped to ""/null at submit — but prefer "" if it works; verify by running the test.

  • Step 2: Rewrite fields.test.tsx select interactions (a small helper keeps it readable). Add within to the import. Replace userEvent.selectOptions(...) with open+click:

    • A helper:
async function choose(triggerName: RegExp, optionName: RegExp) {
  await userEvent.click(screen.getByLabelText(triggerName));
  await userEvent.click(await within(document.body).findByRole("option", { name: optionName }));
}
  • "selecting Authority…": await choose(/^type$/i, /authority/i); then const kind = await screen.findByLabelText(/authority kind/i); (now a Select trigger) → await choose(/authority kind/i, /^person$/i); → create → assert body.authority_kind === "person".

  • "selecting Term…": await choose(/^type$/i, /term/i); then assert the vocabulary trigger appears (await screen.findByLabelText(/^vocabulary$/i)); click create → expect the role="alert" (blocked, posted===false); then await choose(/^vocabulary$/i, /material/i) (the seeded vocab v-material shows its key — match its label; check the MSW vocab fixture for the displayed key text and match it) → create → posted===true.

  • "creates a text field" + "lists field definitions…" — default type is text; the type Select isn't opened, so these should pass unchanged (the create posts data_type: "text"). Verify.

  • If getByLabelText(triggerName) doesn't resolve the Select trigger, use getByRole("combobox", { name: triggerName }) (whatever Task 1 validated) — consistently. Keep all body/posted assertions identical.

  • IMPORTANT: confirm the vocab option label — the test selected "v-material" (the option value); with Select the user clicks the visible label (the vocab key). Read the vocab MSW fixture (web/src/test/handlers.ts) to find the key displayed for id v-material and match the option name to that text.

  • Step 3: FULL GATE (run tests EXACTLY ONCE):

cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors

All green. Report test totals, largest chunk (KB gz; Select adds to the bundle — report and flag if >250), check:colors line.

  • Step 4: Codename + status:
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
  • Step 5: Manual smoke (recommended). pnpm dev: each select now matches Input (height/radius/border) and shows a focus ring on keyboard focus; data_type/vocab/kind disabled on edit; visibility still defaults to draft and submits correctly; term/authority conditional pickers work.

  • Step 6: Commit

git add web/src/fields/field-form.tsx web/src/fields/fields.test.tsx
git commit -m "feat(web): field-form selects use ui/Select; rewrite select tests (#51)"

Self-Review (completed)

Spec coverage: ui/Select matching Input + story-validated (T1); visibility via Controller (T2); data_type/vocabulary/authority_kind via value/onValueChange + disabled-on-edit (T3); behavior preserved (placeholder, "Any"="", required-vocab check, reset); tests rewritten to click interaction with identical payload assertions (T2/T3); gate + check:size report (T3). Acceptance criteria 15 mapped. ✓

Placeholder scan: the Base UI Select part tree/props are "confirm by running" (novel primitive) with a concrete skeleton + the validation step — not a TODO. The ""-value caveat (authority "Any") has an explicit fallback (sentinel). The vocab option label is "read the fixture and match" with the file named. No vague steps. ✓

Type/consistency: exports Select/SelectTrigger/SelectValue/SelectContent/SelectItem defined in T1, consumed identically in T2/T3; the trigger query mechanism (getByLabelText vs getByRole combobox) is chosen once in T1 and used consistently. value/onValueChange contract uniform. ✓

Notes

  • No new dependency (Base UI + lucide already present); no new i18n keys.
  • check:size is the one budget risk (Select in the form chunks) — measured + flagged in T3, not silently bumped.
  • Validate-by-running (T1) is mandatory for the novel Base UI Select, per the repo pattern (combobox/menu/toast).
  • Deferred: searchable vocabulary combobox; a raw-<select> lint guard.