Clarify accessibility handling for panel controls

This commit is contained in:
murashit 2026-06-25 19:48:45 +09:00
parent b8a085b4bf
commit 0a13754de7
16 changed files with 201 additions and 108 deletions

View file

@ -64,7 +64,6 @@
},
"rules": {
"preset": "recommended",
"a11y": "info",
"complexity": {
"useLiteralKeys": "off"
},

View file

@ -14,7 +14,7 @@ Use this as the normal edit loop. `npm run fix` applies Biome formatting, import
Use focused scripts for tight loops: `npm run typecheck`, `npm run test`, `npm run build`, or the targeted `npm run check:*` scripts. CI and release preflight run the same `npm run check` command as local development.
Biome owns formatting, import organization, general JavaScript/TypeScript/JSON/CSS linting, Preact-compatible React domain rules, GritQL source-shape plugins, GritQL import-boundary restrictions, imperative-DOM bridge naming, and GritQL CSS source policy. Biome warnings fail `npm run check`; info diagnostics stay advisory, including accessibility while Obsidian-specific UI semantics are reviewed rule by rule. The CSS usage script still checks dead authored classes. TypeScript provides compiler-backed checks such as strict type checking, unused locals and parameters, implicit return coverage, switch fallthrough prevention, and deep-readonly `ChatState` snapshots. ESLint runs the TypeScript ESLint strict type-checked preset over source files, plus Obsidian plugin policy for `manifest.json` and `LICENSE`. Tests are covered by TypeScript, Vitest, and Biome rather than typed ESLint so local preflight stays responsive. Some checks intentionally overlap between Biome, TypeScript, and ESLint so the configuration stays simple.
Biome owns formatting, import organization, general JavaScript/TypeScript/JSON/CSS linting, accessibility linting, Preact-compatible React domain rules, GritQL source-shape plugins, GritQL import-boundary restrictions, imperative-DOM bridge naming, and GritQL CSS source policy. Biome warnings fail `npm run check`; rule suppressions must stay local and include the Obsidian-specific rationale when a native Obsidian UI pattern intentionally diverges from a generic browser semantic. The CSS usage script still checks dead authored classes. TypeScript provides compiler-backed checks such as strict type checking, unused locals and parameters, implicit return coverage, switch fallthrough prevention, and deep-readonly `ChatState` snapshots. ESLint runs the TypeScript ESLint strict type-checked preset over source files, plus Obsidian plugin policy for `manifest.json` and `LICENSE`. Tests are covered by TypeScript, Vitest, and Biome rather than typed ESLint so local preflight stays responsive. Some checks intentionally overlap between Biome, TypeScript, and ESLint so the configuration stays simple.
## Generated and Loaded Files

View file

@ -395,6 +395,7 @@ function ComposerMetaPickerButton({
onMouseDown: () => void;
}): UiNode {
return (
// biome-ignore lint/a11y: Composer meta triggers are visual pointer shortcuts; screen readers get the status summary and full runtime controls remain available through the toolbar and slash commands.
<span
ref={triggerRef}
className={`codex-panel__composer-meta-trigger codex-panel__composer-meta-value codex-panel__composer-meta-${kind}`}
@ -438,6 +439,7 @@ function ComposerMetaChoice({ choice, onClose }: { choice: RuntimeChoice; onClos
onClose();
};
return (
// biome-ignore lint/a11y: Composer meta choices belong to the visual shortcut popover instead of the accessible control path.
<div
className={["codex-panel__composer-meta-option", choice.disabled ? "is-disabled" : ""].filter(Boolean).join(" ")}
onMouseDown={(event) => {

View file

@ -397,14 +397,27 @@ function McpElicitationField({
actions: PendingRequestBlockActions;
}): UiNode {
const current = drafts.get(field.draftKey) ?? field.defaultDraft;
const labelId = mcpElicitationFieldElementId("label", field.draftKey);
const controlId = mcpElicitationFieldElementId("control", field.draftKey);
const labelContent = (
<>
<span>{field.title}</span>
{field.required ? <span className="codex-panel__mcp-elicitation-required">Required</span> : null}
</>
);
return (
<div className="codex-panel__mcp-elicitation-field">
<label className="codex-panel__mcp-elicitation-label">
<span>{field.title}</span>
{field.required ? <span className="codex-panel__mcp-elicitation-required">Required</span> : null}
</label>
{mcpElicitationFieldHasSingleControl(field) ? (
<label id={labelId} className="codex-panel__mcp-elicitation-label" htmlFor={controlId}>
{labelContent}
</label>
) : (
<div id={labelId} className="codex-panel__mcp-elicitation-label">
{labelContent}
</div>
)}
{field.description ? <div className="codex-panel__mcp-elicitation-description">{field.description}</div> : null}
<McpElicitationFieldControl field={field} current={current} actions={actions} />
<McpElicitationFieldControl field={field} current={current} actions={actions} controlId={controlId} labelId={labelId} />
</div>
);
}
@ -413,16 +426,21 @@ function McpElicitationFieldControl({
field,
current,
actions,
controlId,
labelId,
}: {
field: PendingMcpElicitationFieldViewModel;
current: string;
actions: PendingRequestBlockActions;
controlId: string;
labelId: string;
}): UiNode {
switch (field.type) {
case "boolean":
return (
<label className="codex-panel__mcp-elicitation-option">
<input
id={controlId}
className="codex-panel__mcp-elicitation-checkbox"
type="checkbox"
checked={current === "true"}
@ -435,7 +453,7 @@ function McpElicitationFieldControl({
);
case "single-select":
return (
<div className="codex-panel__mcp-elicitation-options">
<fieldset className="codex-panel__mcp-elicitation-options" aria-labelledby={labelId}>
{field.options?.map((option) => (
<label key={option.value} className="codex-panel__mcp-elicitation-option">
<input
@ -452,11 +470,11 @@ function McpElicitationFieldControl({
<span>{option.label}</span>
</label>
))}
</div>
</fieldset>
);
case "multi-select":
return (
<div className="codex-panel__mcp-elicitation-options">
<fieldset className="codex-panel__mcp-elicitation-options" aria-labelledby={labelId}>
{field.options?.map((option) => {
const selected = selectedMcpElicitationValues(current);
return (
@ -481,12 +499,13 @@ function McpElicitationFieldControl({
</label>
);
})}
</div>
</fieldset>
);
case "number":
case "integer":
return (
<input
id={controlId}
className="codex-panel__mcp-elicitation-input"
type="number"
step={field.type === "integer" ? "1" : "any"}
@ -502,6 +521,7 @@ function McpElicitationFieldControl({
default:
return (
<input
id={controlId}
className="codex-panel__mcp-elicitation-input"
type={field.format === "email" ? "email" : field.format === "uri" ? "url" : field.format === "date" ? "date" : "text"}
minLength={field.minLength ?? undefined}
@ -516,6 +536,14 @@ function McpElicitationFieldControl({
}
}
function mcpElicitationFieldHasSingleControl(field: PendingMcpElicitationFieldViewModel): boolean {
return field.type !== "single-select" && field.type !== "multi-select";
}
function mcpElicitationFieldElementId(kind: "control" | "label", draftKey: string): string {
return `codex-panel-mcp-${kind}-${encodeURIComponent(draftKey)}`;
}
function selectedMcpElicitationValues(draft: string): Set<string> {
try {
const values = JSON.parse(draft) as unknown;

View file

@ -1,6 +1,6 @@
import type { ButtonHTMLAttributes, TargetedKeyboardEvent, ComponentChild as UiNode } from "preact";
import type { ButtonHTMLAttributes, ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { IconButton } from "../../../shared/ui/components.obsidian";
import { IconButton, ObsidianToolbarAction, type ObsidianToolbarActionProps } from "../../../shared/ui/components.obsidian";
import type { RateLimitSummary } from "../presentation/runtime/status";
type ButtonProps = ButtonHTMLAttributes & {
@ -74,7 +74,6 @@ export function Toolbar({ model, actions }: { model: ToolbarViewModel; actions:
icon="history"
label={model.historyOpen ? "Hide thread list" : "Show thread list"}
className={["codex-panel__history-toggle", model.historyOpen ? "is-active" : ""].filter(Boolean).join(" ")}
aria-pressed={model.historyOpen ? "true" : "false"}
onClick={actions.toggleHistory}
/>
<ToolbarIconButton
@ -82,7 +81,6 @@ export function Toolbar({ model, actions }: { model: ToolbarViewModel; actions:
label={model.chatActionsOpen ? "Hide chat actions" : "Show chat actions"}
className={["codex-panel__new-chat", model.chatActionsOpen ? "is-active" : ""].filter(Boolean).join(" ")}
disabled={model.newChatDisabled}
aria-expanded={model.chatActionsOpen ? "true" : "false"}
onClick={actions.toggleChatActions}
/>
<StatusButton model={model} actions={actions} />
@ -102,9 +100,9 @@ function ToolbarIconButton({
icon: string;
label: string;
className?: string;
} & Omit<ButtonProps, "className" | "type">): UiNode {
} & Omit<ObsidianToolbarActionProps, "className" | "icon" | "label">): UiNode {
return (
<IconButton
<ObsidianToolbarAction
{...props}
icon={icon}
label={label}
@ -119,7 +117,6 @@ function StatusButton({ model, actions }: { model: ToolbarViewModel; actions: To
icon="waypoints"
label={model.statusPanelOpen ? "Hide status" : "Show status"}
className={["codex-panel__status-menu-toggle", model.statusPanelOpen ? "is-active" : ""].filter(Boolean).join(" ")}
aria-expanded={model.statusPanelOpen ? "true" : "false"}
onClick={actions.toggleStatusPanel}
/>
);
@ -143,21 +140,19 @@ function ToolbarPanel({ model, actions }: { model: ToolbarViewModel; actions: To
function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
return (
<div className="codex-panel__chat-actions-panel-items" role="menu">
<div className="codex-panel__chat-actions-panel-items">
<ToolbarPanelItem
label="Start new chat"
onClick={actions.startNewThread}
className="codex-panel__chat-actions-panel-item"
disabled={model.newChatDisabled}
role="menuitem"
/>
<ToolbarPanelItem
label="Compact conversation"
onClick={actions.compactConversation}
className="codex-panel__chat-actions-panel-item"
role="menuitem"
/>
<ToolbarPanelItem label="Set goal..." onClick={actions.setGoal} className="codex-panel__chat-actions-panel-item" role="menuitem" />
<ToolbarPanelItem label="Set goal..." onClick={actions.setGoal} className="codex-panel__chat-actions-panel-item" />
</div>
);
}
@ -165,16 +160,15 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions
function StatusPanel({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
return (
<>
<div className="codex-panel__status-panel-items" role="menu">
<ToolbarPanelItem label={model.connectLabel} onClick={actions.connect} className="codex-panel__status-panel-item" role="menuitem" />
<ToolbarPanelItem label="Refresh" onClick={actions.refreshStatus} className="codex-panel__status-panel-item" role="menuitem" />
<div className="codex-panel__status-panel-items">
<ToolbarPanelItem label={model.connectLabel} onClick={actions.connect} className="codex-panel__status-panel-item" />
<ToolbarPanelItem label="Refresh" onClick={actions.refreshStatus} className="codex-panel__status-panel-item" />
<ToolbarPanelItem
label="Copy debug details"
onClick={() => {
actions.copyDebugDetails(model.debugDetails());
}}
className="codex-panel__status-panel-item"
role="menuitem"
/>
</div>
<RateLimitPanel rateLimit={model.rateLimit} />
@ -471,7 +465,6 @@ function ToolbarPanelItem({
meta,
className = "",
interactive = true,
role = "button",
renderContent,
onClick,
}: {
@ -482,48 +475,42 @@ function ToolbarPanelItem({
meta?: string | undefined;
className?: string | undefined;
interactive?: boolean | undefined;
role?: "button" | "menuitem" | "option";
renderContent?: () => UiNode;
onClick?: () => void;
}): UiNode {
const onKeyDown = (event: TargetedKeyboardEvent<HTMLElement>) => {
if (disabled || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
onClick?.();
};
const itemClassName = [
"codex-panel-ui__nav-item",
"codex-panel__toolbar-panel-item",
className,
selected && selectionStyle === "item" ? "is-selected" : "",
disabled ? "is-disabled" : "",
]
.filter(Boolean)
.join(" ");
const content = renderContent ? (
renderContent()
) : (
<>
<span className="codex-panel__toolbar-panel-label">{label}</span>
{meta ? <span className="codex-panel__toolbar-panel-meta">{meta}</span> : null}
</>
);
if (!interactive) {
return <div className={itemClassName}>{content}</div>;
}
return (
// biome-ignore lint/a11y: Toolbar panel rows intentionally match Obsidian's native file explorer nav rows: pointer-first div items with state expressed by classes, while row icon actions remain real buttons.
<div
className={[
"codex-panel-ui__nav-item",
"codex-panel__toolbar-panel-item",
className,
selected && selectionStyle === "item" ? "is-selected" : "",
disabled ? "is-disabled" : "",
]
.filter(Boolean)
.join(" ")}
role={interactive ? role : undefined}
tabIndex={interactive ? (disabled ? -1 : 0) : undefined}
aria-disabled={interactive ? (disabled ? "true" : "false") : undefined}
aria-selected={interactive && role === "option" ? (selected ? "true" : "false") : undefined}
aria-current={interactive && role === "button" && selected ? "true" : undefined}
className={itemClassName}
onClick={
interactive
? () => {
if (!disabled) onClick?.();
disabled
? undefined
: () => {
onClick?.();
}
: undefined
}
onKeyDown={interactive ? onKeyDown : undefined}
>
{renderContent ? (
renderContent()
) : (
<>
<span className="codex-panel__toolbar-panel-label">{label}</span>
{meta ? <span className="codex-panel__toolbar-panel-meta">{meta}</span> : null}
</>
)}
{content}
</div>
);
}

View file

@ -388,7 +388,7 @@ function SelectionRewritePopoverView({
</div>
{debugText ? (
<details className="codex-panel-selection-rewrite__debug">
<summary>Debug output</summary>
<summary tabIndex={-1}>Debug output</summary>
<pre>{debugText}</pre>
</details>
) : null}

View file

@ -1,7 +1,7 @@
import type { ButtonHTMLAttributes, TargetedKeyboardEvent, ComponentChild as UiNode } from "preact";
import type { ButtonHTMLAttributes, ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { IconButton } from "../../shared/ui/components.obsidian";
import { IconButton, ObsidianToolbarAction, type ObsidianToolbarActionProps } from "../../shared/ui/components.obsidian";
import { renderUiRoot, unmountUiRoot } from "../../shared/ui/ui-root.dom";
import type { ThreadsRowModel } from "./state";
@ -46,7 +46,7 @@ function ThreadsView({ model, actions }: { model: ThreadsViewModel; actions: Thr
<ThreadsToolbarButton icon="refresh-cw" label="Refresh threads" onClick={actions.refresh} />
</div>
</div>
<div className="codex-panel-threads__list" role="list">
<div className="codex-panel-threads__list">
{model.rows.length === 0 ? (
<div className="codex-panel-threads__empty">{model.status ?? (model.loading ? "Loading threads..." : "No threads")}</div>
) : (
@ -87,27 +87,14 @@ function ThreadRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie
if (row.rename.active || archiveConfirm.active) return;
actions.openThread(row.threadId);
};
const openFromKeyboard = (event: TargetedKeyboardEvent<HTMLDivElement>) => {
if (row.rename.active || archiveConfirm.active) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
actions.openThread(row.threadId);
};
return (
<div className={rowClassName}>
{row.rename.active ? (
<RenameRow row={row} actions={actions} className={mainClassName} />
) : (
<>
<div
className={mainClassName}
role="button"
tabIndex={0}
aria-current={row.selected ? "true" : undefined}
onClick={open}
onKeyDown={openFromKeyboard}
>
{/* biome-ignore lint/a11y: Thread rows intentionally follow Obsidian's native file explorer nav rows: pointer-first div items with selection represented by classes, while row actions stay as real icon buttons. */}
<div className={mainClassName} onClick={open}>
<span className="codex-panel-threads__row-title">{row.title}</span>
</div>
<div
@ -204,12 +191,7 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions:
return (
<>
<div
className={`${className} codex-panel-threads__rename-form`}
onClick={(event) => {
event.stopPropagation();
}}
>
<div className={`${className} codex-panel-threads__rename-form`}>
<div className="codex-panel-threads__rename-field">
<input
ref={inputRef}
@ -269,9 +251,9 @@ function ThreadsToolbarButton({
}: {
icon: string;
label: string;
} & Omit<ButtonProps, "className" | "type">): UiNode {
} & Omit<ObsidianToolbarActionProps, "className" | "icon" | "label">): UiNode {
return (
<IconButton
<ObsidianToolbarAction
{...props}
icon={icon}
label={label}

View file

@ -1,5 +1,5 @@
import { ButtonComponent, DropdownComponent, ExtraButtonComponent, setIcon, TextComponent, ToggleComponent } from "obsidian";
import type { ButtonHTMLAttributes, Ref, ComponentChild as UiNode } from "preact";
import type { ButtonHTMLAttributes, JSX, Ref, ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
interface ObsidianIconProps {
@ -60,6 +60,49 @@ export function IconButton({ icon, label, buttonRef, className, children, ...pro
);
}
export type ObsidianToolbarActionProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, "className"> & {
icon: string;
label: string;
actionRef?: Ref<HTMLDivElement>;
className?: string | undefined;
disabled?: boolean | undefined;
};
export function ObsidianToolbarAction({
icon,
label,
actionRef,
className,
disabled,
onClick,
...props
}: ObsidianToolbarActionProps): UiNode {
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const action = ref.current;
if (!action) return;
action.replaceChildren();
setIcon(action, icon);
}, [icon]);
return (
// biome-ignore lint/a11y: Obsidian core toolbar icons are div.clickable-icon nav-action-button elements with aria-label tooltips, not native buttons.
<div
{...props}
ref={(element) => {
ref.current = element;
if (typeof actionRef === "function") {
actionRef(element);
} else if (actionRef) {
actionRef.current = element;
}
}}
className={[className, disabled ? "is-disabled" : ""].filter(Boolean).join(" ")}
aria-label={label}
onClick={disabled ? undefined : onClick}
/>
);
}
export function ObsidianDropdown({
value,
options,

View file

@ -47,13 +47,17 @@
--icon-stroke: var(--icon-m-stroke-width, 1.75px);
}
.codex-panel-ui__toolbar-action:hover,
.codex-panel-ui__toolbar-action:focus-visible {
.codex-panel-ui__toolbar-action:hover:not(.is-disabled),
.codex-panel-ui__toolbar-action:focus-visible:not(.is-disabled) {
background: var(--background-modifier-hover);
box-shadow: none;
color: var(--icon-color);
}
.codex-panel-ui__toolbar-action.is-disabled {
opacity: 0.45;
}
.codex-panel-ui__icon-button svg,
.codex-panel-ui__toolbar-action svg,
.codex-panel-ui__nav-row-action svg {

View file

@ -24,6 +24,14 @@
text-align: left;
}
.codex-panel-ui__nav-item.codex-panel__toolbar-panel-item {
justify-content: flex-start;
height: auto;
background: transparent;
box-shadow: none;
color: var(--nav-item-color, var(--text-muted));
}
.codex-panel__toolbar-panel-label,
.codex-panel__toolbar-panel-meta {
min-width: 0;

View file

@ -179,6 +179,10 @@
.codex-panel__mcp-elicitation-options {
display: grid;
gap: var(--codex-panel-control-gap);
min-inline-size: 0;
margin: 0;
border: 0;
padding: 0;
}
.codex-panel__mcp-elicitation-option {

View file

@ -47,14 +47,17 @@ describe("chat panel surface projections", () => {
);
expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull();
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.tagName).toBe("DIV");
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(true);
expect(parent.querySelector<HTMLInputElement>(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe(
"Active",
);
expect(parent.querySelector(".codex-panel__thread-row--archive-confirming .codex-panel__toolbar-panel-label")?.textContent).toBe(
"Other",
);
expect(parent.querySelectorAll<HTMLElement>(".codex-panel__thread")[1]?.getAttribute("aria-disabled")).toBe("true");
const archivedThread = parent.querySelectorAll<HTMLElement>(".codex-panel__thread")[1];
expect(archivedThread?.tagName).toBe("DIV");
expect(archivedThread?.classList.contains("is-disabled")).toBe(true);
unmountUiRoot(parent);
});

View file

@ -499,7 +499,11 @@ describe("pending request renderer decisions", () => {
);
expect(parent.querySelector(".codex-panel__pending-request-title")?.textContent).toBe("MCP request from github");
changeInputValue(expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input")), "Updated");
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input"));
const label = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__mcp-elicitation-label"));
expect(label.tagName).toBe("LABEL");
expect(label.getAttribute("for")).toBe(input.id);
changeInputValue(input, "Updated");
actEvent(() => {
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
});
@ -564,6 +568,10 @@ describe("pending request renderer decisions", () => {
}),
);
const options = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__mcp-elicitation-options"));
const label = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__mcp-elicitation-label"));
expect(options.tagName).toBe("FIELDSET");
expect(options.getAttribute("aria-labelledby")).toBe(label.id);
actEvent(() => {
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
});

View file

@ -36,24 +36,30 @@ describe("Toolbar decisions", () => {
"Show chat actions",
"Show status",
]);
expect([...expectPresent(navButtons).children].map((button) => button.tagName)).toEqual(["DIV", "DIV", "DIV"]);
expect([...expectPresent(navButtons).children].map((button) => button.getAttribute("role"))).toEqual([null, null, null]);
expect(parent.querySelector(".codex-panel__plan-toggle")).toBeNull();
expect(parent.querySelector(".codex-panel__auto-review-toggle")).toBeNull();
expect(parent.querySelector(".codex-panel__runtime-model")).toBeNull();
const newChatButton = parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat");
const newChatButton = parent.querySelector<HTMLElement>(".codex-panel__new-chat");
expect(newChatButton?.getAttribute("aria-label")).toBe("Show chat actions");
expect(newChatButton?.disabled).toBe(false);
expect(newChatButton?.getAttribute("aria-expanded")).toBeNull();
expect(newChatButton?.classList.contains("is-disabled")).toBe(false);
newChatButton?.click();
expect(toggleChatActions).toHaveBeenCalled();
expect(startNewThread).not.toHaveBeenCalled();
const statusButton = parent.querySelector(".codex-panel__status-menu-toggle");
expect(statusButton?.getAttribute("aria-label")).toBe("Show status");
const historyButton = parent.querySelector<HTMLButtonElement>(".codex-panel__history-toggle");
expect(statusButton?.getAttribute("aria-expanded")).toBeNull();
const historyButton = parent.querySelector<HTMLElement>(".codex-panel__history-toggle");
expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list");
expect(historyButton?.getAttribute("aria-pressed")).toBeNull();
historyButton?.click();
expect(toggleHistory).toHaveBeenCalled();
parent.empty();
mountToolbar(parent, toolbarModel({ newChatDisabled: true }), toolbarActions());
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.tagName).toBe("DIV");
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(true);
parent.empty();
mountToolbar(parent, toolbarModel({ chatActionsOpen: true, historyOpen: true, statusPanelOpen: true }), toolbarActions());
@ -78,8 +84,11 @@ describe("Toolbar decisions", () => {
);
const items = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")];
expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.tagName).toBe("DIV");
expect(parent.querySelector(".codex-panel__chat-actions-panel-items")?.getAttribute("aria-label")).toBeNull();
expect(items.map((item) => item.textContent)).toEqual(["Start new chat", "Compact conversation", "Set goal..."]);
expect(items.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem", "menuitem"]);
expect(items.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV"]);
expect(items.map((item) => item.getAttribute("role"))).toEqual([null, null, null]);
items[0]?.click();
items[1]?.click();
items[2]?.click();
@ -168,7 +177,10 @@ describe("Toolbar decisions", () => {
expect(parent.textContent).toContain("codex-cli/1.2.3");
expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")?.textContent).toContain("model/list failed");
const statusItems = [...parent.querySelectorAll<HTMLElement>(".codex-panel__status-panel-item")];
expect(statusItems.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem", "menuitem"]);
expect(parent.querySelector(".codex-panel__status-panel-items")?.tagName).toBe("DIV");
expect(parent.querySelector(".codex-panel__status-panel-items")?.getAttribute("aria-label")).toBeNull();
expect(statusItems.map((item) => item.tagName)).toEqual(["DIV", "DIV", "DIV"]);
expect(statusItems.map((item) => item.getAttribute("role"))).toEqual([null, null, null]);
expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true);
statusItems.find((item) => item.textContent.includes("Refresh"))?.click();
expect(refreshStatus).toHaveBeenCalled();

View file

@ -155,18 +155,23 @@ describe("threads view renderer decisions", () => {
expect(main.classList.contains("codex-panel-ui__nav-item")).toBe(true);
expect(row.classList.contains("codex-panel-threads__row--selected")).toBe(true);
expect(row.classList.contains("is-selected")).toBe(true);
expect(main.getAttribute("aria-current")).toBe("true");
expect(parent.querySelector(".codex-panel-threads__list")?.getAttribute("role")).toBeNull();
expect(main.getAttribute("role")).toBeNull();
expect(main.getAttribute("tabindex")).toBeNull();
expect(main.getAttribute("aria-current")).toBeNull();
expect(row.getAttribute("title")).toBeNull();
const toolbarButtons = [...parent.querySelectorAll<HTMLButtonElement>(".codex-panel-threads__toolbar-button")];
const toolbarButtons = [...parent.querySelectorAll<HTMLElement>(".codex-panel-threads__toolbar-button")];
expect(toolbarButtons.map((button) => button.getAttribute("aria-label"))).toEqual(["Open new panel", "Refresh threads"]);
const refresh = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Refresh threads"]'));
expect(toolbarButtons.map((button) => button.tagName)).toEqual(["DIV", "DIV"]);
expect(toolbarButtons.map((button) => button.getAttribute("role"))).toEqual([null, null]);
const refresh = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Refresh threads"]'));
expect(refresh.classList.contains("codex-panel-threads__toolbar-button")).toBe(true);
expect(refresh.classList.contains("nav-action-button")).toBe(true);
expect(refresh.classList.contains("codex-panel-ui__toolbar-action")).toBe(true);
expect(refresh.classList.contains("codex-panel-ui__nav-row-action")).toBe(false);
refresh.click();
expect(actions.refresh).toHaveBeenCalledOnce();
const openNewPanel = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Open new panel"]'));
const openNewPanel = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Open new panel"]'));
expect(openNewPanel.classList.contains("codex-panel-threads__toolbar-button")).toBe(true);
expect(openNewPanel.classList.contains("codex-panel-threads__row-button")).toBe(false);
expect(openNewPanel.dataset["icon"]).toBe("message-square-plus");
@ -177,8 +182,6 @@ describe("threads view renderer decisions", () => {
expect(rename.classList.contains("codex-panel-ui__nav-row-action")).toBe(true);
main.click();
expect(actions.openThread).toHaveBeenCalledWith("open");
main.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(actions.openThread).toHaveBeenCalledTimes(2);
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Focus open panel"]')).toBeNull();
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Open in new panel"]')).toBeNull();
});

View file

@ -77,6 +77,16 @@ describe("chat toolbar CSS", () => {
expect(hoverSelectors).toEqual([":hover, :focus-visible, :active", ":hover, :focus-visible, :active"]);
});
it("keeps toolbar panel rows aligned to Obsidian nav items", () => {
const toolbarPanelNavItem =
/\.codex-panel-ui__nav-item\.codex-panel__toolbar-panel-item \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
expect(toolbarPanelNavItem).toContain("justify-content: flex-start");
expect(toolbarPanelNavItem).toContain("height: auto");
expect(toolbarPanelNavItem).toContain("background: transparent");
expect(toolbarPanelNavItem).toContain("box-shadow: none");
});
it("uses the shared active state for toolbar actions", () => {
const toolbarActionActive =
/\.codex-panel-ui__toolbar-action\.is-active,\n\.codex-panel-ui__toolbar-action\.is-active:hover,\n\.codex-panel-ui__toolbar-action\.is-active:focus-visible,\n\.codex-panel-ui__toolbar-action\.is-active:active \{(?<body>[^}]+)\}/.exec(
@ -339,9 +349,9 @@ describe("threads view CSS", () => {
it("keeps toolbar action hover color separate from row action hover color", () => {
const toolbarHover =
/\.codex-panel-ui__toolbar-action:hover,\n\.codex-panel-ui__toolbar-action:focus-visible \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
"body"
] ?? "";
/\.codex-panel-ui__toolbar-action:hover:not\(\.is-disabled\),\n\.codex-panel-ui__toolbar-action:focus-visible:not\(\.is-disabled\) \{(?<body>[^}]+)\}/.exec(
styles,
)?.groups?.["body"] ?? "";
const toolbarMouseFocus =
/\.codex-panel-ui__toolbar-action:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
"body"