fix(control-ui): make chat divider accessible

Make the chat sidebar divider accessible and input-method agnostic.\n\n- Add separator semantics, ARIA value updates, keyboard resizing, focus styling, and pointer-event drag handling.\n- Cover divider semantics, keyboard behavior, pointer capture, and clamping in UI tests.\n- Tolerate the platform-specific Knip unused-file result that surfaced on current main so CI remains stable.
This commit is contained in:
Val Alexander
2026-04-29 07:07:16 -05:00
committed by GitHub
parent 64bd2a2cbe
commit efb1a7cb02
7 changed files with 346 additions and 25 deletions

View File

@@ -0,0 +1,187 @@
/* @vitest-environment jsdom */
import { html, nothing, render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type ResizableDivider } from "./resizable-divider.ts";
import "./resizable-divider.ts";
let container: HTMLDivElement;
const originalPointerEvent = globalThis.PointerEvent;
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
readonly pointerType: string;
readonly isPrimary: boolean;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
this.pointerType = init.pointerType ?? "mouse";
this.isPrimary = init.isPrimary ?? true;
}
}
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
async function renderDivider() {
render(
html`
<div id="split-root">
<resizable-divider
.splitRatio=${0.6}
.minRatio=${0.4}
.maxRatio=${0.7}
.label=${"Resize sidebar"}
></resizable-divider>
</div>
`,
container,
);
const root = container.querySelector<HTMLDivElement>("#split-root");
const divider = container.querySelector<ResizableDivider>("resizable-divider");
expect(root).not.toBeNull();
expect(divider).not.toBeNull();
root!.getBoundingClientRect = vi.fn(() => ({
bottom: 0,
height: 0,
left: 0,
right: 400,
top: 0,
width: 400,
x: 0,
y: 0,
toJSON: () => ({}),
}));
await divider!.updateComplete;
await nextFrame();
return divider!;
}
function dispatchPointer(target: EventTarget, type: string, clientX: number) {
target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
cancelable: true,
clientX,
pointerId: 7,
pointerType: "touch",
}),
);
}
describe("resizable-divider", () => {
beforeEach(() => {
if (!globalThis.PointerEvent) {
Object.defineProperty(globalThis, "PointerEvent", {
configurable: true,
value: TestPointerEvent as typeof PointerEvent,
});
}
container = document.createElement("div");
document.body.append(container);
});
afterEach(() => {
render(nothing, container);
container.remove();
if (originalPointerEvent) {
Object.defineProperty(globalThis, "PointerEvent", {
configurable: true,
value: originalPointerEvent,
});
} else {
delete (globalThis as Partial<typeof globalThis>).PointerEvent;
}
vi.restoreAllMocks();
});
it("exposes separator semantics and current split value on the host", async () => {
const divider = await renderDivider();
expect(divider.getAttribute("role")).toBe("separator");
expect(divider.getAttribute("tabindex")).toBe("0");
expect(divider.getAttribute("aria-label")).toBe("Resize sidebar");
expect(divider.getAttribute("aria-orientation")).toBe("vertical");
expect(divider.getAttribute("aria-valuemin")).toBe("40");
expect(divider.getAttribute("aria-valuemax")).toBe("70");
expect(divider.getAttribute("aria-valuenow")).toBe("60");
divider.splitRatio = 0.65;
await divider.updateComplete;
expect(divider.getAttribute("aria-valuenow")).toBe("65");
});
it("resizes with keyboard arrows, Home, and End", async () => {
const divider = await renderDivider();
const resized = vi.fn();
divider.addEventListener("resize", resized);
const arrowLeft = new KeyboardEvent("keydown", {
key: "ArrowLeft",
bubbles: true,
cancelable: true,
});
divider.dispatchEvent(arrowLeft);
expect(arrowLeft.defaultPrevented).toBe(true);
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.58 } }),
);
const arrowRight = new KeyboardEvent("keydown", {
key: "ArrowRight",
shiftKey: true,
bubbles: true,
cancelable: true,
});
divider.dispatchEvent(arrowRight);
expect(arrowRight.defaultPrevented).toBe(true);
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.65 } }),
);
divider.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.4 } }),
);
divider.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.7 } }),
);
});
it("uses pointer events for mouse, pen, and touch dragging", async () => {
const divider = await renderDivider();
const resized = vi.fn();
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
const hasPointerCapture = vi.fn(() => true);
divider.setPointerCapture = setPointerCapture;
divider.releasePointerCapture = releasePointerCapture;
divider.hasPointerCapture = hasPointerCapture;
divider.addEventListener("resize", resized);
dispatchPointer(divider, "pointerdown", 100);
expect(document.activeElement).toBe(divider);
expect(divider.classList.contains("dragging")).toBe(true);
expect(setPointerCapture).toHaveBeenCalledWith(7);
dispatchPointer(document, "pointermove", 220);
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.7 } }),
);
dispatchPointer(document, "pointerup", 220);
expect(divider.classList.contains("dragging")).toBe(false);
expect(releasePointerCapture).toHaveBeenCalledWith(7);
});
});

View File

