feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent
Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||||
import { AnnouncementsPreview } from "@/features/announcements";
|
import { AnnouncementsPreview } from "@/features/announcements";
|
||||||
import { useDrift } from "@/features/templates/useDrift";
|
import { useDrift } from "@/features/templates/useDrift";
|
||||||
@ -287,25 +287,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
>
|
>
|
||||||
Template
|
Template
|
||||||
</label>
|
</label>
|
||||||
<select
|
<SmallDropdown
|
||||||
id="agent-template-select"
|
id="agent-template-select"
|
||||||
aria-label="agent template"
|
aria-label="agent template"
|
||||||
value={newTemplateId}
|
value={newTemplateId}
|
||||||
onChange={(e) => setNewTemplateId(e.target.value)}
|
onChange={setNewTemplateId}
|
||||||
className={cn(
|
className="w-full"
|
||||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
|
||||||
"border border-border outline-none transition-colors",
|
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
)}
|
|
||||||
disabled={vm.busy}
|
disabled={vm.busy}
|
||||||
>
|
size="md"
|
||||||
<option value="">(none / from scratch)</option>
|
options={[
|
||||||
{templates.map((t) => (
|
{ value: "", label: "(none / from scratch)" },
|
||||||
<option key={t.id} value={t.id}>
|
...templates.map((t) => ({ value: t.id, label: t.name })),
|
||||||
{t.name}
|
]}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile selector — hidden when a template is chosen */}
|
{/* Profile selector — hidden when a template is chosen */}
|
||||||
@ -321,24 +315,21 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
{vm.profiles.length > 0 ? (
|
{vm.profiles.length > 0 ? (
|
||||||
<select
|
<SmallDropdown
|
||||||
id="agent-profile-select"
|
id="agent-profile-select"
|
||||||
aria-label="agent profile"
|
aria-label="agent profile"
|
||||||
value={newProfileId}
|
value={newProfileId}
|
||||||
onChange={(e) => setNewProfileId(e.target.value)}
|
onChange={setNewProfileId}
|
||||||
className={cn(
|
className="w-full"
|
||||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
size="md"
|
||||||
"border border-border outline-none transition-colors",
|
options={[
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
{ value: "", label: "— select profile —" },
|
||||||
)}
|
...vm.profiles.map((p) => ({
|
||||||
>
|
value: p.id,
|
||||||
<option value="">— select profile —</option>
|
label: profileLabel(p),
|
||||||
{vm.profiles.map((p) => (
|
})),
|
||||||
<option key={p.id} value={p.id}>
|
]}
|
||||||
{profileLabel(p)}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id="agent-profile-select"
|
id="agent-profile-select"
|
||||||
@ -469,31 +460,25 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
{/* Profile hot-swap selector (Chantier A). Changing the
|
{/* Profile hot-swap selector (Chantier A). Changing the
|
||||||
engine abandons the conversation history → confirmation. */}
|
engine abandons the conversation history → confirmation. */}
|
||||||
{vm.profiles.length > 0 && (
|
{vm.profiles.length > 0 && (
|
||||||
<select
|
<SmallDropdown
|
||||||
aria-label={`profile for ${a.name}`}
|
aria-label={`profile for ${a.name}`}
|
||||||
value={a.profileId}
|
value={a.profileId}
|
||||||
disabled={vm.busy}
|
disabled={vm.busy}
|
||||||
onChange={(e) => {
|
onChange={(profileId) => {
|
||||||
const profileId = e.target.value;
|
|
||||||
if (profileId && profileId !== a.profileId) {
|
if (profileId && profileId !== a.profileId) {
|
||||||
setPendingProfileChange({ agentId: a.id, profileId });
|
setPendingProfileChange({ agentId: a.id, profileId });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
options={[
|
||||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
...(vm.profiles.every((p) => p.id !== a.profileId)
|
||||||
"border border-border outline-none transition-colors",
|
? [{ value: a.profileId, label: a.profileId }]
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
: []),
|
||||||
)}
|
...vm.profiles.map((p) => ({
|
||||||
>
|
value: p.id,
|
||||||
{vm.profiles.every((p) => p.id !== a.profileId) && (
|
label: profileLabel(p),
|
||||||
<option value={a.profileId}>{a.profileId}</option>
|
})),
|
||||||
)}
|
]}
|
||||||
{vm.profiles.map((p) => (
|
/>
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{profileLabel(p)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
{agentDrift && (
|
{agentDrift && (
|
||||||
<Button
|
<Button
|
||||||
@ -612,29 +597,26 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
|
|
||||||
{/* Assign selector */}
|
{/* Assign selector */}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<select
|
<SmallDropdown
|
||||||
aria-label="skill to assign"
|
aria-label="skill to assign"
|
||||||
value={skillToAssign}
|
value={skillToAssign}
|
||||||
onChange={(e) => setSkillToAssign(e.target.value)}
|
onChange={setSkillToAssign}
|
||||||
disabled={vm.busy}
|
disabled={vm.busy}
|
||||||
className={cn(
|
className="min-w-0 flex-1"
|
||||||
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content",
|
size="md"
|
||||||
"border border-border outline-none transition-colors",
|
options={[
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
{ value: "", label: "— assign a skill —" },
|
||||||
)}
|
...skills
|
||||||
>
|
.filter(
|
||||||
<option value="">— assign a skill —</option>
|
(s) =>
|
||||||
{skills
|
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
||||||
.filter(
|
)
|
||||||
(s) =>
|
.map((s) => ({
|
||||||
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
value: s.id,
|
||||||
)
|
label: `${s.name} (${s.scope})`,
|
||||||
.map((s) => (
|
})),
|
||||||
<option key={s.id} value={s.id}>
|
]}
|
||||||
{s.name} ({s.scope})
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
aria-label="assign skill"
|
aria-label="assign skill"
|
||||||
|
|||||||
@ -73,6 +73,17 @@ async function waitForIdle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDropdown(label: string | RegExp) {
|
||||||
|
const trigger = screen.getByLabelText(label);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||||
|
openDropdown(label);
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
|
/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
|
||||||
async function createAgent(name: string, profileId?: string) {
|
async function createAgent(name: string, profileId?: string) {
|
||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
@ -84,7 +95,7 @@ async function createAgent(name: string, profileId?: string) {
|
|||||||
if (profileId) {
|
if (profileId) {
|
||||||
const profileInput = screen.queryByLabelText("agent profile");
|
const profileInput = screen.queryByLabelText("agent profile");
|
||||||
if (profileInput) {
|
if (profileInput) {
|
||||||
fireEvent.change(profileInput, { target: { value: profileId } });
|
chooseDropdownOption("agent profile", profileId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,9 +180,10 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
|||||||
renderPanel(new MockAgentGateway(), profile);
|
renderPanel(new MockAgentGateway(), profile);
|
||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
|
|
||||||
const labels = Array.from(
|
openDropdown("agent profile");
|
||||||
screen.getByLabelText("agent profile").querySelectorAll("option"),
|
const labels = screen
|
||||||
).map((option) => option.textContent);
|
.getAllByRole("option")
|
||||||
|
.map((option) => option.textContent);
|
||||||
|
|
||||||
expect(labels).toContain("Codex fast · gpt-5-mini");
|
expect(labels).toContain("Codex fast · gpt-5-mini");
|
||||||
expect(labels).toContain("Claude deep · claude-opus-4-8");
|
expect(labels).toContain("Claude deep · claude-opus-4-8");
|
||||||
@ -319,8 +331,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
|||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
|
|
||||||
// Select the template in the dropdown
|
// Select the template in the dropdown
|
||||||
const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement;
|
chooseDropdownOption("agent template", "My Template");
|
||||||
fireEvent.change(templateSelect, { target: { value: t.id } });
|
|
||||||
|
|
||||||
// Fill agent name
|
// Fill agent name
|
||||||
fireEvent.change(screen.getByLabelText("agent name"), {
|
fireEvent.change(screen.getByLabelText("agent name"), {
|
||||||
@ -359,9 +370,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
|||||||
renderPanel(new MockAgentGateway(), profile);
|
renderPanel(new MockAgentGateway(), profile);
|
||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
|
|
||||||
// The select element should contain the profile option.
|
openDropdown("agent profile");
|
||||||
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
|
const options = screen.getAllByRole("option").map((o) => o.textContent);
|
||||||
const options = Array.from(select.options).map((o) => o.text);
|
|
||||||
expect(options).toContain("Claude Code");
|
expect(options).toContain("Claude Code");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -375,8 +385,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
|||||||
renderPanel(new MockAgentGateway(), profile);
|
renderPanel(new MockAgentGateway(), profile);
|
||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
|
|
||||||
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
|
openDropdown("agent profile");
|
||||||
const options = Array.from(select.options).map((o) => o.text);
|
const options = screen.getAllByRole("option").map((o) => o.textContent ?? "");
|
||||||
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
|
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -506,8 +516,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
|||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
await screen.findByText("Swap");
|
await screen.findByText("Swap");
|
||||||
|
|
||||||
const select = screen.getByLabelText("profile for Swap") as HTMLSelectElement;
|
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||||
fireEvent.change(select, { target: { value: "prof-2" } });
|
|
||||||
|
|
||||||
// No gateway call yet — only the dialog appears.
|
// No gateway call yet — only the dialog appears.
|
||||||
expect(swapSpy).not.toHaveBeenCalled();
|
expect(swapSpy).not.toHaveBeenCalled();
|
||||||
@ -523,9 +532,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
|||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
await screen.findByText("Swap");
|
await screen.findByText("Swap");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("profile for Swap"), {
|
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||||
target: { value: "prof-2" },
|
|
||||||
});
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "cancel profile change" }),
|
screen.getByRole("button", { name: "cancel profile change" }),
|
||||||
);
|
);
|
||||||
@ -546,9 +553,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
|||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
await screen.findByText("Swap");
|
await screen.findByText("Swap");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("profile for Swap"), {
|
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||||
target: { value: "prof-2" },
|
|
||||||
});
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "confirm profile change" }),
|
screen.getByRole("button", { name: "confirm profile change" }),
|
||||||
);
|
);
|
||||||
@ -571,9 +576,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
|||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
await screen.findByText("Swap");
|
await screen.findByText("Swap");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("profile for Swap"), {
|
chooseDropdownOption("profile for Swap", /Codex CLI/);
|
||||||
target: { value: "prof-2" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
const text = dialog.textContent ?? "";
|
const text = dialog.textContent ?? "";
|
||||||
|
|||||||
@ -25,6 +25,11 @@ import { DIProvider } from "@/app/di";
|
|||||||
import { FirstRunWizard } from "./FirstRunWizard";
|
import { FirstRunWizard } from "./FirstRunWizard";
|
||||||
import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
||||||
|
|
||||||
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||||
|
fireEvent.click(screen.getByLabelText(label));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
function renderWizard(
|
function renderWizard(
|
||||||
profile: MockProfileGateway = new MockProfileGateway(),
|
profile: MockProfileGateway = new MockProfileGateway(),
|
||||||
onDone = vi.fn(),
|
onDone = vi.fn(),
|
||||||
@ -474,11 +479,11 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
|||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
|
|
||||||
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||||
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement;
|
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
|
||||||
expect(modelSelect.disabled).toBe(true);
|
expect(modelSelect.disabled).toBe(true);
|
||||||
|
|
||||||
fireEvent.change(providerSelect, { target: { value: "anthropic" } });
|
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
||||||
expect(modelSelect.disabled).toBe(false);
|
expect(modelSelect.disabled).toBe(false);
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", {
|
const saveButton = screen.getByRole("button", {
|
||||||
@ -486,7 +491,7 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
|||||||
}) as HTMLButtonElement;
|
}) as HTMLButtonElement;
|
||||||
expect(saveButton.disabled).toBe(true);
|
expect(saveButton.disabled).toBe(true);
|
||||||
|
|
||||||
fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } });
|
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
|
||||||
expect(saveButton.disabled).toBe(true);
|
expect(saveButton.disabled).toBe(true);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
@ -500,12 +505,9 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
|||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
|
|
||||||
fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), {
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||||
target: { value: "anthropic" },
|
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
||||||
});
|
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
|
||||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
||||||
target: { value: "claude-sonnet-5" },
|
|
||||||
});
|
|
||||||
const apiKeyInput = screen.getByLabelText(
|
const apiKeyInput = screen.getByLabelText(
|
||||||
`${OPENCODE} api key`,
|
`${OPENCODE} api key`,
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@ -547,8 +549,8 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
|||||||
|
|
||||||
async function goToCloudCustomMode() {
|
async function goToCloudCustomMode() {
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||||
fireEvent.change(providerSelect, { target: { value: "__custom__" } });
|
chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
|
||||||
}
|
}
|
||||||
|
|
||||||
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
|
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
|
||||||
@ -904,8 +906,8 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByLabelText(`${CLONE1} local model server id`),
|
screen.queryByLabelText(`${CLONE1} local model server id`),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
const select = await screen.findByLabelText(`${CLONE1} local model server`);
|
await screen.findByLabelText(`${CLONE1} local model server`);
|
||||||
fireEvent.change(select, { target: { value: server.id } });
|
chooseDropdownOption(`${CLONE1} local model server`, /Local A/);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type {
|
|||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, IconButton, Input, cn } from "@/shared";
|
import { Button, IconButton, Input, SmallDropdown, cn } from "@/shared";
|
||||||
import { ModelServerSelect } from "@/features/model-servers";
|
import { ModelServerSelect } from "@/features/model-servers";
|
||||||
import {
|
import {
|
||||||
defaultOpenCodeConfig,
|
defaultOpenCodeConfig,
|
||||||
@ -300,12 +300,11 @@ function OpenCodeProviderFields({
|
|||||||
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<select
|
<SmallDropdown
|
||||||
aria-label={`${profile.name} provider`}
|
aria-label={`${profile.name} provider`}
|
||||||
value={providerId}
|
value={providerId}
|
||||||
disabled={!catalogReady}
|
disabled={!catalogReady}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
const v = e.target.value;
|
|
||||||
if (v === CUSTOM_PROVIDER_VALUE) {
|
if (v === CUSTOM_PROVIDER_VALUE) {
|
||||||
setMode("custom");
|
setMode("custom");
|
||||||
setProviderId("");
|
setProviderId("");
|
||||||
@ -316,22 +315,27 @@ function OpenCodeProviderFields({
|
|||||||
}
|
}
|
||||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="w-full"
|
||||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
size="md"
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
invalid={Boolean(fieldErrors.providerId)}
|
||||||
fieldErrors.providerId ? "border-danger" : "border-border",
|
options={[
|
||||||
)}
|
{
|
||||||
>
|
value: "",
|
||||||
<option value="" disabled>
|
label: catalog.loading
|
||||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
? "Chargement des providers…"
|
||||||
</option>
|
: "Choisir un provider…",
|
||||||
{filteredProviders.map((p) => (
|
disabled: true,
|
||||||
<option key={p.providerId} value={p.providerId}>
|
},
|
||||||
{p.displayName}
|
...filteredProviders.map((p) => ({
|
||||||
</option>
|
value: p.providerId,
|
||||||
))}
|
label: p.displayName,
|
||||||
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé…</option>
|
})),
|
||||||
</select>
|
{
|
||||||
|
value: CUSTOM_PROVIDER_VALUE,
|
||||||
|
label: "Autre / personnalisé…",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
{fieldErrors.providerId && (
|
{fieldErrors.providerId && (
|
||||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||||
)}
|
)}
|
||||||
@ -339,29 +343,26 @@ function OpenCodeProviderFields({
|
|||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<Caption>Modèle</Caption>
|
<Caption>Modèle</Caption>
|
||||||
<select
|
<SmallDropdown
|
||||||
aria-label={`${profile.name} model`}
|
aria-label={`${profile.name} model`}
|
||||||
value={model}
|
value={model}
|
||||||
disabled={providerId.length === 0}
|
disabled={providerId.length === 0}
|
||||||
onChange={(e) => {
|
onChange={(next) => {
|
||||||
setModel(e.target.value);
|
setModel(next);
|
||||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="w-full"
|
||||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
size="md"
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
invalid={Boolean(fieldErrors.model)}
|
||||||
fieldErrors.model ? "border-danger" : "border-border",
|
options={[
|
||||||
)}
|
{
|
||||||
>
|
value: "",
|
||||||
<option value="" disabled>
|
label: providerId.length === 0 ? "—" : "Choisir un modèle…",
|
||||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
disabled: true,
|
||||||
</option>
|
},
|
||||||
{models.map((m) => (
|
...models.map((m) => ({ value: m, label: m })),
|
||||||
<option key={m} value={m}>
|
]}
|
||||||
{m}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{fieldErrors.model && (
|
{fieldErrors.model && (
|
||||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -22,6 +22,11 @@ function renderSettings(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||||
|
fireEvent.click(screen.getByLabelText(label));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
async function waitReady() {
|
async function waitReady() {
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@ -191,12 +196,17 @@ describe("ProfilesSettings", () => {
|
|||||||
expect(screen.getByText("Local A")).toBeTruthy();
|
expect(screen.getByText("Local A")).toBeTruthy();
|
||||||
|
|
||||||
await createProfile();
|
await createProfile();
|
||||||
const select = await screen.findByLabelText(
|
await screen.findByLabelText(
|
||||||
"OpenCode + llama.cpp copy local model server",
|
"OpenCode + llama.cpp copy local model server",
|
||||||
);
|
);
|
||||||
fireEvent.change(select, { target: { value: server.id } });
|
chooseDropdownOption(
|
||||||
|
"OpenCode + llama.cpp copy local model server",
|
||||||
|
/Local A/,
|
||||||
|
);
|
||||||
|
|
||||||
const profileRow = select.closest("li");
|
const profileRow = screen
|
||||||
|
.getByLabelText("OpenCode + llama.cpp copy local model server")
|
||||||
|
.closest("li");
|
||||||
expect(profileRow).not.toBeNull();
|
expect(profileRow).not.toBeNull();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(profileRow as HTMLElement).getByRole("button", {
|
within(profileRow as HTMLElement).getByRole("button", {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { LocalModelServerConfig } from "@/domain";
|
import type { LocalModelServerConfig } from "@/domain";
|
||||||
import { cn } from "@/shared";
|
import { SmallDropdown } from "@/shared";
|
||||||
|
|
||||||
/** The sentinel option value standing for "no managed server". */
|
/** The sentinel option value standing for "no managed server". */
|
||||||
const NONE = "";
|
const NONE = "";
|
||||||
@ -34,24 +34,20 @@ export function ModelServerSelect({
|
|||||||
value !== undefined && !servers.some((s) => s.id === value);
|
value !== undefined && !servers.some((s) => s.id === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<SmallDropdown
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
value={value ?? NONE}
|
value={value ?? NONE}
|
||||||
onChange={(e) => onChange(e.target.value === NONE ? undefined : e.target.value)}
|
onChange={(next) => onChange(next === NONE ? undefined : next)}
|
||||||
className={cn(
|
options={[
|
||||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
{ value: NONE, label: "None (external endpoint)" },
|
||||||
"border border-border outline-none focus:border-primary",
|
...servers.map((s) => ({
|
||||||
)}
|
value: s.id,
|
||||||
>
|
label: `${s.name} — ${s.servedModelName}`,
|
||||||
<option value={NONE}>None (external endpoint)</option>
|
})),
|
||||||
{servers.map((s) => (
|
...(missing && value !== undefined
|
||||||
<option key={s.id} value={s.id}>
|
? [{ value, label: `${value} (unknown / removed)` }]
|
||||||
{s.name} — {s.servedModelName}
|
: []),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
{missing && (
|
|
||||||
<option value={value}>{value} (unknown / removed)</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,11 @@ const SERVER: LocalModelServerConfig = {
|
|||||||
stopPolicy: "stopOnAppExit",
|
stopPolicy: "stopOnAppExit",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||||
|
fireEvent.click(screen.getByLabelText(label));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
function setup(modelServer = new MockModelServerGateway()) {
|
function setup(modelServer = new MockModelServerGateway()) {
|
||||||
const gateways = { modelServer } as unknown as Gateways;
|
const gateways = { modelServer } as unknown as Gateways;
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
@ -433,9 +438,10 @@ describe("ModelServerSelect (F35)", () => {
|
|||||||
onChange={(id) => (picked = id)}
|
onChange={(id) => (picked = id)}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
|
expect(screen.getByLabelText("local model server").textContent).toContain(
|
||||||
expect(select.value).toBe("");
|
"None (external endpoint)",
|
||||||
fireEvent.change(select, { target: { value: SERVER.id } });
|
);
|
||||||
|
chooseDropdownOption("local model server", /Local A/);
|
||||||
expect(picked).toBe(SERVER.id);
|
expect(picked).toBe(SERVER.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -448,9 +454,7 @@ describe("ModelServerSelect (F35)", () => {
|
|||||||
onChange={(id) => (picked = id)}
|
onChange={(id) => (picked = id)}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.change(screen.getByLabelText("local model server"), {
|
chooseDropdownOption("local model server", "None (external endpoint)");
|
||||||
target: { value: "" },
|
|
||||||
});
|
|
||||||
expect(picked).toBeUndefined();
|
expect(picked).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,11 @@ async function waitForSkillsIdle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||||
|
fireEvent.click(screen.getByLabelText(label));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
/** Opens the SkillEditor, fills it, and saves. */
|
/** Opens the SkillEditor, fills it, and saves. */
|
||||||
async function createSkill(
|
async function createSkill(
|
||||||
name: string,
|
name: string,
|
||||||
@ -192,9 +197,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
|||||||
|
|
||||||
// Choose the skill and assign
|
// Choose the skill and assign
|
||||||
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
||||||
fireEvent.change(screen.getByLabelText("skill to assign"), {
|
chooseDropdownOption("skill to assign", "Deploy (project)");
|
||||||
target: { value: sk.id },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
|
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
|
||||||
|
|
||||||
// The agent record now carries the skill ref
|
// The agent record now carries the skill ref
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
import type { AgentProfile, Template } from "@/domain";
|
import type { AgentProfile, Template } from "@/domain";
|
||||||
import { Button, Input, cn } from "@/shared";
|
import { Button, Input, SmallDropdown, cn } from "@/shared";
|
||||||
|
|
||||||
export interface TemplateEditorProps {
|
export interface TemplateEditorProps {
|
||||||
/**
|
/**
|
||||||
@ -114,25 +114,19 @@ export function TemplateEditor({
|
|||||||
Default profile
|
Default profile
|
||||||
</label>
|
</label>
|
||||||
{profiles.length > 0 ? (
|
{profiles.length > 0 ? (
|
||||||
<select
|
<SmallDropdown
|
||||||
id="te-profile"
|
id="te-profile"
|
||||||
aria-label="template default profile"
|
aria-label="template default profile"
|
||||||
value={defaultProfileId}
|
value={defaultProfileId}
|
||||||
onChange={(e) => setDefaultProfileId(e.target.value)}
|
onChange={setDefaultProfileId}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className="w-full"
|
||||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
size="md"
|
||||||
"border border-border outline-none transition-colors",
|
options={[
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
{ value: "", label: "— none —" },
|
||||||
)}
|
...profiles.map((p) => ({ value: p.id, label: p.name })),
|
||||||
>
|
]}
|
||||||
<option value="">— none —</option>
|
/>
|
||||||
{profiles.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id="te-profile"
|
id="te-profile"
|
||||||
|
|||||||
@ -86,6 +86,11 @@ async function waitForAgentsIdle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||||
|
fireEvent.click(screen.getByLabelText(label));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the TemplateEditor overlay, fills the create-template form, and saves it.
|
* Opens the TemplateEditor overlay, fills the create-template form, and saves it.
|
||||||
* Adapted for the new fullscreen-editor flow:
|
* Adapted for the new fullscreen-editor flow:
|
||||||
@ -115,9 +120,12 @@ async function createTemplate(
|
|||||||
target: { value: content },
|
target: { value: content },
|
||||||
});
|
});
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
fireEvent.change(screen.getByLabelText("template default profile"), {
|
const profileField = screen.getByLabelText("template default profile");
|
||||||
target: { value: profileId },
|
if (profileField.tagName === "INPUT") {
|
||||||
});
|
fireEvent.change(profileField, { target: { value: profileId } });
|
||||||
|
} else {
|
||||||
|
chooseDropdownOption("template default profile", profileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save via the "Save template" button (aria-label on the submit button)
|
// Save via the "Save template" button (aria-label on the submit button)
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { SmallDropdown, type SmallDropdownOption } from "@/shared";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import { cn, zIndex } from "@/shared";
|
export type TicketViewportSelectOption = SmallDropdownOption;
|
||||||
|
|
||||||
export interface TicketViewportSelectOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketViewportSelectProps {
|
export interface TicketViewportSelectProps {
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
@ -19,18 +12,6 @@ export interface TicketViewportSelectProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopupPosition {
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
maxHeight: number;
|
|
||||||
placement: "top" | "bottom";
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIEWPORT_MARGIN = 8;
|
|
||||||
const MIN_POPUP_HEIGHT = 96;
|
|
||||||
const MAX_POPUP_HEIGHT = 280;
|
|
||||||
|
|
||||||
export function TicketViewportSelect({
|
export function TicketViewportSelect({
|
||||||
ariaLabel: ariaLabelProp,
|
ariaLabel: ariaLabelProp,
|
||||||
"aria-label": ariaLabelAttribute,
|
"aria-label": ariaLabelAttribute,
|
||||||
@ -40,153 +21,14 @@ export function TicketViewportSelect({
|
|||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: TicketViewportSelectProps) {
|
}: TicketViewportSelectProps) {
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
const popupRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [position, setPosition] = useState<PopupPosition | null>(null);
|
|
||||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
|
||||||
const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
|
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
|
||||||
const trigger = triggerRef.current;
|
|
||||||
if (!trigger) return;
|
|
||||||
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
||||||
const popupWidth = Math.max(rect.width, 160);
|
|
||||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
|
|
||||||
const spaceAbove = rect.top - VIEWPORT_MARGIN;
|
|
||||||
const placement =
|
|
||||||
spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
|
|
||||||
const available = Math.max(
|
|
||||||
MIN_POPUP_HEIGHT,
|
|
||||||
placement === "top" ? spaceAbove : spaceBelow,
|
|
||||||
);
|
|
||||||
const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
|
|
||||||
const left = Math.min(
|
|
||||||
Math.max(VIEWPORT_MARGIN, rect.left),
|
|
||||||
Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
|
|
||||||
);
|
|
||||||
const top =
|
|
||||||
placement === "top"
|
|
||||||
? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
|
|
||||||
: Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
|
|
||||||
|
|
||||||
setPosition({ top, left, width: popupWidth, maxHeight, placement });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
updatePosition();
|
|
||||||
}, [open, updatePosition]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
function onPointerDown(event: PointerEvent) {
|
|
||||||
const target = event.target as Node | null;
|
|
||||||
if (
|
|
||||||
target &&
|
|
||||||
(triggerRef.current?.contains(target) || popupRef.current?.contains(target))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
setOpen(false);
|
|
||||||
triggerRef.current?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onViewportChange = () => {
|
|
||||||
updatePosition();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("pointerdown", onPointerDown);
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
window.addEventListener("resize", onViewportChange);
|
|
||||||
window.addEventListener("scroll", onViewportChange, true);
|
|
||||||
window.visualViewport?.addEventListener("resize", onViewportChange);
|
|
||||||
window.visualViewport?.addEventListener("scroll", onViewportChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
|
||||||
window.removeEventListener("resize", onViewportChange);
|
|
||||||
window.removeEventListener("scroll", onViewportChange, true);
|
|
||||||
window.visualViewport?.removeEventListener("resize", onViewportChange);
|
|
||||||
window.visualViewport?.removeEventListener("scroll", onViewportChange);
|
|
||||||
};
|
|
||||||
}, [open, updatePosition]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SmallDropdown
|
||||||
<button
|
aria-label={ariaLabelAttribute ?? ariaLabelProp}
|
||||||
ref={triggerRef}
|
value={value}
|
||||||
type="button"
|
options={options}
|
||||||
aria-label={ariaLabel}
|
onChange={onChange}
|
||||||
aria-haspopup="listbox"
|
className={className}
|
||||||
aria-expanded={open}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
/>
|
||||||
onClick={() => setOpen((current) => !current)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 items-center justify-between gap-2 rounded-md bg-raised px-2 text-xs text-content",
|
|
||||||
"border border-border outline-none transition-colors",
|
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
|
|
||||||
<span aria-hidden="true" className="text-muted">▾</span>
|
|
||||||
</button>
|
|
||||||
{open &&
|
|
||||||
position &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
ref={popupRef}
|
|
||||||
role="listbox"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
data-placement={position.placement}
|
|
||||||
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
|
|
||||||
style={{
|
|
||||||
top: position.top,
|
|
||||||
left: position.left,
|
|
||||||
width: position.width,
|
|
||||||
maxHeight: position.maxHeight,
|
|
||||||
zIndex: zIndex.transientPopup,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={option.value === value}
|
|
||||||
disabled={option.disabled}
|
|
||||||
onClick={() => {
|
|
||||||
if (option.disabled) return;
|
|
||||||
onChange(option.value);
|
|
||||||
setOpen(false);
|
|
||||||
triggerRef.current?.focus();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-8 w-full items-center px-2 text-left text-xs text-content",
|
|
||||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
|
||||||
option.value === value && "bg-raised",
|
|
||||||
option.disabled && "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 truncate">{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,3 +43,6 @@ export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
|
|||||||
|
|
||||||
export { DockRegion } from "./ui/DockRegion";
|
export { DockRegion } from "./ui/DockRegion";
|
||||||
export type { DockRegionProps, DockSide } from "./ui/DockRegion";
|
export type { DockRegionProps, DockSide } from "./ui/DockRegion";
|
||||||
|
|
||||||
|
export { SmallDropdown } from "./ui/SmallDropdown";
|
||||||
|
export type { SmallDropdownProps, SmallDropdownOption } from "./ui/SmallDropdown";
|
||||||
|
|||||||
296
frontend/src/shared/ui/SmallDropdown.tsx
Normal file
296
frontend/src/shared/ui/SmallDropdown.tsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
import { zIndex } from "./zIndex";
|
||||||
|
|
||||||
|
export interface SmallDropdownOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmallDropdownProps {
|
||||||
|
ariaLabel?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
value: string;
|
||||||
|
options: SmallDropdownOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
invalid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopupPosition {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
maxHeight: number;
|
||||||
|
placement: "top" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
const MIN_POPUP_HEIGHT = 96;
|
||||||
|
const MAX_POPUP_HEIGHT = 280;
|
||||||
|
|
||||||
|
function firstEnabledIndex(options: SmallDropdownOption[]): number {
|
||||||
|
return options.findIndex((option) => !option.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextEnabledIndex(
|
||||||
|
options: SmallDropdownOption[],
|
||||||
|
from: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): number {
|
||||||
|
if (options.length === 0) return -1;
|
||||||
|
for (let offset = 1; offset <= options.length; offset += 1) {
|
||||||
|
const index = (from + offset * direction + options.length) % options.length;
|
||||||
|
if (!options[index]?.disabled) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmallDropdown({
|
||||||
|
ariaLabel: ariaLabelProp,
|
||||||
|
"aria-label": ariaLabelAttribute,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
size = "sm",
|
||||||
|
invalid = false,
|
||||||
|
}: SmallDropdownProps) {
|
||||||
|
const idBase = useId();
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const popupRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState<PopupPosition | null>(null);
|
||||||
|
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||||
|
const selected = selectedIndex >= 0 ? options[selectedIndex] : options[0];
|
||||||
|
const [activeIndex, setActiveIndex] = useState(
|
||||||
|
selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options),
|
||||||
|
);
|
||||||
|
const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
const popupWidth = Math.max(rect.width, 160);
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
|
||||||
|
const spaceAbove = rect.top - VIEWPORT_MARGIN;
|
||||||
|
const placement =
|
||||||
|
spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
|
||||||
|
const available = Math.max(
|
||||||
|
MIN_POPUP_HEIGHT,
|
||||||
|
placement === "top" ? spaceAbove : spaceBelow,
|
||||||
|
);
|
||||||
|
const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(VIEWPORT_MARGIN, rect.left),
|
||||||
|
Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
|
||||||
|
);
|
||||||
|
const top =
|
||||||
|
placement === "top"
|
||||||
|
? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
|
||||||
|
: Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
|
||||||
|
|
||||||
|
setPosition({ top, left, width: popupWidth, maxHeight, placement });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openPopup = useCallback(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
const nextActive = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options);
|
||||||
|
setActiveIndex(nextActive);
|
||||||
|
setOpen(true);
|
||||||
|
}, [disabled, options, selectedIndex]);
|
||||||
|
|
||||||
|
const closePopup = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectOption = useCallback(
|
||||||
|
(option: SmallDropdownOption | undefined) => {
|
||||||
|
if (!option || option.disabled) return;
|
||||||
|
onChange(option.value);
|
||||||
|
closePopup();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
},
|
||||||
|
[closePopup, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
updatePosition();
|
||||||
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(triggerRef.current?.contains(target) || popupRef.current?.contains(target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closePopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closePopup();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((current) =>
|
||||||
|
nextEnabledIndex(options, current, event.key === "ArrowDown" ? 1 : -1),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex(firstEnabledIndex(options));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex(nextEnabledIndex(options, 0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectOption(options[activeIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onViewportChange = () => {
|
||||||
|
updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("resize", onViewportChange);
|
||||||
|
window.addEventListener("scroll", onViewportChange, true);
|
||||||
|
window.visualViewport?.addEventListener("resize", onViewportChange);
|
||||||
|
window.visualViewport?.addEventListener("scroll", onViewportChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("resize", onViewportChange);
|
||||||
|
window.removeEventListener("scroll", onViewportChange, true);
|
||||||
|
window.visualViewport?.removeEventListener("resize", onViewportChange);
|
||||||
|
window.visualViewport?.removeEventListener("scroll", onViewportChange);
|
||||||
|
};
|
||||||
|
}, [activeIndex, closePopup, open, options, selectOption, updatePosition]);
|
||||||
|
|
||||||
|
const popupId = `${idBase}-listbox`;
|
||||||
|
const activeOptionId =
|
||||||
|
open && activeIndex >= 0 ? `${idBase}-option-${activeIndex}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? popupId : undefined}
|
||||||
|
aria-activedescendant={activeOptionId}
|
||||||
|
aria-invalid={invalid ? "true" : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => (open ? closePopup() : openPopup())}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
!open &&
|
||||||
|
(event.key === "ArrowDown" ||
|
||||||
|
event.key === "ArrowUp" ||
|
||||||
|
event.key === "Enter" ||
|
||||||
|
event.key === " ")
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
openPopup();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-between gap-2 rounded-md bg-raised text-content",
|
||||||
|
"border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
invalid ? "border-danger" : "border-border",
|
||||||
|
size === "md" ? "h-9 px-3 text-sm" : "h-8 px-2 text-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
|
||||||
|
<span aria-hidden="true" className="text-muted">
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
position &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={popupRef}
|
||||||
|
id={popupId}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
data-placement={position.placement}
|
||||||
|
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
|
||||||
|
style={{
|
||||||
|
top: position.top,
|
||||||
|
left: position.left,
|
||||||
|
width: position.width,
|
||||||
|
maxHeight: position.maxHeight,
|
||||||
|
zIndex: zIndex.transientPopup,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={`${option.value}-${index}`}
|
||||||
|
id={`${idBase}-option-${index}`}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!option.disabled) setActiveIndex(index);
|
||||||
|
}}
|
||||||
|
onClick={() => selectOption(option)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-8 w-full items-center px-2 text-left text-content",
|
||||||
|
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||||
|
size === "md" ? "text-sm" : "text-xs",
|
||||||
|
(option.value === value || index === activeIndex) && "bg-raised",
|
||||||
|
option.disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Input } from "./Input";
|
import { Input } from "./Input";
|
||||||
import { Field } from "./Field";
|
import { Field } from "./Field";
|
||||||
|
import { SmallDropdown } from "./SmallDropdown";
|
||||||
import { Tabs } from "./Tabs";
|
import { Tabs } from "./Tabs";
|
||||||
|
|
||||||
describe("Button", () => {
|
describe("Button", () => {
|
||||||
@ -115,3 +116,48 @@ describe("Tabs", () => {
|
|||||||
expect(onClose).toHaveBeenCalledWith("b");
|
expect(onClose).toHaveBeenCalledWith("b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SmallDropdown", () => {
|
||||||
|
const options = [
|
||||||
|
{ value: "", label: "None" },
|
||||||
|
{ value: "a", label: "Alpha" },
|
||||||
|
{ value: "b", label: "Beta", disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("opens a listbox and reports the selected value", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<SmallDropdown
|
||||||
|
aria-label="test dropdown"
|
||||||
|
value=""
|
||||||
|
options={options}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("test dropdown"));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "Alpha" }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("a");
|
||||||
|
expect(screen.queryByRole("listbox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports keyboard selection and ignores disabled options", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<SmallDropdown
|
||||||
|
aria-label="test dropdown"
|
||||||
|
value=""
|
||||||
|
options={options}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByLabelText("test dropdown");
|
||||||
|
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||||
|
fireEvent.keyDown(document, { key: "ArrowDown" });
|
||||||
|
fireEvent.keyDown(document, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("a");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user