feat(ui): standardize responsive control sizing (#3563)
This commit is contained in:
@@ -156,6 +156,7 @@ export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelP
|
||||
<PanelRoot data-testid="browser-panel">
|
||||
<PanelHeaderBar>
|
||||
<Input
|
||||
controlSize="none"
|
||||
value={url.displayDraft}
|
||||
onChange={(e) => url.setUrlDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -151,7 +151,7 @@ function DynamicRouteRecoveryActions({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 gap-1.5"
|
||||
className="gap-1.5"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction("retry")}
|
||||
data-testid="dynamic-route-retry"
|
||||
@@ -162,7 +162,7 @@ function DynamicRouteRecoveryActions({
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="min-h-11 min-w-11 gap-1.5"
|
||||
className="gap-1.5"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction("skip")}
|
||||
data-testid="dynamic-route-try-next"
|
||||
@@ -174,7 +174,6 @@ function DynamicRouteRecoveryActions({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction("cancel_wait")}
|
||||
data-testid="dynamic-route-cancel-wait"
|
||||
@@ -185,7 +184,6 @@ function DynamicRouteRecoveryActions({
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="min-h-11 min-w-11"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction("stop")}
|
||||
data-testid="dynamic-route-stop"
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useArchiveAndSwitchTask, useTaskActions } from "@/hooks/use-task-action
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import type { MessageAction } from "@/components/task/chat/types";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
|
||||
export const ACTION_ICON_MAP: Record<string, ElementType> = {
|
||||
archive: IconArchive,
|
||||
@@ -87,6 +88,71 @@ type StandardActionButtonProps = Omit<ActionButtonProps, "action"> & {
|
||||
action: MessageAction & { type: "archive_task" | "ws_request" };
|
||||
};
|
||||
|
||||
type ArchiveAndSwitchTask = ReturnType<typeof useArchiveAndSwitchTask>;
|
||||
|
||||
async function executeStandardAction(
|
||||
action: StandardActionButtonProps["action"],
|
||||
taskId: string | null,
|
||||
archiveAndSwitch: ArchiveAndSwitchTask,
|
||||
onCompleted?: () => void,
|
||||
): Promise<void> {
|
||||
if (action.type === "archive_task") {
|
||||
if (taskId) await archiveAndSwitch(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getWebSocketClient();
|
||||
const params = action.params as { method: string; payload: Record<string, unknown> } | undefined;
|
||||
// i18n-exempt: technical error for unavailable WebSocket recovery client.
|
||||
if (!client || !params) throw new Error("WebSocket recovery request is unavailable");
|
||||
await client.request(params.method, params.payload);
|
||||
onCompleted?.();
|
||||
}
|
||||
|
||||
type ActionButtonVisualProps = {
|
||||
action: MessageAction;
|
||||
compact: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
labelOverride?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
function ActionButtonVisual({
|
||||
action,
|
||||
compact,
|
||||
disabled,
|
||||
onClick,
|
||||
labelOverride,
|
||||
destructive = false,
|
||||
}: ActionButtonVisualProps): ReactElement {
|
||||
const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null;
|
||||
const sizingClassName = controlSizingClassName(
|
||||
compact ? "compact" : "standard",
|
||||
compact ? "max-md:min-h-11 [@media(pointer:coarse)]:min-h-11" : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={compact ? "ghost" : "outline"}
|
||||
size={compact ? "sm" : "default"}
|
||||
className={cn(
|
||||
compact
|
||||
? "shrink-0 px-2 text-xs cursor-pointer"
|
||||
: "w-full gap-1.5 text-xs cursor-pointer sm:w-auto",
|
||||
sizingClassName,
|
||||
destructive && "text-destructive hover:text-destructive",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
data-testid={action.test_id}
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{labelOverride ?? action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function StandardActionButton({
|
||||
action,
|
||||
messageTaskId,
|
||||
@@ -103,24 +169,8 @@ function StandardActionButton({
|
||||
if (state === "busy") return;
|
||||
setState("busy");
|
||||
try {
|
||||
switch (action.type) {
|
||||
case "archive_task": {
|
||||
if (taskId) await archiveAndSwitch(taskId);
|
||||
break;
|
||||
}
|
||||
case "ws_request": {
|
||||
const client = getWebSocketClient();
|
||||
const params = action.params as
|
||||
| { method: string; payload: Record<string, unknown> }
|
||||
| undefined;
|
||||
// i18n-exempt: technical error for unavailable WebSocket recovery client.
|
||||
if (!client || !params) throw new Error("WebSocket recovery request is unavailable");
|
||||
await client.request(params.method, params.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await executeStandardAction(action, taskId, archiveAndSwitch, onCompleted);
|
||||
setState("done");
|
||||
if (action.type === "ws_request") onCompleted?.();
|
||||
} catch {
|
||||
setState("error");
|
||||
setTimeout(() => setState("idle"), 3000);
|
||||
@@ -133,27 +183,18 @@ function StandardActionButton({
|
||||
// buttons, so this stale one would just confuse the user.
|
||||
if (state === "done" && action.type === "ws_request") return null;
|
||||
|
||||
const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null;
|
||||
const disabled = state === "busy" || state === "done";
|
||||
const isDestructive = action.variant === "destructive";
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant={compact ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
compact
|
||||
? "h-auto min-h-11 shrink-0 px-2 text-xs cursor-pointer sm:min-h-8"
|
||||
: "h-auto min-h-11 w-full gap-1.5 text-xs cursor-pointer sm:min-h-8 sm:w-auto",
|
||||
isDestructive && "text-destructive hover:text-destructive",
|
||||
)}
|
||||
<ActionButtonVisual
|
||||
action={action}
|
||||
compact={compact}
|
||||
disabled={disabled}
|
||||
onClick={execute}
|
||||
data-testid={action.test_id}
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{labelOverride ?? action.label}
|
||||
</Button>
|
||||
labelOverride={labelOverride}
|
||||
destructive={isDestructive}
|
||||
/>
|
||||
);
|
||||
|
||||
if (action.tooltip) {
|
||||
@@ -171,6 +212,33 @@ type DeleteActionButtonProps = Omit<ActionButtonProps, "action"> & {
|
||||
action: MessageAction & { type: "delete_task" };
|
||||
};
|
||||
|
||||
type DeleteTaskById = ReturnType<typeof useTaskActions>["deleteTaskById"];
|
||||
type RemoveTaskFromBoard = ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"];
|
||||
type TaskStore = ReturnType<typeof useAppStoreApi>;
|
||||
|
||||
async function deleteTaskAndRemoveFromBoard({
|
||||
taskId,
|
||||
cascade,
|
||||
discardWorktreeChanges,
|
||||
store,
|
||||
deleteTaskById,
|
||||
removeTaskFromBoard,
|
||||
}: {
|
||||
taskId: string;
|
||||
cascade: boolean;
|
||||
discardWorktreeChanges: boolean;
|
||||
store: TaskStore;
|
||||
deleteTaskById: DeleteTaskById;
|
||||
removeTaskFromBoard: RemoveTaskFromBoard;
|
||||
}): Promise<void> {
|
||||
const { activeTaskId, activeSessionId } = store.getState().tasks;
|
||||
await deleteTaskById(taskId, { cascade, discardWorktreeChanges });
|
||||
await removeTaskFromBoard(taskId, {
|
||||
wasActiveTaskId: activeTaskId,
|
||||
wasActiveSessionId: activeSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function DeleteActionButton({
|
||||
action,
|
||||
messageTaskId,
|
||||
@@ -199,11 +267,13 @@ function DeleteActionButton({
|
||||
if (!taskId || state === "busy") return;
|
||||
setState("busy");
|
||||
try {
|
||||
const { activeTaskId, activeSessionId } = store.getState().tasks;
|
||||
await deleteTaskById(taskId, { cascade, discardWorktreeChanges });
|
||||
await removeTaskFromBoard(taskId, {
|
||||
wasActiveTaskId: activeTaskId,
|
||||
wasActiveSessionId: activeSessionId,
|
||||
await deleteTaskAndRemoveFromBoard({
|
||||
taskId,
|
||||
cascade,
|
||||
discardWorktreeChanges,
|
||||
store,
|
||||
deleteTaskById,
|
||||
removeTaskFromBoard,
|
||||
});
|
||||
setState("done");
|
||||
} catch {
|
||||
@@ -218,25 +288,16 @@ function DeleteActionButton({
|
||||
if (state !== "busy") setDeleteDialogOpen(true);
|
||||
}, [state]);
|
||||
|
||||
const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null;
|
||||
const disabled = state === "busy" || state === "done";
|
||||
const button = (
|
||||
<Button
|
||||
variant={compact ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
compact
|
||||
? "h-auto min-h-11 shrink-0 px-2 text-xs cursor-pointer sm:min-h-8"
|
||||
: "h-auto min-h-11 w-full gap-1.5 text-xs cursor-pointer sm:min-h-8 sm:w-auto",
|
||||
"text-destructive hover:text-destructive",
|
||||
)}
|
||||
<ActionButtonVisual
|
||||
action={action}
|
||||
compact={compact}
|
||||
disabled={disabled}
|
||||
onClick={execute}
|
||||
data-testid={action.test_id}
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{labelOverride ?? action.label}
|
||||
</Button>
|
||||
labelOverride={labelOverride}
|
||||
destructive
|
||||
/>
|
||||
);
|
||||
const dialog = (
|
||||
<TaskDeleteConfirmDialog
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { SessionRecoveryAction } from "@/lib/services/session-recovery-service";
|
||||
import type { MessageAction } from "@/components/task/chat/types";
|
||||
import { ACTION_ICON_MAP, ActionButton } from "./action-message-actions";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
|
||||
export function sessionRecoveryAction(action: MessageAction): SessionRecoveryAction | null {
|
||||
if (action.type !== "ws_request" || !action.params) return null;
|
||||
@@ -130,8 +131,10 @@ function SessionRecoveryActionButton({
|
||||
const button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 w-full gap-1.5 text-xs cursor-pointer sm:min-h-8 sm:w-auto"
|
||||
className={controlSizingClassName(
|
||||
"standard",
|
||||
"w-full gap-1.5 text-xs cursor-pointer sm:w-auto",
|
||||
)}
|
||||
disabled={busyAction !== null}
|
||||
onClick={onClick}
|
||||
data-testid={action.test_id}
|
||||
|
||||
@@ -374,7 +374,8 @@ describe("ActionMessage — running stall notice", () => {
|
||||
expect(notice.className).not.toContain("text-red");
|
||||
expect(notice.querySelector("svg")).toBeNull();
|
||||
const button = screen.getByTestId(STALL_CANCEL_TEST_ID);
|
||||
expect(button.className).toContain("min-h-11");
|
||||
expect(button.className).toContain("h-6");
|
||||
expect(button.className).toContain("max-md:min-h-11");
|
||||
expect(button.className).not.toContain("w-full");
|
||||
});
|
||||
|
||||
@@ -453,8 +454,8 @@ describe("ActionMessage — missing PR branch", () => {
|
||||
expect(screen.getByText("codex/enhance-prompt-result-delivery")).toBeTruthy();
|
||||
const technicalDetails = screen.getByText(TECHNICAL_DETAILS).closest("details");
|
||||
expect(technicalDetails?.open).toBe(false);
|
||||
expect(screen.getByTestId("missing-branch-archive-button").className).toContain("min-h-11");
|
||||
expect(screen.getByTestId("missing-branch-delete-button").className).toContain("min-h-11");
|
||||
expect(screen.getByTestId("missing-branch-archive-button").className).toContain("h-7");
|
||||
expect(screen.getByTestId("missing-branch-delete-button").className).toContain("h-7");
|
||||
|
||||
fireEvent.click(screen.getByText(TECHNICAL_DETAILS));
|
||||
expect(technicalDetails?.open).toBe(true);
|
||||
@@ -521,7 +522,8 @@ describe("ActionMessage — provider quota recovery", () => {
|
||||
expect(screen.getByText(/kimi-k3/i)).toBeTruthy();
|
||||
const details = screen.getByText(TECHNICAL_DETAILS).closest("details");
|
||||
expect(details?.open).toBe(false);
|
||||
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("min-h-11");
|
||||
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("h-7");
|
||||
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("max-md:h-11");
|
||||
|
||||
fireEvent.click(screen.getByText(TECHNICAL_DETAILS));
|
||||
expect(details?.open).toBe(true);
|
||||
@@ -640,7 +642,8 @@ describe("ActionMessage — remediation link", () => {
|
||||
expect(link.href).toBe(REMEDIATION_URL);
|
||||
expect(link.target).toBe("_blank");
|
||||
expect(link.rel).toBe("noopener noreferrer");
|
||||
expect(link.className).toContain("min-h-11");
|
||||
expect(link.className).toContain("h-7");
|
||||
expect(link.className).toContain("max-md:h-11");
|
||||
// The sanitized message and collapsed details stay URL-free.
|
||||
expect(screen.queryByText(/opencode\.ai\/workspace/i)).toBeNull();
|
||||
expect(screen.getByTestId("provider-quota-recovery").textContent).toContain(QUOTA_OUTPUT);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createXAxisTickFormatter, formatYAxisTick } from "./chart-format";
|
||||
import { useRichOutputChartAnimations } from "./chart-motion";
|
||||
import { useChartPlotVisibility } from "./chart-visibility";
|
||||
import type { RichOutputChartBlock } from "./types";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
|
||||
const HOST_SERIES_COLORS = [
|
||||
"var(--chart-1)",
|
||||
@@ -85,7 +86,7 @@ function SeriesLegend({
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={isVisible}
|
||||
className={`flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`}
|
||||
className={`${controlSizingClassName("standard")} flex cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`}
|
||||
data-testid={`rich-output-chart-legend-${key}`}
|
||||
onClick={() => onToggle(key)}
|
||||
>
|
||||
|
||||
@@ -111,8 +111,7 @@ export function FilePreviewBlock({ block, sessionId, onOpenFile }: FilePreviewBl
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 cursor-pointer gap-1.5 px-2.5 min-[640px]:min-h-9"
|
||||
className="cursor-pointer gap-1.5 px-2.5"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={previewId}
|
||||
onClick={handleToggle}
|
||||
@@ -129,8 +128,7 @@ export function FilePreviewBlock({ block, sessionId, onOpenFile }: FilePreviewBl
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 cursor-pointer gap-1.5 px-2.5 min-[640px]:min-h-9"
|
||||
className="cursor-pointer gap-1.5 px-2.5"
|
||||
onClick={() => onOpenFile(block.path, block.repo)}
|
||||
data-testid="rich-output-file-open"
|
||||
>
|
||||
|
||||
@@ -234,9 +234,8 @@ function CompletedSessionActions({
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
data-testid="recovery-resume-button"
|
||||
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
onClick={handleResume}
|
||||
disabled={busyAction !== null || !profileExists}
|
||||
>
|
||||
@@ -252,8 +251,7 @@ function CompletedSessionActions({
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
onClick={() => {
|
||||
if (taskId) onShowDialog(true);
|
||||
}}
|
||||
@@ -300,9 +298,8 @@ function RecoverableSessionButtons({
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
data-testid="recovery-resume-button"
|
||||
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
onClick={onResume}
|
||||
disabled={busyAction !== null || !profileExists}
|
||||
>
|
||||
@@ -318,8 +315,7 @@ function RecoverableSessionButtons({
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
className="w-full shrink-0 gap-1.5 cursor-pointer sm:w-auto"
|
||||
onClick={onFreshStart}
|
||||
disabled={busyAction !== null}
|
||||
data-testid="recovery-fresh-button"
|
||||
|
||||
@@ -62,7 +62,11 @@ describe("CommitDiffView error state", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Commit detail unavailable");
|
||||
expect(screen.queryByText("No files in this commit")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
const retryButton = screen.getByRole("button", { name: "Retry" });
|
||||
expect(retryButton.getAttribute("data-size")).toBe("default");
|
||||
expect(retryButton.className).toContain("h-7");
|
||||
expect(retryButton.className).toContain("pointer:coarse");
|
||||
fireEvent.click(retryButton);
|
||||
expect(mocks.refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,9 +256,8 @@ function CommitDetailErrorState({
|
||||
<span>{error}</span>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 cursor-pointer"
|
||||
className="cursor-pointer"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onRetry()}
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
|
||||
@@ -31,6 +31,7 @@ export function FileBrowserSearchHeader({
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
controlSize="none"
|
||||
value={localSearchQuery}
|
||||
onChange={onSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -535,6 +535,7 @@ export function TreeNodeName({
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
controlSize="none"
|
||||
value={rename.renameValue}
|
||||
onChange={(e) => rename.setRenameValue(e.target.value)}
|
||||
onKeyDown={rename.handleRenameKeyDown}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function InlineFileInput({ depth, onSubmit, onCancel }: InlineFileInputPr
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
controlSize="none"
|
||||
className="h-5 text-xs px-1 py-0 border-muted-foreground/30"
|
||||
placeholder={t("task:filename")}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeRemediationUrl } from "@/lib/remediation-url";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
|
||||
/**
|
||||
* Shared localized external link for the allowlisted provider remediation
|
||||
@@ -23,7 +24,8 @@ export function RemediationLink({ url, className }: { url?: string; className?:
|
||||
rel="noopener noreferrer"
|
||||
data-testid="remediation-link"
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center gap-1.5 text-xs text-primary underline underline-offset-4 cursor-pointer sm:min-h-8",
|
||||
controlSizingClassName("standard", "inline-flex items-center gap-1.5"),
|
||||
"text-xs text-primary underline underline-offset-4 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -135,7 +135,7 @@ export function ContextSelect({
|
||||
<label className="text-xs font-medium text-muted-foreground">{t("task:context")}</label>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Select value={value} onValueChange={onValueChange} disabled={isSummarizing}>
|
||||
<SelectTrigger className="!h-11 w-full min-w-0 text-xs sm:!h-7">
|
||||
<SelectTrigger className="w-full min-w-0 text-xs">
|
||||
<SelectValue>{isSummarizing ? t("task:summarizing") : displayLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@kandev/ui/command";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildOptionGroups, hasGroupedOptions } from "./filter-option-groups";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -57,7 +58,7 @@ export function FilterMultiSelect({
|
||||
type="button"
|
||||
data-testid="filter-value-multi"
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md border border-input bg-transparent px-2 text-xs transition-colors hover:bg-accent/40",
|
||||
`${controlSizingClassName("standard")} flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md border border-input bg-transparent px-2 text-xs transition-colors hover:bg-accent/40`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function GroupPicker({ value, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as GroupKey)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-full text-xs" data-testid="group-key-select">
|
||||
<SelectTrigger className="w-full text-xs" data-testid="group-key-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function TypedSortPicker<Key extends string>({
|
||||
className={`flex items-center gap-1.5${mobile ? " [&_button]:min-h-11 [&_[role=combobox]]:min-h-11" : ""}`}
|
||||
>
|
||||
<Select value={value.key} onValueChange={(key) => onChange({ ...value, key: key as Key })}>
|
||||
<SelectTrigger size="sm" className="h-7 min-w-0 flex-1 text-xs" data-testid={testIds.key}>
|
||||
<SelectTrigger className="min-w-0 flex-1 text-xs" data-testid={testIds.key}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -63,8 +63,7 @@ export function TypedSortPicker<Key extends string>({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={mobile ? "h-11 cursor-pointer" : "h-7 cursor-pointer"}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
onChange({ ...value, direction: value.direction === "asc" ? "desc" : "asc" })
|
||||
}
|
||||
|
||||
@@ -114,11 +114,7 @@ export function TypedFilterClauseEditor<Dimension extends string, Op extends str
|
||||
value={clause.dimension}
|
||||
onValueChange={(value) => changeDimension(value as Dimension)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 w-32 shrink-0 text-xs"
|
||||
data-testid={testIds.dimension}
|
||||
>
|
||||
<SelectTrigger className="w-32 shrink-0 text-xs" data-testid={testIds.dimension}>
|
||||
<SelectValue>{getDimensionLabel(clause.dimension)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -131,7 +127,7 @@ export function TypedFilterClauseEditor<Dimension extends string, Op extends str
|
||||
</Select>
|
||||
|
||||
<Select value={clause.op} onValueChange={(value) => changeOp(value as Op)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-24 shrink-0 text-xs" data-testid={testIds.op}>
|
||||
<SelectTrigger className="w-24 shrink-0 text-xs" data-testid={testIds.op}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -150,7 +146,6 @@ export function TypedFilterClauseEditor<Dimension extends string, Op extends str
|
||||
valueTestId={
|
||||
meta.valueKind === "text" ? (testIds.textValue ?? testIds.value) : testIds.value
|
||||
}
|
||||
mobile={mobile}
|
||||
onChange={(value) => onChange({ ...clause, value })}
|
||||
valuePlaceholder={meta.placeholder ?? t("task:value")}
|
||||
selectValuePlaceholder={t("task:selectValue")}
|
||||
@@ -161,7 +156,7 @@ export function TypedFilterClauseEditor<Dimension extends string, Op extends str
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
data-testid={testIds.remove}
|
||||
aria-label={t("task:removeFilter")}
|
||||
@@ -191,7 +186,6 @@ function FilterValueInput<Dimension extends string, Op extends string>({
|
||||
meta,
|
||||
options,
|
||||
valueTestId,
|
||||
mobile,
|
||||
onChange,
|
||||
valuePlaceholder,
|
||||
selectValuePlaceholder,
|
||||
@@ -201,7 +195,6 @@ function FilterValueInput<Dimension extends string, Op extends string>({
|
||||
meta: TypedFilterMeta<Dimension, Op>;
|
||||
options: ValueOption[];
|
||||
valueTestId: string;
|
||||
mobile: boolean;
|
||||
onChange: (value: TypedFilterValue) => void;
|
||||
valuePlaceholder: string;
|
||||
selectValuePlaceholder: string;
|
||||
@@ -215,7 +208,7 @@ function FilterValueInput<Dimension extends string, Op extends string>({
|
||||
value={String(clause.value ?? "")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={valuePlaceholder}
|
||||
className={cn("h-7 min-w-0 flex-1 text-xs", mobile && "h-11")}
|
||||
className="min-w-0 flex-1 text-xs"
|
||||
data-testid={valueTestId}
|
||||
/>
|
||||
);
|
||||
@@ -224,20 +217,13 @@ function FilterValueInput<Dimension extends string, Op extends string>({
|
||||
const multi = clause.op === "in" || clause.op === "not_in";
|
||||
if (multi) {
|
||||
const selected = Array.isArray(clause.value) ? clause.value.map(String) : [];
|
||||
return (
|
||||
<FilterMultiSelect
|
||||
options={options}
|
||||
selected={selected}
|
||||
onChange={onChange}
|
||||
className={mobile ? "h-11" : undefined}
|
||||
/>
|
||||
);
|
||||
return <FilterMultiSelect options={options} selected={selected} onChange={onChange} />;
|
||||
}
|
||||
|
||||
const current = String(clause.value ?? "");
|
||||
return (
|
||||
<Select value={current} onValueChange={onChange}>
|
||||
<SelectTrigger size="sm" className="h-7 min-w-0 flex-1 text-xs" data-testid={valueTestId}>
|
||||
<SelectTrigger className="min-w-0 flex-1 text-xs" data-testid={valueTestId}>
|
||||
<SelectValue placeholder={selectValuePlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -175,6 +175,7 @@ function NameInput({
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
controlSize="none"
|
||||
autoFocus
|
||||
aria-label={mode === "rename" ? t("task:viewName") : t("task:newViewName")}
|
||||
value={value}
|
||||
|
||||
@@ -179,7 +179,7 @@ function AttachmentBody({ doc }: { doc: TaskDocument }) {
|
||||
download={doc.filename ?? doc.key}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer">
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<IconDownload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
@@ -426,7 +426,7 @@ function TopBarRight({
|
||||
)}
|
||||
{officeTaskHref && (
|
||||
<TopbarCluster label={t("task:openInOfficeView")} className="[&_a]:h-7 [&_a]:text-xs">
|
||||
<Button asChild size="sm" variant="outline" className="h-7 cursor-pointer px-2">
|
||||
<Button asChild size="sm" variant="outline" className="cursor-pointer px-2">
|
||||
<Link href={officeTaskHref}>{t("task:openInOfficeView")}</Link>
|
||||
</Button>
|
||||
</TopbarCluster>
|
||||
|
||||
@@ -517,9 +517,9 @@ function StepDisclosureBody({
|
||||
<Button
|
||||
type="button"
|
||||
data-testid={`workflow-step-disclosure-move-${step.id}`}
|
||||
size="sm"
|
||||
size="default"
|
||||
variant="default"
|
||||
className="h-7 shrink-0 cursor-pointer rounded-sm px-2.5 text-xs [@media(pointer:coarse)]:h-11"
|
||||
className="shrink-0 cursor-pointer rounded-sm px-2.5 text-xs"
|
||||
disabled={movingToStepId !== null}
|
||||
onClick={() => void onMove(step.id)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user