mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:50:45 +00:00
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:
187
ui/src/ui/components/resizable-divider.test.ts
Normal file
187
ui/src/ui/components/resizable-divider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user