merge: token-styled ui/Select replacing raw selects (#51)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
# 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):
|
||||
```tsx
|
||||
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 `SelectItem`s; 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.
|
||||
```tsx
|
||||
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**
|
||||
```bash
|
||||
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:
|
||||
```tsx
|
||||
{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 8–26). Replace the `HTMLSelectElement.options` inspection with open-and-assert:
|
||||
```tsx
|
||||
// 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 68–82) 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**
|
||||
```bash
|
||||
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 118–133):
|
||||
```tsx
|
||||
<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 `SelectItem`s 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:
|
||||
```tsx
|
||||
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):**
|
||||
```bash
|
||||
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:**
|
||||
```bash
|
||||
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**
|
||||
```bash
|
||||
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 1–5 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.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Token-Styled Select — Design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||
**Issue:** #51.
|
||||
|
||||
## Context
|
||||
|
||||
Four raw `<select>` elements (`field-form.tsx:120` data_type, `:138` vocabulary, `:158`
|
||||
authority_kind; `object-form.tsx:148` visibility) are styled `w-full rounded border px-2 py-1 text-sm`
|
||||
— different radius/border/padding/height from the sibling `ui/Input` (`h-8 rounded-lg border-input …
|
||||
focus-visible:ring-3 ring-ring/50`), and crucially they have **no focus ring** (keyboard users get no
|
||||
focus affordance). This milestone adds a token-styled `ui/Select` (Base UI Select) matching `Input`
|
||||
and replaces all four. (Decision: all four → `ui/Select`; making the vocabulary picker a searchable
|
||||
combobox is a deferred follow-up — keeps the working object-editing combobox untouched.)
|
||||
|
||||
**Facts:** Base UI Select is `import { Select as SelectPrimitive } from "@base-ui/react/select"`
|
||||
(namespace; parts Root/Trigger/Value/Icon/Portal/Positioner/Popup/List/Item/ItemIndicator/ItemText) —
|
||||
no new dependency. `object-form` is react-hook-form (visibility is `register`'d); `field-form` is
|
||||
useState-controlled (all three selects), with `data_type`/`vocabulary_id`/`authority_kind` `disabled`
|
||||
on edit. `ui/menu.tsx` + `ui/combobox.tsx` are the established Base UI wrapper patterns. The Input
|
||||
className to match is in `ui/input.tsx`. Base UI Select is NOT a native `<select>`, so existing tests
|
||||
using `userEvent.selectOptions` / `HTMLSelectElement` must be rewritten to click interaction.
|
||||
|
||||
### Decisions (from brainstorming)
|
||||
1. **All four selects → `ui/Select`** (uniform token styling + focus ring; lowest risk).
|
||||
2. New `ui/select.tsx` wraps Base UI Select; **validated by running** (novel primitive).
|
||||
3. Tests rewritten to Base UI Select interaction (open trigger → click item).
|
||||
|
||||
## Components
|
||||
|
||||
### `web/src/components/ui/select.tsx` (new)
|
||||
Wrap Base UI Select in the `ui/*` style (`data-slot`, `cn`, no semicolons), mirroring
|
||||
`ui/combobox.tsx`/`ui/menu.tsx`. Exports (names final after validate-by-running):
|
||||
- `Select` — `SelectPrimitive.Root` (generic value; `value`/`onValueChange`/`defaultValue`/`disabled`/`name`).
|
||||
- `SelectTrigger` — `SelectPrimitive.Trigger` styled to **match Input**: `h-8 w-full rounded-lg border
|
||||
border-input bg-transparent px-2.5 py-1 text-sm inline-flex items-center justify-between
|
||||
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 dark:bg-input/30` + a
|
||||
trailing chevron (`SelectPrimitive.Icon` with a lucide `ChevronDown`, `aria-hidden`) and a
|
||||
`SelectValue` (`SelectPrimitive.Value`) showing the chosen item (with `placeholder`).
|
||||
- `SelectContent` — `SelectPrimitive.Portal` + `SelectPrimitive.Positioner` (`sideOffset`, `z-50`) +
|
||||
`SelectPrimitive.Popup` styled as a card (`min-w-[var(--anchor-width)]` if supported, else
|
||||
`min-w-32`; `rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none` +
|
||||
open/close animation data-attrs).
|
||||
- `SelectItem` — `SelectPrimitive.Item` row (`flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm
|
||||
outline-none select-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground`) +
|
||||
a `SelectPrimitive.ItemIndicator` (lucide `Check`) and `SelectPrimitive.ItemText`.
|
||||
- Token classes only (no raw palette). The exact part tree + props (`SelectValue` placeholder,
|
||||
positioner anchoring, item `value`) **must be confirmed by running the story** (Base UI Select is
|
||||
novel here), as combobox/menu/toast were.
|
||||
|
||||
**Accessibility:** `SelectTrigger` accepts `id` so the existing `<Label htmlFor={id}>` associates →
|
||||
`getByLabelText`/`getByRole("combobox", { name })` keeps working in tests. (Base UI Select trigger has
|
||||
role `combobox`; confirm the accessible-name wiring when validating.)
|
||||
|
||||
### `web/src/components/ui/select.stories.tsx` (new)
|
||||
A `Select` with a few `SelectItem`s; a `play` test that opens the trigger and selects an item, asserting
|
||||
the value/label updates (portal content queried via `within(document.body)`, like the menu story). This
|
||||
is the validation.
|
||||
|
||||
### Replacements
|
||||
|
||||
**`object-form.tsx` visibility** (create-mode only): Base UI Select isn't a native input, so replace
|
||||
the `register("visibility")` `<select>` with a **`Controller`** (`control={form.control}
|
||||
name="visibility"`) rendering `<Select value={field.value} onValueChange={field.onChange}>` with items
|
||||
`draft`/`internal` (labels `form.draft`/`form.internal`). Keep `<Label htmlFor="visibility">` → trigger
|
||||
`id="visibility"`. Default stays `"draft"` (the form's defaultValues already set it).
|
||||
|
||||
**`field-form.tsx`** (useState — pass `value`/`onValueChange` directly, no Controller):
|
||||
- **data_type:** `<Select value={dataType} onValueChange={setDataType} disabled={isEdit}>`, items from
|
||||
`TYPES` (labels `fields.types.${type}`). `id="field-type"`.
|
||||
- **vocabulary_id** (when `dataType==="term"`): `<Select value={vocabularyId} onValueChange={setVocabularyId}
|
||||
disabled={isEdit}>` with a placeholder (`form.selectPlaceholder`) and items from `vocabularies` (value
|
||||
`vocab.id`, label `vocab.key`). `id="field-vocab"`. The empty/placeholder state: Base UI Select shows
|
||||
the `SelectValue` placeholder when value is `""`; keep `vocabularyId=""` as the unselected state (the
|
||||
existing required-check `!vocabularyId` still works).
|
||||
- **authority_kind** (when `dataType==="authority"`): `<Select value={authorityKind}
|
||||
onValueChange={setAuthorityKind} disabled={isEdit}>` with an "Any" item (value `""`, label
|
||||
`fields.anyKind`) + `KINDS` items (labels `authorities.${kind}`). `id="field-kind"`.
|
||||
|
||||
No change to submit logic, validation, or the disabled-on-edit behavior — only the control swaps.
|
||||
|
||||
## Data flow
|
||||
Unchanged: the same state/`register`/`Controller` value drives the same submit payloads. Only the
|
||||
rendered control (native `<select>` → Base UI Select) and its styling change.
|
||||
|
||||
## Error handling / edges
|
||||
- Base UI Select `value=""` must render the placeholder (vocabulary) or the "Any" item (authority_kind)
|
||||
— verify when running. The authority_kind "Any" is a real selectable item with value `""`.
|
||||
- `disabled={isEdit}` must visually + functionally disable the trigger (the styling includes
|
||||
`disabled:` classes).
|
||||
- The visibility `Controller` default must remain `"draft"` (no regression to the create payload).
|
||||
- Keyboard: Base UI Select is fully keyboard-operable (the focus ring is the headline fix).
|
||||
|
||||
## Testing
|
||||
- **`select.stories.tsx`** validates the primitive by running (open + select).
|
||||
- **`object-form.test.tsx`** rewrite the visibility assertions:
|
||||
- "shows visibility (draft/internal only, not public)": open the Select trigger, assert items `Draft`
|
||||
+ `Internal` are present and `Public` is absent (query the portal list); select `Internal` and
|
||||
assert it's reflected. (Replaces `HTMLSelectElement.options` inspection.)
|
||||
- "edit mode: no visibility control": keep — query by the visibility Label name returns null.
|
||||
- **`fields.test.tsx`** rewrite the select flows to click interaction:
|
||||
- data_type → open, click `Authority` → the kind picker appears → open, click `Person` → assert
|
||||
`authority_kind: "person"` in the create payload.
|
||||
- data_type → open, click `Term` → the vocabulary picker appears → submit blocked until a vocab is
|
||||
chosen → open, click the vocab → assert the create payload. Keep the same payload assertions.
|
||||
- "creates a text field" (default type) — unaffected or minimal change.
|
||||
- Keep all payload/mutation assertions identical (don't weaken); only the interaction changes.
|
||||
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (no new keys
|
||||
expected); no codename. **`check:size`:** Base UI Select adds to the always-loaded form chunk —
|
||||
report the value (budget 250 KB gz; flag if it exceeds rather than silently raising).
|
||||
|
||||
## Acceptance criteria
|
||||
1. A `ui/Select` (Base UI Select) exists, styled to match `Input` (h-8, rounded-lg, border-input,
|
||||
focus-visible ring, disabled/aria-invalid states), with a Storybook story validated by running.
|
||||
2. All four raw `<select>` elements (object-form visibility; field-form data_type / vocabulary /
|
||||
authority_kind) are replaced by `ui/Select`; they share Input's tokens and have a visible focus ring.
|
||||
3. Behavior preserved: same values/payloads, disabled-on-edit, create-mode-only visibility, term/authority
|
||||
conditional pickers, the required-vocabulary check, and the create-form reset.
|
||||
4. Tests rewritten to Base UI Select interaction (no native `selectOptions`); all payload assertions
|
||||
unchanged; suite green.
|
||||
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (within budget or
|
||||
flagged); en/sv parity; no codename; no new npm dependency.
|
||||
|
||||
## Out of scope → follow-ups
|
||||
- Making the vocabulary picker a **searchable combobox** (generalizing `OptionsCombobox` to `{id,label}`)
|
||||
— deferred; revisit if vocabulary lists grow large.
|
||||
- Replacing any other native form controls; a lint guard banning raw `<select>` outside `components/ui/`.
|
||||
- The `visibility` select gaining a `public` option (intentionally absent — publishing is via
|
||||
`publish-control`).
|
||||
@@ -0,0 +1,38 @@
|
||||
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={(next) => setValue(next ?? '')}>
|
||||
<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')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Base UI's <Select.Value> resolves the selected item's *label* from the
|
||||
// Root `items` prop only; without it the trigger shows the raw value. We
|
||||
// derive that map from the rendered <SelectItem> children so consumers keep
|
||||
// the simple `<SelectItem value="…">Label</SelectItem>` API and still get
|
||||
// labels (not raw values) in the trigger.
|
||||
function collectItems(
|
||||
node: React.ReactNode,
|
||||
out: Array<{ value: unknown; label: React.ReactNode }>
|
||||
) {
|
||||
React.Children.forEach(node, (child) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (child.type === SelectItem) {
|
||||
const props = child.props as SelectPrimitive.Item.Props
|
||||
|
||||
out.push({ value: props.value, label: props.children })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const props = child.props as { children?: React.ReactNode }
|
||||
|
||||
if (props.children != null) {
|
||||
collectItems(props.children, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function Select<Value>({ children, ...props }: SelectPrimitive.Root.Props<Value>) {
|
||||
const items = React.useMemo(() => {
|
||||
const collected: Array<{ value: unknown; label: React.ReactNode }> = []
|
||||
|
||||
collectItems(children, collected)
|
||||
|
||||
return collected
|
||||
}, [children])
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root data-slot="select" items={items} {...props}>
|
||||
{children}
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
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?: SelectPrimitive.Positioner.Props["sideOffset"]
|
||||
}) {
|
||||
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 }
|
||||
@@ -12,6 +12,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||
@@ -117,58 +124,62 @@ export function FieldForm({
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-type">{t("fields.type")}</Label>
|
||||
<select
|
||||
id="field-type"
|
||||
value={dataType}
|
||||
disabled={isEdit}
|
||||
onChange={(e) => setDataType(e.target.value)}
|
||||
className="w-full rounded-md border px-2 py-1 text-sm disabled:opacity-60"
|
||||
>
|
||||
{TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`fields.types.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select value={dataType} onValueChange={(v) => setDataType(v ?? "")} 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>
|
||||
|
||||
{dataType === "term" && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
|
||||
<select
|
||||
id="field-vocab"
|
||||
<Select
|
||||
value={vocabularyId}
|
||||
onValueChange={(v) => setVocabularyId(v ?? "")}
|
||||
disabled={isEdit}
|
||||
onChange={(e) => setVocabularyId(e.target.value)}
|
||||
className="w-full rounded-md border px-2 py-1 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="">{t("form.selectPlaceholder")}</option>
|
||||
{vocabularies?.map((vocab) => (
|
||||
<option key={vocab.id} value={vocab.id}>
|
||||
{vocab.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="field-vocab">
|
||||
<SelectValue placeholder={t("form.selectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vocabularies?.map((vocab) => (
|
||||
<SelectItem key={vocab.id} value={vocab.id}>
|
||||
{vocab.key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataType === "authority" && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
|
||||
<select
|
||||
id="field-kind"
|
||||
<Select
|
||||
value={authorityKind}
|
||||
onValueChange={(v) => setAuthorityKind(v ?? "")}
|
||||
disabled={isEdit}
|
||||
onChange={(e) => setAuthorityKind(e.target.value)}
|
||||
className="w-full rounded-md border px-2 py-1 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="">{t("fields.anyKind")}</option>
|
||||
{KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{t(`authorities.${kind}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="field-kind">
|
||||
<SelectValue placeholder={t("fields.anyKind")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t("fields.anyKind")}</SelectItem>
|
||||
{KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`authorities.${kind}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
@@ -16,6 +16,11 @@ function tree() {
|
||||
);
|
||||
}
|
||||
|
||||
async function choose(triggerName: RegExp, optionName: RegExp) {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: triggerName }));
|
||||
await userEvent.click(await within(document.body).findByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
test("lists field definitions grouped, with an Other heading for ungrouped", async () => {
|
||||
renderApp(tree(), { route: "/fields" });
|
||||
expect(await screen.findByText("Inscription")).toBeInTheDocument();
|
||||
@@ -56,9 +61,9 @@ test("selecting Authority reveals the kind picker and posts the chosen kind", as
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^key$/i), "maker");
|
||||
await userEvent.type(screen.getByLabelText(/^label$/i), "Maker");
|
||||
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority");
|
||||
const kind = await screen.findByLabelText(/authority kind/i);
|
||||
await userEvent.selectOptions(kind, "person");
|
||||
await choose(/^type$/i, /^authority$/i);
|
||||
await screen.findByLabelText(/authority kind/i);
|
||||
await choose(/authority kind/i, /^person$/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||
|
||||
await waitFor(() => expect(body?.authority_kind).toBe("person"));
|
||||
@@ -77,7 +82,7 @@ test("selecting Term reveals the vocabulary picker and blocks submit until chose
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
|
||||
await userEvent.type(screen.getByLabelText(/^label$/i), "Material");
|
||||
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
|
||||
await choose(/^type$/i, /^term$/i);
|
||||
|
||||
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
||||
|
||||
@@ -87,7 +92,7 @@ test("selecting Term reveals the vocabulary picker and blocks submit until chose
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(posted).toBe(false);
|
||||
|
||||
await userEvent.selectOptions(vocab, "v-material");
|
||||
await choose(/^vocabulary$/i, /^material$/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||
await waitFor(() => expect(posted).toBe(true));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderApp } from "../test/render";
|
||||
import { ObjectForm } from "./object-form";
|
||||
@@ -13,10 +13,11 @@ test("create mode: shows visibility (draft/internal only) and submits assembled
|
||||
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||
|
||||
const visibility = screen.getByLabelText(/visibility/i) as HTMLSelectElement;
|
||||
expect([...visibility.options].map((o) => o.value)).toEqual(expect.arrayContaining(["draft", "internal"]));
|
||||
expect([...visibility.options].map((o) => o.value)).not.toContain("public");
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /visibility/i })); // open
|
||||
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();
|
||||
await userEvent.keyboard("{Escape}"); // close, keep default draft
|
||||
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
const values = onSubmit.mock.calls[0][0];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useFieldDefinitions } from "../api/queries";
|
||||
@@ -11,6 +11,7 @@ import { useUnsavedChanges } from "../lib/use-unsaved-changes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export type ObjectCore = {
|
||||
object_number: string;
|
||||
@@ -176,14 +177,21 @@ export function ObjectForm({
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="visibility">{t("form.visibility")}</Label>
|
||||
|
||||
<select
|
||||
id="visibility"
|
||||
className="w-full rounded-md border px-2 py-1 text-sm"
|
||||
{...register("visibility")}
|
||||
>
|
||||
<option value="draft">{t("form.draft")}</option>
|
||||
<option value="internal">{t("form.internal")}</option>
|
||||
</select>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user