From d8d7bc3401a20bdfa09804bd5d7c9e06910f3eb0 Mon Sep 17 00:00:00 2001 From: chen-985211 <2301091735@qq.com> Date: Fri, 10 Jul 2026 19:38:48 +0800 Subject: [PATCH 1/3] feat: refine console and control center UI --- package.json | 3 +- scripts/check-ui-styles.mjs | 238 ++++++++++ src/app/renderer/components/CoveSelect.tsx | 27 +- .../renderer/components/ui/Button.spec.tsx | 47 ++ src/app/renderer/components/ui/Button.tsx | 46 ++ .../renderer/components/ui/Dialog.spec.tsx | 67 +++ src/app/renderer/components/ui/Dialog.tsx | 171 +++++++ .../components/ui/DismissableLayer.tsx | 145 ++++++ .../components/ui/IconButton.spec.tsx | 30 ++ src/app/renderer/components/ui/IconButton.tsx | 46 ++ .../renderer/components/ui/Popover.spec.tsx | 244 ++++++++++ src/app/renderer/components/ui/Popover.tsx | 206 +++++++++ .../components/ui/SettingRow.spec.tsx | 42 ++ src/app/renderer/components/ui/SettingRow.tsx | 55 +++ .../renderer/components/ui/Toggle.spec.tsx | 48 ++ src/app/renderer/components/ui/Toggle.tsx | 59 +++ .../ui/anchoredSurfacePlacement.spec.ts | 88 ++++ .../components/ui/anchoredSurfacePlacement.ts | 121 +++++ src/app/renderer/components/ui/classNames.ts | 5 + .../components/ui/overlayLayerRegistry.ts | 58 +++ src/app/renderer/i18n/locales/en.ts | 3 + src/app/renderer/i18n/locales/zh-CN.ts | 3 + src/app/renderer/shell/AppShell.tsx | 12 +- .../renderer/shell/components/AppHeader.tsx | 3 + .../shell/components/AppShellOverlays.tsx | 12 +- .../shell/components/ControlCenter.tsx | 279 +++++------- .../RemoteDirectoryPickerWindow.tsx | 3 +- src/app/renderer/styles.css | 3 + src/app/renderer/styles/app-header.css | 102 +++-- src/app/renderer/styles/app-message.css | 30 +- src/app/renderer/styles/app-notifications.css | 52 +-- src/app/renderer/styles/base.css | 221 ++++----- src/app/renderer/styles/command-center.css | 51 ++- src/app/renderer/styles/control-center.css | 383 ++++++++++------ src/app/renderer/styles/cove-fields.css | 52 +-- src/app/renderer/styles/cove-window.css | 101 ++--- src/app/renderer/styles/document-node.css | 12 +- src/app/renderer/styles/image-node.css | 36 +- src/app/renderer/styles/issue-report.css | 8 +- src/app/renderer/styles/node-chrome.css | 74 +++ src/app/renderer/styles/note-node.css | 8 +- src/app/renderer/styles/remote-ui.css | 34 +- src/app/renderer/styles/role-node.css | 17 +- .../styles/settings-panel.buttons.css | 32 +- src/app/renderer/styles/settings-panel.css | 128 +++--- .../renderer/styles/settings-panel.lists.css | 2 +- .../renderer/styles/settings-panel.search.css | 2 +- .../styles/settings-panel.sidebar.css | 27 +- .../styles/task-node.agent-sessions.css | 6 +- src/app/renderer/styles/task-node.css | 34 +- src/app/renderer/styles/terminal-node.css | 56 ++- .../renderer/styles/themes/ember-light.css | 5 +- src/app/renderer/styles/themes/ember.css | 5 +- src/app/renderer/styles/ui-controls.css | 316 +++++++++++++ src/app/renderer/styles/ui-overlays.css | 76 ++++ src/app/renderer/styles/website-node.css | 26 +- src/app/renderer/styles/whats-new.css | 6 +- .../renderer/styles/workspace-agent-item.css | 27 +- src/app/renderer/styles/workspace-canvas.css | 121 ++--- .../styles/workspace-context-menu.css | 8 +- .../renderer/styles/workspace-overlays.css | 27 +- .../styles/workspace-overlays.spaces.css | 36 +- .../workspace-overlays.spaces.explorer.css | 4 +- ...orkspace-overlays.spaces.explorer.tree.css | 2 +- .../workspace-overlays.worktree.archive.css | 8 +- .../styles/workspace-overlays.worktree.css | 100 ++--- .../workspace-overlays.worktree.light.css | 104 ----- src/app/renderer/styles/workspace-pr-chip.css | 18 +- .../styles/workspace-search-panel.css | 46 +- src/app/renderer/styles/workspace-sidebar.css | 44 +- .../renderer/styles/workspace-sidebar.dnd.css | 4 +- .../styles/workspace-sidebar.fade.css | 24 +- .../styles/workspace-sidebar.rail.css | 12 +- .../styles/workspace-sidebar.surface.css | 2 +- .../presentation/renderer/SettingsPanel.tsx | 420 +++++++++--------- .../settingsPanel/SettingsPanelSidebar.tsx | 5 +- .../TaskPromptTemplatesMenu.tsx | 14 +- .../windows/TaskCreatorWindow.tsx | 1 + .../windows/TaskEditorWindow.tsx | 1 + tests/e2e/control-center.spec.ts | 202 ++++++++- tests/e2e/settings.spec.ts | 27 ++ tests/unit/scripts/ui-style-check.spec.ts | 123 +++++ 82 files changed, 3966 insertions(+), 1380 deletions(-) create mode 100644 scripts/check-ui-styles.mjs create mode 100644 src/app/renderer/components/ui/Button.spec.tsx create mode 100644 src/app/renderer/components/ui/Button.tsx create mode 100644 src/app/renderer/components/ui/Dialog.spec.tsx create mode 100644 src/app/renderer/components/ui/Dialog.tsx create mode 100644 src/app/renderer/components/ui/DismissableLayer.tsx create mode 100644 src/app/renderer/components/ui/IconButton.spec.tsx create mode 100644 src/app/renderer/components/ui/IconButton.tsx create mode 100644 src/app/renderer/components/ui/Popover.spec.tsx create mode 100644 src/app/renderer/components/ui/Popover.tsx create mode 100644 src/app/renderer/components/ui/SettingRow.spec.tsx create mode 100644 src/app/renderer/components/ui/SettingRow.tsx create mode 100644 src/app/renderer/components/ui/Toggle.spec.tsx create mode 100644 src/app/renderer/components/ui/Toggle.tsx create mode 100644 src/app/renderer/components/ui/anchoredSurfacePlacement.spec.ts create mode 100644 src/app/renderer/components/ui/anchoredSurfacePlacement.ts create mode 100644 src/app/renderer/components/ui/classNames.ts create mode 100644 src/app/renderer/components/ui/overlayLayerRegistry.ts create mode 100644 src/app/renderer/styles/node-chrome.css create mode 100644 src/app/renderer/styles/ui-controls.css create mode 100644 src/app/renderer/styles/ui-overlays.css delete mode 100644 src/app/renderer/styles/workspace-overlays.worktree.light.css create mode 100644 tests/unit/scripts/ui-style-check.spec.ts diff --git a/package.json b/package.json index 9c7988474..ad64fb465 100644 --- a/package.json +++ b/package.json @@ -102,13 +102,14 @@ "secrets:scan": "secretlint \"**/*\"", "naming-check:staged": "node scripts/check-naming-staged.mjs", "line-check:staged": "node scripts/check-max-lines.mjs", + "ui:style-check": "node scripts/check-ui-styles.mjs", "test:staged": "node scripts/run-vitest-related-staged.mjs", "test:e2e:pre-commit": "node scripts/run-precommit-e2e.mjs", "release:patch": "node scripts/prepare-release.mjs patch", "release:minor": "node scripts/prepare-release.mjs minor", "release:major": "node scripts/prepare-release.mjs major", "release:version": "node scripts/prepare-release.mjs", - "pre-commit": "pnpm line-check:staged && pnpm secret-check:staged && pnpm naming-check:staged && pnpm lint:fix && pnpm format-check:staged && pnpm check && pnpm test:staged && pnpm test:e2e:pre-commit", + "pre-commit": "pnpm line-check:staged && pnpm secret-check:staged && pnpm naming-check:staged && pnpm ui:style-check && pnpm lint:fix && pnpm format-check:staged && pnpm check && pnpm test:staged && pnpm test:e2e:pre-commit", "prepare": "husky" }, "dependencies": { diff --git a/scripts/check-ui-styles.mjs b/scripts/check-ui-styles.mjs new file mode 100644 index 000000000..e0d9ddb7f --- /dev/null +++ b/scripts/check-ui-styles.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const DEFAULT_REPO_ROOT = resolve(SCRIPT_DIR, '..') +const UI_STYLE_ROOT = 'src/app/renderer' +const BASE_TOKEN_FILE = 'src/app/renderer/styles/base.css' +const THEME_DIRECTORY = 'src/app/renderer/styles/themes/' + +const TOKEN_DECLARATION_PATTERN = /(--cove-[a-z0-9_-]+)\s*:/gi +const TOKEN_USAGE_PATTERN = /var\(\s*(--cove-[a-z0-9_-]+)/gi +const RAW_COLOR_PATTERN = + /(?:#[0-9a-f]{8}|#[0-9a-f]{6}|#[0-9a-f]{4}|#[0-9a-f]{3})(?![0-9a-f])|rgba?\s*\([^)]*\)/gi +const THEME_BRANCH_PATTERN = /\[\s*data-cove-theme(?:-id)?(?:\s*[~|^$*]?=|\s*\])/gi + +function colorSet(colors) { + return new Set(colors.map(canonicalizeRawColor)) +} + +// These colors mirror the third-party OpenCode terminal palette. They are part +// of the terminal-provider presentation protocol rather than OpenCove chrome. +const OPENCODE_TERMINAL_COLORS = colorSet([ + 'rgba(107, 138, 236, 0.65)', + 'rgba(104, 137, 226, 0.45)', + 'rgba(0, 0, 0, 0.45)', + 'rgba(153, 223, 255, 0.88)', + 'rgba(168, 233, 255, 0.42)', + 'rgba(0, 0, 0, 0.52)', + 'rgba(7, 12, 24, 0.9)', + 'rgba(18, 28, 50, 0.96)', + '#0a0f1d', + '#d6e4ff', + 'rgba(94, 156, 255, 0.35)', + 'rgba(255, 255, 255, 0.92)', + 'rgba(255, 255, 255, 0.6)', + 'rgba(255, 255, 255, 0.4)', + 'rgba(255, 255, 255, 0.12)', + 'rgba(255, 255, 255, 0.08)', + 'rgba(255, 255, 255, 0.04)', +]) + +// Provider icon accents identify external agent brands. Only these audited +// constants are exempt; other colors in the same component still need tokens. +const AGENT_PROVIDER_BRAND_COLORS = colorSet([ + 'rgba(148, 163, 184, 0.92)', + 'rgba(217, 119, 87, 0.96)', + 'rgba(125, 211, 252, 0.96)', + 'rgba(96, 165, 250, 0.96)', + 'rgba(196, 181, 253, 0.96)', +]) + +export const DEFAULT_RAW_COLOR_ALLOWLIST = new Map([ + ['src/app/renderer/styles/terminal-node.theme-opencode.css', OPENCODE_TERMINAL_COLORS], + ['src/app/renderer/styles/workspace-agent-item.css', AGENT_PROVIDER_BRAND_COLORS], +]) + +function normalizePath(filePath) { + return filePath.replaceAll('\\', '/') +} + +function canonicalizeRawColor(color) { + return color.toLowerCase().replace(/\s+/g, ' ').trim() +} + +function stripCommentsPreservingLocations(content) { + return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\r\n]/g, ' ')) +} + +function isTokenSource(filePath) { + return filePath === BASE_TOKEN_FILE || filePath.startsWith(THEME_DIRECTORY) +} + +function locationAt(content, index) { + const prefix = content.slice(0, index) + const line = prefix.split(/\r\n|\r|\n/).length + const lineStart = Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1 + const lineEndMatch = content.slice(index).search(/\r|\n/) + const lineEnd = lineEndMatch === -1 ? content.length : index + lineEndMatch + + return { + line, + column: index - lineStart + 1, + excerpt: content.slice(lineStart, lineEnd).trim(), + } +} + +function makeViolation({ file, content, index, rule, message }) { + return { + file, + rule, + message, + ...locationAt(content, index), + } +} + +function isAllowedRawColor(filePath, rawColor, allowlist) { + const allowedColors = allowlist.get(filePath) + return allowedColors?.has(canonicalizeRawColor(rawColor)) ?? false +} + +export function checkUiStyleFiles(files, { rawColorAllowlist = DEFAULT_RAW_COLOR_ALLOWLIST } = {}) { + const normalizedFiles = files.map(file => { + const content = file.content ?? '' + return { + path: normalizePath(file.path), + content, + source: stripCommentsPreservingLocations(content), + } + }) + const declaredTokens = new Set() + const tokenUsages = [] + const violations = [] + + for (const file of normalizedFiles) { + for (const match of file.source.matchAll(TOKEN_DECLARATION_PATTERN)) { + declaredTokens.add(match[1].toLowerCase()) + } + + for (const match of file.source.matchAll(TOKEN_USAGE_PATTERN)) { + tokenUsages.push({ file, index: match.index, token: match[1].toLowerCase() }) + } + + if (isTokenSource(file.path)) { + continue + } + + for (const match of file.source.matchAll(RAW_COLOR_PATTERN)) { + if (isAllowedRawColor(file.path, match[0], rawColorAllowlist)) { + continue + } + + violations.push( + makeViolation({ + file: file.path, + content: file.content, + index: match.index, + rule: 'raw-color', + message: `Replace raw color \`${match[0]}\` with a declared \`--cove-*\` token.`, + }), + ) + } + + for (const match of file.source.matchAll(THEME_BRANCH_PATTERN)) { + violations.push( + makeViolation({ + file: file.path, + content: file.content, + index: match.index, + rule: 'component-theme-branch', + message: 'Move `data-cove-theme` branching into base.css or styles/themes/**.', + }), + ) + } + } + + for (const usage of tokenUsages) { + if (declaredTokens.has(usage.token)) { + continue + } + + violations.push( + makeViolation({ + file: usage.file.path, + content: usage.file.content, + index: usage.index, + rule: 'undeclared-token', + message: `Declare \`${usage.token}\` before using it.`, + }), + ) + } + + return violations.sort( + (left, right) => + left.file.localeCompare(right.file) || + left.line - right.line || + left.column - right.column || + left.rule.localeCompare(right.rule), + ) +} + +function collectCssFiles(directory, repoRoot, files) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolutePath = resolve(directory, entry.name) + if (entry.isDirectory()) { + collectCssFiles(absolutePath, repoRoot, files) + continue + } + if (!entry.isFile() || !entry.name.endsWith('.css')) { + continue + } + + files.push({ + path: normalizePath(relative(repoRoot, absolutePath)), + content: readFileSync(absolutePath, 'utf8'), + }) + } +} + +export function readUiStyleFiles(repoRoot = DEFAULT_REPO_ROOT) { + const files = [] + collectCssFiles(resolve(repoRoot, UI_STYLE_ROOT), repoRoot, files) + return files +} + +export function formatUiStyleViolations(violations) { + if (violations.length === 0) { + return '' + } + + const lines = [`[ui-style-check] Found ${violations.length} UI style violation(s):`] + for (const violation of violations) { + lines.push( + `- ${violation.file}:${violation.line}:${violation.column} [${violation.rule}]`, + ` ${violation.message}`, + ` ${violation.excerpt}`, + ) + } + lines.push('', 'See docs/ui/README.md for the token and theme rules.') + return `${lines.join('\n')}\n` +} + +export function runUiStyleCheck({ repoRoot = DEFAULT_REPO_ROOT, stderr = process.stderr } = {}) { + const violations = checkUiStyleFiles(readUiStyleFiles(repoRoot)) + if (violations.length === 0) { + return 0 + } + + stderr.write(formatUiStyleViolations(violations)) + return 1 +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null +if (invokedPath === import.meta.url) { + process.exitCode = runUiStyleCheck() +} diff --git a/src/app/renderer/components/CoveSelect.tsx b/src/app/renderer/components/CoveSelect.tsx index 197e18158..8a10e10f1 100644 --- a/src/app/renderer/components/CoveSelect.tsx +++ b/src/app/renderer/components/CoveSelect.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' +import { DismissableLayer } from './ui/DismissableLayer' export interface CoveSelectOption { value: string @@ -142,29 +143,14 @@ export function CoveSelect({ updateMenuPosition() - const handlePointerDown = (event: PointerEvent) => { - const target = event.target - if (!(target instanceof Node)) { - return - } - - if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) { - return - } - - closeMenu() - } - const handleWindowChange = () => { updateMenuPosition() } - document.addEventListener('pointerdown', handlePointerDown, true) window.addEventListener('resize', handleWindowChange) window.addEventListener('scroll', handleWindowChange, true) return () => { - document.removeEventListener('pointerdown', handlePointerDown, true) window.removeEventListener('resize', handleWindowChange) window.removeEventListener('scroll', handleWindowChange, true) } @@ -337,12 +323,19 @@ export function CoveSelect({ {isOpen && menuPosition ? createPortal( -
{ + closeMenu() + if (reason === 'escape') { + triggerRef.current?.focus() + } + }} style={{ top: menuPosition.top, left: menuPosition.left, @@ -384,7 +377,7 @@ export function CoveSelect({ ) })} -
, + , document.body, ) : null} diff --git a/src/app/renderer/components/ui/Button.spec.tsx b/src/app/renderer/components/ui/Button.spec.tsx new file mode 100644 index 000000000..43b929cbb --- /dev/null +++ b/src/app/renderer/components/ui/Button.spec.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { Button } from './Button' + +describe('Button', () => { + it('defaults to a non-submitting button and forwards its ref', () => { + const ref = React.createRef() + + render() + + const button = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement + expect(button.type).toBe('button') + expect(button.classList.contains('cove-button--secondary')).toBe(true) + expect(button.classList.contains('cove-button--md')).toBe(true) + expect(ref.current).toBe(button) + }) + + it('supports an explicit submit type', () => { + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + + render( +
+ +
, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it('exposes loading state and prevents activation', () => { + const onClick = vi.fn() + + render( + , + ) + + const button = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(button.getAttribute('aria-busy')).toBe('true') + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/renderer/components/ui/Button.tsx b/src/app/renderer/components/ui/Button.tsx new file mode 100644 index 000000000..f0bf0aad8 --- /dev/null +++ b/src/app/renderer/components/ui/Button.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { classNames } from './classNames' + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' +export type ButtonSize = 'sm' | 'md' + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant + size?: ButtonSize + loading?: boolean +} + +export const Button = React.forwardRef(function Button( + { + variant = 'secondary', + size = 'md', + loading = false, + disabled = false, + type = 'button', + className, + children, + 'aria-busy': ariaBusy, + ...buttonProps + }, + forwardedRef, +) { + return ( + + ) +}) diff --git a/src/app/renderer/components/ui/Dialog.spec.tsx b/src/app/renderer/components/ui/Dialog.spec.tsx new file mode 100644 index 000000000..a9260a4ae --- /dev/null +++ b/src/app/renderer/components/ui/Dialog.spec.tsx @@ -0,0 +1,67 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { Dialog } from './Dialog' + +function DialogHarness({ onDismiss = vi.fn() }: { onDismiss?: (reason: string) => void }) { + const [open, setOpen] = React.useState(false) + const triggerRef = React.useRef(null) + const initialFocusRef = React.useRef(null) + + return ( + <> + + { + onDismiss(reason) + setOpen(false) + }} + > + + + + + ) +} + +describe('Dialog', () => { + it('moves focus inside, traps Tab, and restores focus after Escape', () => { + vi.useFakeTimers() + const onDismiss = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + act(() => vi.runOnlyPendingTimers()) + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Search' })) + + const save = screen.getByRole('button', { name: 'Save' }) + save.focus() + fireEvent.keyDown(save, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Search' })) + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onDismiss).toHaveBeenCalledWith('escape') + act(() => vi.runOnlyPendingTimers()) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Open' })) + vi.useRealTimers() + }) + + it('dismisses a pointer down outside the dialog', () => { + vi.useFakeTimers() + const onDismiss = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + act(() => vi.runOnlyPendingTimers()) + + fireEvent.pointerDown(screen.getByRole('dialog', { name: 'Preferences' }).parentElement!) + + expect(onDismiss).toHaveBeenCalledWith('pointer-down-outside') + vi.useRealTimers() + }) +}) diff --git a/src/app/renderer/components/ui/Dialog.tsx b/src/app/renderer/components/ui/Dialog.tsx new file mode 100644 index 000000000..de2faf808 --- /dev/null +++ b/src/app/renderer/components/ui/Dialog.tsx @@ -0,0 +1,171 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { classNames } from './classNames' +import { DismissableLayer, type DismissableLayerDismissReason } from './DismissableLayer' + +export type DialogDismissReason = DismissableLayerDismissReason + +export interface DialogProps extends Omit< + React.HTMLAttributes, + 'aria-modal' | 'children' | 'role' +> { + open: boolean + onDismiss: (reason: DialogDismissReason) => void + children: React.ReactNode + initialFocusRef?: React.RefObject + returnFocus?: React.RefObject | false + backdropClassName?: string + backdropTestId?: string + portalContainer?: HTMLElement +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not(:disabled)', + 'input:not(:disabled)', + 'select:not(:disabled)', + 'textarea:not(:disabled)', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +function focusWithoutScrolling(element: HTMLElement): void { + try { + element.focus({ preventScroll: true }) + } catch { + element.focus() + } +} + +function getFocusableElements(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(element => { + return !element.hidden && element.getAttribute('aria-hidden') !== 'true' + }) +} + +export const Dialog = React.forwardRef(function Dialog( + { + open, + onDismiss, + children, + initialFocusRef, + returnFocus, + backdropClassName, + backdropTestId, + portalContainer, + className, + onKeyDown, + ...dialogProps + }, + forwardedRef, +): React.JSX.Element | null { + const dialogRef = React.useRef(null) + const restoreFocusRef = React.useRef(null) + + const setRefs = React.useCallback( + (element: HTMLDivElement | null): void => { + dialogRef.current = element + if (typeof forwardedRef === 'function') { + forwardedRef(element) + } else if (forwardedRef) { + forwardedRef.current = element + } + }, + [forwardedRef], + ) + + React.useLayoutEffect(() => { + if (!open) { + return + } + + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null + const restoreFocusTarget = + returnFocus === false ? null : (returnFocus?.current ?? restoreFocusRef.current) + const focusTimer = window.setTimeout(() => { + const dialog = dialogRef.current + const focusTarget = + initialFocusRef?.current ?? (dialog ? getFocusableElements(dialog)[0] : null) + if (focusTarget) { + focusWithoutScrolling(focusTarget) + } else if (dialog) { + focusWithoutScrolling(dialog) + } + }, 0) + + return () => { + window.clearTimeout(focusTimer) + if (returnFocus === false) { + return + } + + restoreFocusRef.current = null + if (restoreFocusTarget?.isConnected) { + window.setTimeout(() => { + if (restoreFocusTarget.isConnected) { + focusWithoutScrolling(restoreFocusTarget) + } + }, 0) + } + } + }, [initialFocusRef, open, returnFocus]) + + if (!open || typeof document === 'undefined') { + return null + } + + const handleKeyDown = (event: React.KeyboardEvent): void => { + onKeyDown?.(event) + if (event.defaultPrevented || event.key !== 'Tab') { + return + } + + const dialog = dialogRef.current + if (!dialog || !(event.target instanceof Node) || !dialog.contains(event.target)) { + return + } + + const focusableElements = getFocusableElements(dialog) + if (focusableElements.length === 0) { + event.preventDefault() + focusWithoutScrolling(dialog) + return + } + + const first = focusableElements[0] + const last = focusableElements[focusableElements.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + focusWithoutScrolling(last) + return + } + + if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + focusWithoutScrolling(first) + } + } + + return createPortal( +
+ { + onDismiss(reason) + }} + onKeyDown={handleKeyDown} + > + {children} + +
, + portalContainer ?? document.body, + ) +}) diff --git a/src/app/renderer/components/ui/DismissableLayer.tsx b/src/app/renderer/components/ui/DismissableLayer.tsx new file mode 100644 index 000000000..f0dc9ecbe --- /dev/null +++ b/src/app/renderer/components/ui/DismissableLayer.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import { + claimOverlayLayerEvent, + createOverlayLayerId, + getOverlayBranches, + isTopOverlayLayer, + registerOverlayBranch, + registerOverlayLayer, + type OverlayLayerId, +} from './overlayLayerRegistry' + +export type DismissableLayerDismissReason = 'escape' | 'pointer-down-outside' + +export interface DismissableLayerProps extends React.HTMLAttributes { + onDismiss: (reason: DismissableLayerDismissReason, event: KeyboardEvent | PointerEvent) => void + dismissOnEscape?: boolean + dismissOnPointerDownOutside?: boolean + branchRefs?: readonly React.RefObject[] +} + +const OverlayLayerContext = React.createContext(null) + +function assignRef(ref: React.ForwardedRef, value: T | null): void { + if (typeof ref === 'function') { + ref(value) + return + } + + if (ref) { + ref.current = value + } +} + +function eventOccurredWithin(event: Event, element: HTMLElement | null): boolean { + if (!element) { + return false + } + + if (typeof event.composedPath === 'function' && event.composedPath().includes(element)) { + return true + } + + return event.target instanceof Node && element.contains(event.target) +} + +export function useOverlayBranch( + branchRef: React.RefObject, + enabled = true, +): void { + const layerId = React.useContext(OverlayLayerContext) + + React.useLayoutEffect(() => { + const branch = branchRef.current + if (!enabled || layerId === null || !branch) { + return + } + + return registerOverlayBranch(layerId, branch) + }, [branchRef, enabled, layerId]) +} + +export const DismissableLayer = React.forwardRef( + function DismissableLayer( + { + onDismiss, + dismissOnEscape = true, + dismissOnPointerDownOutside = true, + branchRefs = [], + children, + ...rest + }, + forwardedRef, + ): React.JSX.Element { + const [layerId] = React.useState(createOverlayLayerId) + const rootRef = React.useRef(null) + const latestOptionsRef = React.useRef({ onDismiss, branchRefs }) + latestOptionsRef.current = { onDismiss, branchRefs } + + const setRefs = React.useCallback( + (element: HTMLDivElement | null): void => { + rootRef.current = element + assignRef(forwardedRef, element) + }, + [forwardedRef], + ) + + React.useLayoutEffect(() => registerOverlayLayer(layerId), [layerId]) + + React.useEffect(() => { + const ownerDocument = rootRef.current?.ownerDocument ?? document + + const eventIsInsideLayer = (event: Event): boolean => { + if (eventOccurredWithin(event, rootRef.current)) { + return true + } + + const directBranches = latestOptionsRef.current.branchRefs + if (directBranches.some(ref => eventOccurredWithin(event, ref.current))) { + return true + } + + return getOverlayBranches(layerId).some(branch => eventOccurredWithin(event, branch)) + } + + const handlePointerDown = (event: PointerEvent): void => { + if ( + !dismissOnPointerDownOutside || + !isTopOverlayLayer(layerId) || + eventIsInsideLayer(event) || + !claimOverlayLayerEvent(layerId, event) + ) { + return + } + + latestOptionsRef.current.onDismiss('pointer-down-outside', event) + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (!dismissOnEscape || event.key !== 'Escape' || !claimOverlayLayerEvent(layerId, event)) { + return + } + + event.preventDefault() + event.stopPropagation() + latestOptionsRef.current.onDismiss('escape', event) + } + + ownerDocument.addEventListener('pointerdown', handlePointerDown, true) + ownerDocument.addEventListener('keydown', handleKeyDown, true) + + return () => { + ownerDocument.removeEventListener('pointerdown', handlePointerDown, true) + ownerDocument.removeEventListener('keydown', handleKeyDown, true) + } + }, [dismissOnEscape, dismissOnPointerDownOutside, layerId]) + + return ( + +
+ {children} +
+
+ ) + }, +) diff --git a/src/app/renderer/components/ui/IconButton.spec.tsx b/src/app/renderer/components/ui/IconButton.spec.tsx new file mode 100644 index 000000000..d92fad883 --- /dev/null +++ b/src/app/renderer/components/ui/IconButton.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { IconButton } from './IconButton' + +describe('IconButton', () => { + it('uses its required label as the accessible name and fallback title', () => { + render( + + + , + ) + + const button = screen.getByRole('button', { name: 'Close panel' }) as HTMLButtonElement + expect(button.type).toBe('button') + expect(button.title).toBe('Close panel') + expect(button.classList.contains('cove-icon-button--danger')).toBe(true) + expect(button.classList.contains('cove-icon-button--xs')).toBe(true) + }) + + it('keeps native disabled semantics', () => { + render( + + + , + ) + + const button = screen.getByRole('button', { name: 'Refresh' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + }) +}) diff --git a/src/app/renderer/components/ui/IconButton.tsx b/src/app/renderer/components/ui/IconButton.tsx new file mode 100644 index 000000000..e31003d5d --- /dev/null +++ b/src/app/renderer/components/ui/IconButton.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { classNames } from './classNames' + +export type IconButtonVariant = 'ghost' | 'secondary' | 'danger' +export type IconButtonSize = 'xs' | 'sm' | 'md' + +export interface IconButtonProps extends Omit< + React.ButtonHTMLAttributes, + 'aria-label' +> { + label: string + variant?: IconButtonVariant + size?: IconButtonSize +} + +export const IconButton = React.forwardRef(function IconButton( + { + label, + variant = 'ghost', + size = 'sm', + type = 'button', + title, + className, + children, + ...buttonProps + }, + forwardedRef, +) { + return ( + + ) +}) diff --git a/src/app/renderer/components/ui/Popover.spec.tsx b/src/app/renderer/components/ui/Popover.spec.tsx new file mode 100644 index 000000000..b119cb849 --- /dev/null +++ b/src/app/renderer/components/ui/Popover.spec.tsx @@ -0,0 +1,244 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { createPortal } from 'react-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Popover, type PopoverDismissReason } from './Popover' +import { useOverlayBranch } from './DismissableLayer' + +interface RectValues { + left: number + top: number + width: number + height: number +} + +function toDomRect({ left, top, width, height }: RectValues): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect +} + +class MockResizeObserver implements ResizeObserver { + static instances: MockResizeObserver[] = [] + + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + + constructor(private readonly callback: ResizeObserverCallback) { + MockResizeObserver.instances.push(this) + } + + takeRecords(): ResizeObserverEntry[] { + return [] + } + + notify(): void { + this.callback([], this) + } +} + +function ControlledPopover({ + open, + onDismiss, + children = , +}: { + open: boolean + onDismiss: (reason: PopoverDismissReason) => void + children?: React.ReactNode +}): React.JSX.Element { + const anchorRef = React.useRef(null) + + return ( + <> + + + {children} + + + ) +} + +function PortalBranch(): React.JSX.Element { + const branchRef = React.useRef(null) + useOverlayBranch(branchRef) + + return createPortal( + , + document.body, + ) +} + +describe('Popover', () => { + beforeEach(() => { + MockResizeObserver.instances = [] + vi.stubGlobal('ResizeObserver', MockResizeObserver) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 400 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 300 }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('dismisses only the top layer for outside pointer and Escape events', () => { + const firstDismiss = vi.fn() + const secondDismiss = vi.fn() + + render( + <> + + + + , + ) + + fireEvent.pointerDown(screen.getByTestId('outside')) + + expect(firstDismiss).not.toHaveBeenCalled() + expect(secondDismiss).toHaveBeenCalledWith('pointer-down-outside') + + firstDismiss.mockClear() + secondDismiss.mockClear() + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(firstDismiss).not.toHaveBeenCalled() + expect(secondDismiss).toHaveBeenCalledWith('escape') + }) + + it('treats registered child portals as part of the layer', () => { + const onDismiss = vi.fn() + + render( + + + , + ) + + fireEvent.pointerDown(screen.getByTestId('portal-branch')) + + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('restores focus after Escape but not after an outside pointer dismissal', () => { + function Harness(): React.JSX.Element { + const [open, setOpen] = React.useState(true) + const anchorRef = React.useRef(null) + + return ( + <> + + { + setOpen(false) + }} + > + + + + + ) + } + + const first = render() + screen.getByTestId('inside-focus-target').focus() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(document.activeElement).toBe(screen.getByTestId('focus-anchor')) + first.unmount() + + render() + const outside = screen.getByTestId('outside-focus-target') + outside.focus() + fireEvent.pointerDown(outside) + expect(document.activeElement).toBe(outside) + }) + + it('restores focus when a parent closes it programmatically', () => { + const onDismiss = vi.fn() + const { rerender } = render() + screen.getByRole('button', { name: 'Inside' }).focus() + + rerender() + + expect(document.activeElement).toBe(screen.getByTestId('anchor')) + }) + + it('repositions for scroll, resize, and observed size changes', () => { + let anchorRect = toDomRect({ left: 40, top: 30, width: 60, height: 24 }) + let surfaceRect = toDomRect({ left: 0, top: 0, width: 120, height: 80 }) + + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement, + ) { + return this.dataset.testid === 'anchor' ? anchorRect : surfaceRect + }) + + render() + const popover = screen.getByTestId('popover') + expect(popover.style.left).toBe('40px') + expect(popover.style.top).toBe('62px') + + anchorRect = toDomRect({ left: 80, top: 40, width: 60, height: 24 }) + act(() => { + window.dispatchEvent(new Event('scroll')) + }) + expect(popover.style.left).toBe('80px') + expect(popover.style.top).toBe('72px') + + anchorRect = toDomRect({ left: 100, top: 50, width: 60, height: 24 }) + act(() => { + window.dispatchEvent(new Event('resize')) + }) + expect(popover.style.left).toBe('100px') + expect(popover.style.top).toBe('82px') + + anchorRect = toDomRect({ left: 260, top: 50, width: 60, height: 24 }) + surfaceRect = toDomRect({ left: 0, top: 0, width: 180, height: 80 }) + act(() => { + MockResizeObserver.instances[0]?.notify() + }) + expect(popover.style.left).toBe('208px') + expect(popover.style.top).toBe('82px') + }) + + it('cleans up window, document, and ResizeObserver subscriptions', () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue( + toDomRect({ left: 20, top: 20, width: 80, height: 40 }), + ) + const removeWindowListener = vi.spyOn(window, 'removeEventListener') + const removeDocumentListener = vi.spyOn(document, 'removeEventListener') + const { unmount } = render() + const observer = MockResizeObserver.instances[0] + + unmount() + + expect(observer?.disconnect).toHaveBeenCalledOnce() + expect(removeWindowListener).toHaveBeenCalledWith('resize', expect.any(Function)) + expect(removeWindowListener).toHaveBeenCalledWith('scroll', expect.any(Function), true) + expect(removeDocumentListener).toHaveBeenCalledWith('pointerdown', expect.any(Function), true) + expect(removeDocumentListener).toHaveBeenCalledWith('keydown', expect.any(Function), true) + }) +}) diff --git a/src/app/renderer/components/ui/Popover.tsx b/src/app/renderer/components/ui/Popover.tsx new file mode 100644 index 000000000..9bcd357a7 --- /dev/null +++ b/src/app/renderer/components/ui/Popover.tsx @@ -0,0 +1,206 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { + placeAnchoredSurface, + type AnchoredSurfaceAlign, + type AnchoredSurfacePlacement, + type AnchoredSurfaceSide, +} from './anchoredSurfacePlacement' +import { DismissableLayer, type DismissableLayerDismissReason } from './DismissableLayer' + +export type PopoverDismissReason = DismissableLayerDismissReason + +export interface PopoverProps extends Omit< + React.HTMLAttributes, + 'aria-modal' | 'children' +> { + open: boolean + anchorRef: React.RefObject + onDismiss: (reason: PopoverDismissReason) => void + children: React.ReactNode + side?: AnchoredSurfaceSide + align?: AnchoredSurfaceAlign + offset?: number + collisionPadding?: number + returnFocus?: React.RefObject | false + branchRefs?: readonly React.RefObject[] + portalContainer?: HTMLElement +} + +function assignRef(ref: React.ForwardedRef, value: T | null): void { + if (typeof ref === 'function') { + ref(value) + return + } + + if (ref) { + ref.current = value + } +} + +function focusWithoutScrolling(element: HTMLElement): void { + try { + element.focus({ preventScroll: true }) + } catch { + element.focus() + } +} + +function placementsMatch( + previous: AnchoredSurfacePlacement | null, + next: AnchoredSurfacePlacement, +): boolean { + return ( + previous !== null && + previous.side === next.side && + Math.abs(previous.left - next.left) < 0.5 && + Math.abs(previous.top - next.top) < 0.5 + ) +} + +export const Popover = React.forwardRef(function Popover( + { + open, + anchorRef, + onDismiss, + children, + side = 'bottom', + align = 'start', + offset = 8, + collisionPadding = 12, + returnFocus, + branchRefs = [], + portalContainer, + className, + style, + ...rest + }, + forwardedRef, +): React.JSX.Element | null { + const surfaceRef = React.useRef(null) + const [placement, setPlacement] = React.useState(null) + const previousOpenRef = React.useRef(open) + const dismissReasonRef = React.useRef(null) + + const setRefs = React.useCallback( + (element: HTMLDivElement | null): void => { + surfaceRef.current = element + assignRef(forwardedRef, element) + }, + [forwardedRef], + ) + + const updatePlacement = React.useCallback((): void => { + const anchor = anchorRef.current + const surface = surfaceRef.current + if (!anchor || !surface) { + return + } + + const anchorRect = anchor.getBoundingClientRect() + const surfaceRect = surface.getBoundingClientRect() + const view = anchor.ownerDocument.defaultView ?? window + const nextPlacement = placeAnchoredSurface({ + anchorRect, + surfaceSize: { width: surfaceRect.width, height: surfaceRect.height }, + viewport: { width: view.innerWidth, height: view.innerHeight }, + side, + align, + offset, + collisionPadding, + }) + setPlacement(previous => (placementsMatch(previous, nextPlacement) ? previous : nextPlacement)) + }, [align, anchorRef, collisionPadding, offset, side]) + + React.useLayoutEffect(() => { + if (!open) { + setPlacement(null) + return + } + + const anchor = anchorRef.current + const surface = surfaceRef.current + if (!anchor || !surface) { + return + } + + const view = anchor.ownerDocument.defaultView ?? window + updatePlacement() + view.addEventListener('resize', updatePlacement) + view.addEventListener('scroll', updatePlacement, true) + + const observer = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePlacement) + observer?.observe(anchor) + observer?.observe(surface) + + return () => { + view.removeEventListener('resize', updatePlacement) + view.removeEventListener('scroll', updatePlacement, true) + observer?.disconnect() + } + }, [anchorRef, open, updatePlacement]) + + React.useLayoutEffect(() => { + const wasOpen = previousOpenRef.current + previousOpenRef.current = open + + if (open) { + dismissReasonRef.current = null + return + } + + if (!wasOpen) { + return + } + + const dismissReason = dismissReasonRef.current + dismissReasonRef.current = null + if (dismissReason === 'pointer-down-outside' || returnFocus === false) { + return + } + + const focusTarget = (returnFocus ?? anchorRef).current + if (focusTarget?.isConnected) { + focusWithoutScrolling(focusTarget) + } + }, [anchorRef, open, returnFocus]) + + const handleDismiss = React.useCallback( + (reason: PopoverDismissReason): void => { + dismissReasonRef.current = reason + onDismiss(reason) + }, + [onDismiss], + ) + + if (!open || typeof document === 'undefined') { + return null + } + + const target = portalContainer ?? anchorRef.current?.ownerDocument.body ?? document.body + if (!target) { + return null + } + + return createPortal( + + {children} + , + target, + ) +}) diff --git a/src/app/renderer/components/ui/SettingRow.spec.tsx b/src/app/renderer/components/ui/SettingRow.spec.tsx new file mode 100644 index 000000000..62fa196f2 --- /dev/null +++ b/src/app/renderer/components/ui/SettingRow.spec.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SettingRow } from './SettingRow' +import { Toggle } from './Toggle' + +describe('SettingRow', () => { + it('associates its visible label and description with the control', () => { + const onCheckedChange = vi.fn() + + render( + + } + />, + ) + + const checkbox = screen.getByRole('switch', { name: 'Show minimap' }) + const descriptionId = checkbox.getAttribute('aria-describedby') + expect(document.getElementById(descriptionId ?? '')?.textContent).toBe( + 'Display an overview of the canvas.', + ) + + fireEvent.click(screen.getByText('Show minimap')) + expect(onCheckedChange).toHaveBeenCalledWith(true) + }) + + it('supports a row without a description', () => { + render( + } + />, + ) + + expect( + screen.getByRole('switch', { name: 'Compact mode' }).getAttribute('aria-describedby'), + ).toBeNull() + }) +}) diff --git a/src/app/renderer/components/ui/SettingRow.tsx b/src/app/renderer/components/ui/SettingRow.tsx new file mode 100644 index 000000000..e841817cd --- /dev/null +++ b/src/app/renderer/components/ui/SettingRow.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { classNames } from './classNames' + +interface ControlAssociationProps { + id?: string + 'aria-labelledby'?: string + 'aria-describedby'?: string +} + +export interface SettingRowProps extends Omit, 'children'> { + label: React.ReactNode + description?: React.ReactNode + control: React.ReactElement +} + +function joinIdReferences(...values: Array): string | undefined { + const ids = values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? []) + return ids.length > 0 ? [...new Set(ids)].join(' ') : undefined +} + +export function SettingRow({ + label, + description, + control, + className, + ...rowProps +}: SettingRowProps): React.JSX.Element { + const generatedId = React.useId() + const labelId = `${generatedId}-label` + const descriptionId = + description === undefined || description === null ? undefined : `${generatedId}-description` + const typedControl = control as React.ReactElement + const controlId = typedControl.props.id ?? `${generatedId}-control` + const associatedControl = React.cloneElement(typedControl, { + id: controlId, + 'aria-labelledby': joinIdReferences(typedControl.props['aria-labelledby'], labelId), + 'aria-describedby': joinIdReferences(typedControl.props['aria-describedby'], descriptionId), + }) + + return ( +
+
+ + {descriptionId ? ( +

+ {description} +

+ ) : null} +
+
{associatedControl}
+
+ ) +} diff --git a/src/app/renderer/components/ui/Toggle.spec.tsx b/src/app/renderer/components/ui/Toggle.spec.tsx new file mode 100644 index 000000000..946f080c8 --- /dev/null +++ b/src/app/renderer/components/ui/Toggle.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Toggle } from './Toggle' + +describe('Toggle', () => { + it('uses a native checkbox and reports the requested checked state', () => { + const onCheckedChange = vi.fn() + const { rerender } = render( + , + ) + + const checkbox = screen.getByRole('switch', { name: 'Show sidebar' }) as HTMLInputElement + expect(checkbox.checked).toBe(false) + + fireEvent.click(checkbox) + expect(onCheckedChange).toHaveBeenCalledWith(true) + + rerender() + expect(checkbox.checked).toBe(true) + }) + + it('associates supporting text and keeps disabled semantics', () => { + const onCheckedChange = vi.fn() + + render( + <> +

Keeps navigation visible.

+ + , + ) + + const toggle = screen.getByTestId('sidebar-toggle') + const checkbox = screen.getByRole('switch', { name: 'Show sidebar' }) as HTMLInputElement + expect(toggle.contains(checkbox)).toBe(true) + expect(checkbox.getAttribute('role')).toBe('switch') + expect(checkbox.disabled).toBe(true) + expect(checkbox.getAttribute('aria-describedby')).toBe('toggle-help') + fireEvent.click(checkbox) + expect(onCheckedChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/renderer/components/ui/Toggle.tsx b/src/app/renderer/components/ui/Toggle.tsx new file mode 100644 index 000000000..0d4c8387c --- /dev/null +++ b/src/app/renderer/components/ui/Toggle.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { classNames } from './classNames' + +export interface ToggleProps extends Omit< + React.InputHTMLAttributes, + 'aria-label' | 'checked' | 'className' | 'defaultChecked' | 'disabled' | 'onChange' | 'type' +> { + checked: boolean + onCheckedChange: (checked: boolean) => void + disabled?: boolean + label: string + describedBy?: string + testId?: string + className?: string +} + +function joinIdReferences(...values: Array): string | undefined { + const ids = values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? []) + return ids.length > 0 ? [...new Set(ids)].join(' ') : undefined +} + +export const Toggle = React.forwardRef(function Toggle( + { + checked, + onCheckedChange, + disabled = false, + label, + describedBy, + testId, + className, + 'aria-describedby': ariaDescribedBy, + ...inputProps + }, + forwardedRef, +) { + return ( + + onCheckedChange(event.currentTarget.checked)} + /> + + ) +}) diff --git a/src/app/renderer/components/ui/anchoredSurfacePlacement.spec.ts b/src/app/renderer/components/ui/anchoredSurfacePlacement.spec.ts new file mode 100644 index 000000000..5b701c0ba --- /dev/null +++ b/src/app/renderer/components/ui/anchoredSurfacePlacement.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { placeAnchoredSurface } from './anchoredSurfacePlacement' + +describe('placeAnchoredSurface', () => { + it('places a start-aligned surface below its anchor', () => { + expect( + placeAnchoredSurface({ + anchorRect: { + left: 80, + right: 120, + top: 40, + bottom: 68, + width: 40, + height: 28, + }, + surfaceSize: { width: 120, height: 80 }, + viewport: { width: 400, height: 300 }, + side: 'bottom', + align: 'start', + offset: 8, + collisionPadding: 12, + }), + ).toEqual({ left: 80, top: 76, side: 'bottom' }) + }) + + it('supports top placement with end alignment', () => { + expect( + placeAnchoredSurface({ + anchorRect: { + left: 250, + right: 290, + top: 200, + bottom: 230, + width: 40, + height: 30, + }, + surfaceSize: { width: 100, height: 60 }, + viewport: { width: 400, height: 300 }, + side: 'top', + align: 'end', + offset: 10, + collisionPadding: 12, + }), + ).toEqual({ left: 190, top: 130, side: 'top' }) + }) + + it('flips to the opposite side when the preferred side collides', () => { + expect( + placeAnchoredSurface({ + anchorRect: { + left: 220, + right: 260, + top: 252, + bottom: 280, + width: 40, + height: 28, + }, + surfaceSize: { width: 100, height: 80 }, + viewport: { width: 400, height: 300 }, + side: 'bottom', + align: 'center', + offset: 8, + collisionPadding: 12, + }), + ).toEqual({ left: 190, top: 164, side: 'top' }) + }) + + it('clamps alignment to the collision boundary', () => { + expect( + placeAnchoredSurface({ + anchorRect: { + left: 4, + right: 24, + top: 40, + bottom: 60, + width: 20, + height: 20, + }, + surfaceSize: { width: 100, height: 40 }, + viewport: { width: 320, height: 200 }, + side: 'bottom', + align: 'center', + offset: 6, + collisionPadding: 12, + }), + ).toEqual({ left: 12, top: 66, side: 'bottom' }) + }) +}) diff --git a/src/app/renderer/components/ui/anchoredSurfacePlacement.ts b/src/app/renderer/components/ui/anchoredSurfacePlacement.ts new file mode 100644 index 000000000..7d9ca3b9b --- /dev/null +++ b/src/app/renderer/components/ui/anchoredSurfacePlacement.ts @@ -0,0 +1,121 @@ +export type AnchoredSurfaceSide = 'bottom' | 'top' +export type AnchoredSurfaceAlign = 'start' | 'center' | 'end' + +export interface AnchoredSurfaceRect { + left: number + right: number + top: number + bottom: number + width: number + height: number +} + +export interface AnchoredSurfaceSize { + width: number + height: number +} + +export interface AnchoredSurfaceViewport { + width: number + height: number +} + +export interface AnchoredSurfacePlacement { + left: number + top: number + side: AnchoredSurfaceSide +} + +function clampCoordinate( + coordinate: number, + surfaceExtent: number, + viewportExtent: number, + padding: number, +): number { + const maximum = Math.max(padding, viewportExtent - padding - surfaceExtent) + return Math.min(Math.max(coordinate, padding), maximum) +} + +function resolveSide(options: { + preferredSide: AnchoredSurfaceSide + anchorRect: AnchoredSurfaceRect + surfaceHeight: number + viewportHeight: number + offset: number + padding: number +}): AnchoredSurfaceSide { + const { preferredSide, anchorRect, surfaceHeight, viewportHeight, offset, padding } = options + const availableBySide: Record = { + bottom: viewportHeight - padding - anchorRect.bottom - offset, + top: anchorRect.top - padding - offset, + } + const oppositeSide: AnchoredSurfaceSide = preferredSide === 'bottom' ? 'top' : 'bottom' + + if (surfaceHeight <= availableBySide[preferredSide]) { + return preferredSide + } + + if (surfaceHeight <= availableBySide[oppositeSide]) { + return oppositeSide + } + + return availableBySide[oppositeSide] > availableBySide[preferredSide] + ? oppositeSide + : preferredSide +} + +function resolveAlignedLeft(options: { + anchorRect: AnchoredSurfaceRect + surfaceWidth: number + align: AnchoredSurfaceAlign +}): number { + const { anchorRect, surfaceWidth, align } = options + + if (align === 'center') { + return anchorRect.left + (anchorRect.width - surfaceWidth) / 2 + } + + if (align === 'end') { + return anchorRect.right - surfaceWidth + } + + return anchorRect.left +} + +export function placeAnchoredSurface(options: { + anchorRect: AnchoredSurfaceRect + surfaceSize: AnchoredSurfaceSize + viewport: AnchoredSurfaceViewport + side?: AnchoredSurfaceSide + align?: AnchoredSurfaceAlign + offset?: number + collisionPadding?: number +}): AnchoredSurfacePlacement { + const side = options.side ?? 'bottom' + const align = options.align ?? 'start' + const offset = options.offset ?? 8 + const padding = Math.max(0, options.collisionPadding ?? 12) + const resolvedSide = resolveSide({ + preferredSide: side, + anchorRect: options.anchorRect, + surfaceHeight: options.surfaceSize.height, + viewportHeight: options.viewport.height, + offset, + padding, + }) + const rawTop = + resolvedSide === 'bottom' + ? options.anchorRect.bottom + offset + : options.anchorRect.top - offset - options.surfaceSize.height + const rawLeft = resolveAlignedLeft({ + anchorRect: options.anchorRect, + surfaceWidth: options.surfaceSize.width, + align, + }) + + return { + left: clampCoordinate(rawLeft, options.surfaceSize.width, options.viewport.width, padding), + top: clampCoordinate(rawTop, options.surfaceSize.height, options.viewport.height, padding), + side: resolvedSide, + } +} diff --git a/src/app/renderer/components/ui/classNames.ts b/src/app/renderer/components/ui/classNames.ts new file mode 100644 index 000000000..94205749c --- /dev/null +++ b/src/app/renderer/components/ui/classNames.ts @@ -0,0 +1,5 @@ +import { clsx, type ClassValue } from 'clsx' + +export function classNames(...values: ClassValue[]): string { + return clsx(values) +} diff --git a/src/app/renderer/components/ui/overlayLayerRegistry.ts b/src/app/renderer/components/ui/overlayLayerRegistry.ts new file mode 100644 index 000000000..ede1b1c61 --- /dev/null +++ b/src/app/renderer/components/ui/overlayLayerRegistry.ts @@ -0,0 +1,58 @@ +export type OverlayLayerId = number + +let nextLayerId = 0 +const activeLayers = new Set() +const branchesByLayer = new Map>() +const claimedEvents = new WeakSet() + +export function createOverlayLayerId(): OverlayLayerId { + nextLayerId += 1 + return nextLayerId +} + +export function registerOverlayLayer(layerId: OverlayLayerId): () => void { + activeLayers.add(layerId) + + return () => { + activeLayers.delete(layerId) + branchesByLayer.delete(layerId) + } +} + +export function isTopOverlayLayer(layerId: OverlayLayerId): boolean { + let topLayerId: OverlayLayerId | null = null + + for (const activeLayerId of activeLayers) { + if (topLayerId === null || activeLayerId > topLayerId) { + topLayerId = activeLayerId + } + } + + return topLayerId === layerId +} + +export function claimOverlayLayerEvent(layerId: OverlayLayerId, event: Event): boolean { + if (!isTopOverlayLayer(layerId) || claimedEvents.has(event)) { + return false + } + + claimedEvents.add(event) + return true +} + +export function registerOverlayBranch(layerId: OverlayLayerId, branch: HTMLElement): () => void { + const branches = branchesByLayer.get(layerId) ?? new Set() + branches.add(branch) + branchesByLayer.set(layerId, branches) + + return () => { + branches.delete(branch) + if (branches.size === 0) { + branchesByLayer.delete(layerId) + } + } +} + +export function getOverlayBranches(layerId: OverlayLayerId): readonly HTMLElement[] { + return Array.from(branchesByLayer.get(layerId) ?? []) +} diff --git a/src/app/renderer/i18n/locales/en.ts b/src/app/renderer/i18n/locales/en.ts index 94cb128c8..1eb81b500 100644 --- a/src/app/renderer/i18n/locales/en.ts +++ b/src/app/renderer/i18n/locales/en.ts @@ -98,10 +98,13 @@ export const en = { controlCenter: { open: 'Control Center', title: 'Control Center', + quickSettings: 'Quick settings', sidebar: 'Sidebar', minimap: 'Minimap', theme: 'Theme', agentStandbyBanner: 'Top-right banner', + shown: 'Shown', + hidden: 'Hidden', on: 'On', off: 'Off', }, diff --git a/src/app/renderer/i18n/locales/zh-CN.ts b/src/app/renderer/i18n/locales/zh-CN.ts index 4054ae574..c2782430f 100644 --- a/src/app/renderer/i18n/locales/zh-CN.ts +++ b/src/app/renderer/i18n/locales/zh-CN.ts @@ -100,10 +100,13 @@ export const zhCN = { controlCenter: { open: '控制中心', title: '控制中心', + quickSettings: '快捷设置', sidebar: '侧边栏', minimap: '缩略图', theme: '主题', agentStandbyBanner: '右上角横幅', + shown: '已显示', + hidden: '已隐藏', on: '已开启', off: '已关闭', }, diff --git a/src/app/renderer/shell/AppShell.tsx b/src/app/renderer/shell/AppShell.tsx index 96092f802..74d68c15d 100644 --- a/src/app/renderer/shell/AppShell.tsx +++ b/src/app/renderer/shell/AppShell.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from '@app/renderer/i18n' import { resolvePerformanceStatus } from '@app/renderer/performanceDiagnostics/performanceDiagnosticsFormatting' import { usePerformanceIncidentRecorder } from '@app/renderer/performanceDiagnostics/performanceIncidentRecorder' @@ -142,6 +142,7 @@ export default function App(): React.JSX.Element { const [isFocusNodeTargetZoomPreviewing, setIsFocusNodeTargetZoomPreviewing] = useState(false) const [settingsInitialPageId, setSettingsInitialPageId] = useState(null) + const controlCenterButtonRef = useRef(null) const { isCommandCenterOpen, isControlCenterOpen, @@ -329,6 +330,7 @@ export default function App(): React.JSX.Element { memoryTrend={memoryTrend} performanceIncidents={performanceIncidents} updateState={updateState} + controlCenterButtonRef={controlCenterButtonRef} onToggleControlCenter={toggleControlCenter} onToggleCommandCenter={toggleCommandCenter} onTogglePerformanceMonitor={togglePerformanceMonitor} @@ -409,8 +411,8 @@ export default function App(): React.JSX.Element { agentSettings={agentSettings} setAgentSettings={setAgentSettings} activeWorkspace={activeWorkspace} - isPrimarySidebarCollapsed={isPrimarySidebarCollapsed} isControlCenterOpen={isControlCenterOpen} + controlCenterAnchorRef={controlCenterButtonRef} onCloseControlCenter={closeControlCenter} onMinimapVisibilityChange={handleWorkspaceMinimapVisibilityChange} onOpenSettings={handleOpenSettings} @@ -460,11 +462,9 @@ export default function App(): React.JSX.Element { setProjectDeleteConfirmation(null) }} onConfirmProjectDelete={() => { - if (!projectDeleteConfirmation) { - return + if (projectDeleteConfirmation) { + void handleRemoveWorkspace(projectDeleteConfirmation.workspaceId) } - - void handleRemoveWorkspace(projectDeleteConfirmation.workspaceId) }} /> onToggleControlCenter: () => void onToggleCommandCenter: () => void onTogglePerformanceMonitor: () => void @@ -292,6 +294,7 @@ export function AppHeader({ ) : null} - - - - + {getUiThemeLabel(t, uiTheme)} + - -
-
{t('controlCenter.theme')}
-
- {themeOptions.map(option => { - const Icon = option.icon - const isSelected = uiTheme === option.value - return ( - - ) - })} -
+
+ {UI_THEMES.map(theme => ( + + ))}
+ - - -
+