@@ -2,17 +2,19 @@ import { LitElement, css, nothing } from "lit";
import { property } from "lit/decorators.js";
/**
* A draggable divider for resizable split views.
* An accessible draggable divider for resizable split views.
* Dispatches 'resize' events with { splitRatio: number } detail.
*/
export class ResizableDivider extends LitElement {
@property({ type: Number }) splitRatio = 0.6;
@property({ type: Number }) minRatio = 0.4;
@property({ type: Number }) maxRatio = 0.7;
@property({ type: String }) label = "Resize split view";
private isDragging = false;
private startX = 0;
private startRatio = 0;
private activePointerId: number | null = null;
static styles = css`
:host {
@@ -22,6 +24,8 @@ export class ResizableDivider extends LitElement {
transition: background 150ms ease-out;
flex-shrink: 0;
position: relative;
touch-action: none;
user-select: none;
}
:host::before {
content: "";
@@ -37,6 +41,11 @@ export class ResizableDivider extends LitElement {
:host(.dragging) {
background: var(--accent, #007bff);
}
:host(:focus-visible) {
outline: 2px solid var(--accent, #007bff);
outline-offset: 2px;
background: var(--accent, #007bff);
}
`;
render() {
@@ -45,29 +54,48 @@ export class ResizableDivider extends LitElement {
connectedCallback() {
super.connectedCallback();
this.addEventListener("mousedown", this.handleMouseDown);
this.setStaticAccessibilityAttributes();
this.addEventListener("pointerdown", this.handlePointerDown);
this.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("mousedown", this.handleMouseDown);
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
this.removeEventListener("pointerdown", this.handlePointerDown);
this.removeEventListener("keydown", this.handleKeyDown);
this.stopDragging();
}
private handleMouseDown = (e: MouseEvent) => {
protected updated() {
this.setAttribute("aria-valuemin", String(this.toAriaValue(this.minRatio)));
this.setAttribute("aria-valuemax", String(this.toAriaValue(this.maxRatio)));
this.setAttribute("aria-valuenow", String(this.toAriaValue(this.splitRatio)));
if (this.label) {
this.setAttribute("aria-label", this.label);
} else {
this.removeAttribute("aria-label");
}
}
private handlePointerDown = (e: PointerEvent) => {
if (e.button !== 0) {
return;
}
this.isDragging = true;
this.startX = e.clientX;
this.startRatio = this.splitRatio;
this.classList.add("dragging");
this.focus();
this.capturePointer(e.pointerId);
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
document.addEventListener("pointermove", this.handlePointerMove);
document.addEventListener("pointerup", this.handlePointerUp);
document.addEventListener("pointercancel", this.handlePointerUp);
e.preventDefault();
};
private handleMouseMove = (e: MouseEvent) => {
private handlePointerMove = (e: PointerEvent) => {
if (!this.isDragging) {
return;
}
@@ -81,25 +109,92 @@ export class ResizableDivider extends LitElement {
const deltaX = e.clientX - this.startX;
const deltaRatio = deltaX / containerWidth;
let newRatio = this.startRatio + deltaRatio;
newRatio = Math.max(this.minRatio, Math.min(this.maxRatio, newRatio));
this.emitResize(this.startRatio + deltaRatio);
};
private handlePointerUp = () => {
this.stopDragging();
};
private handleKeyDown = (e: KeyboardEvent) => {
const step = e.shiftKey ? 0.05 : 0.02;
let nextRatio: number | null = null;
if (e.key === "ArrowLeft") {
nextRatio = this.splitRatio - step;
} else if (e.key === "ArrowRight") {
nextRatio = this.splitRatio + step;
} else if (e.key === "Home") {
nextRatio = this.minRatio;
} else if (e.key === "End") {
nextRatio = this.maxRatio;
}
if (nextRatio == null) {
return;
}
e.preventDefault();
this.emitResize(nextRatio);
};
private stopDragging() {
if (!this.isDragging) {
return;
}
this.isDragging = false;
this.classList.remove("dragging");
this.releaseActivePointer();
document.removeEventListener("pointermove", this.handlePointerMove);
document.removeEventListener("pointerup", this.handlePointerUp);
document.removeEventListener("pointercancel", this.handlePointerUp);
}
private emitResize(nextRatio: number) {
const splitRatio = this.clampRatio(nextRatio);
this.dispatchEvent(
new CustomEvent("resize", {
detail: { splitRatio: newRatio },
detail: { splitRatio },
bubbles: true,
composed: true,
}),
);
};
}
private handleMouseUp = () => {
this.isDragging = false;
this.classList.remove("dragging");
private clampRatio(value: number) {
return Math.max(this.minRatio, Math.min(this.maxRatio, value));
}
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
};
private toAriaValue(value: number) {
return Math.round(value * 100);
}
private setStaticAccessibilityAttributes() {
this.setAttribute("role", "separator");
this.setAttribute("tabindex", "0");
this.setAttribute("aria-orientation", "vertical");
}
private capturePointer(pointerId: number) {
if (typeof this.setPointerCapture !== "function") {
return;
}
this.setPointerCapture(pointerId);
this.activePointerId = pointerId;
}
private releaseActivePointer() {
const pointerId = this.activePointerId;
this.activePointerId = null;
if (pointerId == null || typeof this.releasePointerCapture !== "function") {
return;
}
if (typeof this.hasPointerCapture === "function" && !this.hasPointerCapture(pointerId)) {
return;
}
this.releasePointerCapture(pointerId);
}
}
if (!customElements.get("resizable-divider")) {

View File

@@ -1,6 +1,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../../i18n/index.ts";
import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts";
import {
getChatAttachmentPreviewUrl,
@@ -1067,6 +1068,7 @@ export function renderChat(props: ChatProps) {
? html`
<resizable-divider
.splitRatio=${splitRatio}
.label=${t("nav.resize")}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar">