@avament-pub/select
v0.3.3
Published
Canonical searchable Select + Radix primitives for Avament apps
Readme
@avament-pub/select
Canonical React Select for Avament apps — single or multiple choice, with a simple mode for short fixed lists and a searchable mode for growing catalogs (companies, types, clients, …).
| | |
|---|---|
| Package | @avament-pub/select |
| React | ≥ 18 (peer) |
| License | MIT |
Table of contents
- Install
- What you get
- When to use which mode
- Minimal usage
- Searchable + multi + recent
- Option actions & free footer
- Custom trigger, controlled open & portal
- Props API
- Exports
- Theming
- Localization
- Troubleshooting
1. Install
npm install @avament-pub/selectyarn add @avament-pub/select
pnpm add @avament-pub/selectRequirements
- React 18+ and React DOM 18+
- Client component in Next.js App Router (
"use client")
Optional Next.js:
// next.config.ts
transpilePackages: ["@avament-pub/select"];2. What you get
High-level Select (catalog / growing lists)
mode="simple"|mode="searchable"- Single or multiple, A–Z sort, search autofocus, keyboard, recent pins
- Free
header/footer/ option actions
Radix primitives (drop-in for local shadcn Select)
For screens still built with SelectTrigger / SelectContent / SelectItem:
import {
SelectRoot, // was: Select (Radix Root)
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
} from "@avament-pub/select";Rename only the Root import (Select → SelectRoot) to avoid clashing with the high-level catalog Select. Self-contained CSS variables — no Tailwind content scan needed.
3. When to use which mode
| Mode | Use for | Search |
|---|---|---|
| mode="simple" (default) | Gender, protocol type, ~3–5 fixed enums | No |
| mode="searchable" | Companies, workshops, clients, suppliers, types, users — anything that can grow | Yes |
4. Minimal usage
Simple single (e.g. gender)
"use client";
import { useState } from "react";
import { Select } from "@avament-pub/select";
const GENDER = [
{ value: "female", label: "Female" },
{ value: "male", label: "Male" },
{ value: "other", label: "Other" },
];
export function GenderField() {
const [value, setValue] = useState<string | null>(null);
return (
<Select
mode="simple"
label="Gender"
placeholder="Select gender"
options={GENDER}
value={value}
onChange={setValue}
/>
);
}Searchable single
"use client";
import { useState } from "react";
import { Select } from "@avament-pub/select";
export function TypeField({ options }: { options: { value: string; label: string }[] }) {
const [value, setValue] = useState<string | null>(null);
return (
<Select
mode="searchable"
label="Type"
options={options}
value={value}
onChange={setValue}
labels={{ searchPlaceholder: "Search" }}
/>
);
}5. Searchable + multi + recent
"use client";
import { useState } from "react";
import { Select } from "@avament-pub/select";
export function CompaniesField({ options }) {
const [value, setValue] = useState<string[]>([]);
const [recent, setRecent] = useState<string[]>([]);
return (
<Select
mode="searchable"
selection="multiple"
selectAll
showRecent // last 3; or showRecent={5}
recentValues={recent}
onRecentValuesChange={setRecent}
label="Responsibles"
options={options}
value={value}
onChange={setValue}
selectedCountLabel={(n) => `${n} selected`}
header={
<button type="button" style={{ color: "#2b60e6", border: "none", background: "none" }}>
+ Add company
</button>
}
footer={(ctx) => (
<div style={{ padding: 8, borderTop: "1px solid #e5e7eb", fontSize: 12 }}>
{ctx.selectedCount} selected
</div>
)}
/>
);
}showRecent pins MRU values above a separator; the same options remain in the A–Z list below. Omit recentValues to let Select track recent state internally.
6. Option actions & free footer
<Select
mode="searchable"
options={options}
value={value}
onChange={setValue}
optionActionsVisibility="hover"
renderOptionActions={(option, ctx) => (
<>
<button type="button" onClick={() => edit(option)}>Edit</button>
<button
type="button"
onClick={() => {
remove(option);
ctx.close();
}}
>
Delete
</button>
</>
)}
footer={(ctx) => (
<div style={{ display: "flex", gap: 8, padding: 8, borderTop: "1px solid #eee" }}>
<button type="button" onClick={ctx.close}>Cancel</button>
<button type="button" onClick={ctx.clear}>Clear</button>
<button
type="button"
onClick={() => {
save(ctx.value);
ctx.close();
}}
>
Save
</button>
</div>
)}
/>Clicks inside endAdornment / renderOptionActions do not toggle the row selection.
SelectPanelContext (header / footer render fn)
| Field | Meaning |
|---|---|
| open / close() | Panel open state |
| query / filtered | Search string and filtered options |
| selectedCount / value | Current selection |
| clear() | Clear selection |
| selectAllFiltered() / allFilteredSelected | Multi helpers |
7. Custom trigger, controlled open & portal
Use these for table cells (TipTap HTML, status letter) and any overflow-clipped container:
"use client";
import { useState } from "react";
import { Select } from "@avament-pub/select";
const STATUS = [
{ value: "o", label: "Open" },
{ value: "d", label: "Done" },
{ value: "i", label: "In progress" },
];
export function StatusCell({ isEditing, onEditingChange }) {
const [value, setValue] = useState<string | null>("o");
return (
<Select
mode="simple"
options={STATUS}
value={value}
onChange={(v) => {
setValue(v);
onEditingChange(false);
}}
open={isEditing}
onOpenChange={onEditingChange}
portal // body portal + position:fixed
portalZIndex={300} // optional; default 200
renderTrigger={({ triggerProps, selectedOption, open }) => (
<button
type="button"
{...triggerProps}
style={{
width: 36,
height: 36,
fontWeight: 700,
border: open ? "1.5px solid #2b60e6" : "1px solid #d1d5db",
}}
>
{(selectedOption?.value ?? "?").toString().toUpperCase()}
</button>
)}
/>
);
}| Prop | Role |
|---|---|
| renderTrigger | Replace the default button. Spread triggerProps for a11y + keyboard. |
| open / defaultOpen / onOpenChange | Controlled or uncontrolled panel lifecycle (e.g. table isEditing). |
| portal | Render panel in document.body with position: fixed under the trigger. |
| portalZIndex | Stacking for the portaled panel (default 200). |
8. Props API
Shared
| Prop | Type | Default | Description |
|---|---|---|---|
| options | SelectOption<T>[] | — | { value, label, disabled?, icon?, endAdornment? } |
| mode | "simple" \| "searchable" | "simple" | Searchable adds search + autofocus |
| autoFocusSearch | boolean | true | Searchable only |
| selectAll | boolean | false | Searchable + multiple |
| header / footer | ReactNode \| (ctx) => ReactNode | — | Unstyled slots |
| renderTrigger | (props) => ReactNode | — | Custom trigger (table cells, etc.) |
| open / defaultOpen | boolean | — | Controlled / uncontrolled open |
| onOpenChange | (open: boolean) => void | — | Open lifecycle |
| portal | boolean | false | Body portal + fixed position |
| portalZIndex | number | 200 | Portaled panel z-index |
| renderOptionActions | (option, ctx) => ReactNode | — | Trailing actions |
| optionActionsVisibility | "always" \| "hover" | "always" | Hide actions until highlight/hover |
| sortAlphabetically | boolean | true | Sort by label |
| showRecent | boolean \| number | off | true → last 3 |
| recentValues | T[] | — | Controlled MRU |
| onRecentValuesChange | (T[]) => void | — | MRU updates |
| label / placeholder | string | — | Field chrome |
| labels | SelectLabels | English defaults | Search / empty / a11y strings |
| disabled | boolean | false | |
Single (selection omitted or "single")
| Prop | Type |
|---|---|
| value | T \| null |
| onChange | (value: T \| null) => void |
| clearable | boolean (default true) |
Multiple (selection="multiple")
| Prop | Type |
|---|---|
| value | readonly T[] |
| onChange | (value: T[]) => void |
| selectedCountLabel | (count: number) => string |
9. Exports
import {
Select,
useSelect,
filterOptions,
sortOptions,
pushRecentValue,
selectCssVars,
} from "@avament-pub/select";
import type {
SelectProps,
SelectOption,
SelectLabels,
SelectPanelContext,
SelectOptionActionsContext,
SelectTriggerRenderProps,
SelectMode,
SelectSelection,
SelectSlotRender,
} from "@avament-pub/select";10. Theming
Select ships with inline CSS variables (no required CSS import). Override on a parent:
.my-form {
--ava-select-primary: #2b60e6;
--ava-select-border-focus: #2b60e6;
--ava-select-selected-bg: #e8effc;
--ava-select-radius: 8px;
}Available tokens include --ava-select-border, --ava-select-bg, --ava-select-text, --ava-select-muted, --ava-select-hover, --ava-select-selected-text, --ava-select-shadow, --ava-select-font.
Or import selectCssVars and spread onto a wrapper style.
11. Localization
Pass labels:
labels={{
searchPlaceholder: "Suchen…",
selectAllLabel: "Alle auswählen",
emptyMessage: "Keine Treffer",
clearAriaLabel: "Auswahl löschen",
listAriaLabel: "Optionen",
}}12. Troubleshooting
| Issue | Fix |
|---|---|
| Search missing | Set mode="searchable" |
| Options not A–Z | Default sorts; check sortAlphabetically={false} isn’t set |
| Footer looks unstyled | Expected — you style footer yourself |
| Actions toggle selection | Put buttons in renderOptionActions / endAdornment (clicks are stopped) |
| Recent empty | Select something first, or pass recentValues |
| Panel clipped in table | Set portal |
| Need custom cell trigger | Use renderTrigger + spread triggerProps |
| Sync with table isEditing | Controlled open / onOpenChange |
| Next.js SSR error | Use "use client" on the consuming component |
Versioning & publish
Package version lives in package.json. After README-only changes, bump the version before npm publish.
