From d0c67bbf7d527e4e011cecb77e12df1650b9f433 Mon Sep 17 00:00:00 2001 From: Xavier-Trump <2933243959@qq.com> Date: Thu, 28 May 2026 16:55:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared):=20=E6=96=B0=E5=A2=9E=E7=8E=BB?= =?UTF-8?q?=E7=92=83=E6=80=81=20Select=20=E7=BB=84=E4=BB=B6=E2=80=94?= =?UTF-8?q?=E2=80=94portal=20=E4=B8=8B=E6=8B=89=E3=80=81=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E3=80=81OKLCH=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 PR #222 提取并修复: - 所有 rgba() 替换为 OKLCH 设计 tokens(--border/--card/--popover/--ring) - 修复 .chevon → .chevron CSS 拼写 - 修复 z-index 使用 --z-overlay(30) 替代 --z-dropdown(10) - 新增 Select.test.tsx(6 测试:渲染、键盘导航、onChange) - 添加到 shared/ui 导出 Co-Authored-By: Xavier-Trump <2933243959@qq.com> --- app/shared/src/ui/Select.module.css | 199 ++++++++++++++++++++++++++++ app/shared/src/ui/Select.test.tsx | 53 ++++++++ app/shared/src/ui/Select.tsx | 167 +++++++++++++++++++++++ app/shared/src/ui/index.ts | 2 + 4 files changed, 421 insertions(+) create mode 100644 app/shared/src/ui/Select.module.css create mode 100644 app/shared/src/ui/Select.test.tsx create mode 100644 app/shared/src/ui/Select.tsx diff --git a/app/shared/src/ui/Select.module.css b/app/shared/src/ui/Select.module.css new file mode 100644 index 000000000..e0e99de69 --- /dev/null +++ b/app/shared/src/ui/Select.module.css @@ -0,0 +1,199 @@ +.container { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +/* ── Trigger ──────────────────────────────── */ +.trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 158px; + width: 100%; + height: 34px; + padding: 0 10px 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md, 6px); + background: var(--card, oklch(1 0 0 / 0.48)); + color: var(--foreground); + font: inherit; + font-size: 13px; + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + -webkit-font-smoothing: antialiased; +} + +.trigger:hover { + background: var(--card-hover, oklch(1 0 0 / 0.62)); + border-color: var(--border-hover, oklch(0 0 0 / 0.13)); +} + +.trigger:focus-visible { + outline: none; + border-color: var(--ring, oklch(0.35 0.02 260 / 0.5)); + box-shadow: 0 0 0 3px var(--ring, oklch(0.35 0.02 260 / 0.15)); +} + +.trigger:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.label { + overflow: hidden; + text-overflow: ellipsis; +} + +.placeholder { + color: var(--muted-foreground); + font-weight: 450; + overflow: hidden; + text-overflow: ellipsis; +} + +.chevron { + opacity: 0.4; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* ── Dropdown panel ────────────────────────── */ +.dropdown { + position: fixed; + z-index: var(--z-overlay, 30); + min-width: 158px; + max-height: 260px; + overflow-y: auto; + background: var(--popover, oklch(0.96 0.01 260 / 0.92)); + backdrop-filter: blur(24px) saturate(1.08); + -webkit-backdrop-filter: blur(24px) saturate(1.08); + border: 1px solid var(--border); + border-radius: var(--radius-xl, 12px); + box-shadow: 0 18px 44px -12px oklch(0 0 0 / 0.2), 0 4px 14px -8px oklch(0 0 0 / 0.14); + padding: 6px; + animation: dropdownIn 0.12s ease-out; + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.dropdown::-webkit-scrollbar { + width: 4px; +} + +.dropdown::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.dropdownUp { + animation: dropdownInUp 0.12s ease-out; +} + +@keyframes dropdownIn { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes dropdownInUp { + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* ── Options ───────────────────────────────── */ +.option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: var(--radius-md, 6px); + background: transparent; + cursor: pointer; + text-align: left; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--foreground); + transition: background 0.12s ease; +} + +.option:hover, +.optionFocused { + background: var(--hover-overlay, oklch(0 0 0 / 0.04)); +} + +.optionSelected { + font-weight: var(--font-weight-semibold, 600); +} + +.optionLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.check { + flex-shrink: 0; + color: var(--foreground); + opacity: 0.6; +} + +/* ── Dark mode ─────────────────────────────── */ +[data-theme='dark'] .trigger { + border-color: var(--border); + background: var(--card, oklch(1 0 0 / 0.055)); +} + +[data-theme='dark'] .trigger:hover { + background: var(--card-hover, oklch(1 0 0 / 0.075)); + border-color: var(--border-hover, oklch(1 0 0 / 0.14)); +} + +[data-theme='dark'] .trigger:focus-visible { + border-color: var(--ring, oklch(1 0 0 / 0.16)); + box-shadow: 0 0 0 3px var(--ring, oklch(1 0 0 / 0.045)); +} + +[data-theme='dark'] .dropdown { + background: var(--popover, oklch(0.18 0.01 260 / 0.94)); + border-color: var(--border); + box-shadow: 0 18px 48px -14px oklch(0 0 0 / 0.58), 0 4px 18px -10px oklch(0 0 0 / 0.42); +} + +[data-theme='dark'] .dropdown::-webkit-scrollbar-thumb { + background: var(--border); +} + +[data-theme='dark'] .option:hover, +[data-theme='dark'] .optionFocused { + background: var(--hover-overlay, oklch(1 0 0 / 0.05)); +} + +[data-theme='dark'] .chevron { + opacity: 0.5; +} diff --git a/app/shared/src/ui/Select.test.tsx b/app/shared/src/ui/Select.test.tsx new file mode 100644 index 000000000..ad1db73a5 --- /dev/null +++ b/app/shared/src/ui/Select.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { Select } from './Select'; + +const options: Array<[string, string]> = [ + ['a', 'Option A'], + ['b', 'Option B'], + ['c', 'Option C'], +]; + +describe('Select', () => { + it('renders placeholder when no value', () => { + render(); + expect(screen.getByText('Option B')).toBeInTheDocument(); + }); + + it('opens dropdown on trigger click', () => { + render(); + fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByText('Option B')); + expect(onChange).toHaveBeenCalledWith('b'); + }); + + it('closes on Escape', () => { + render(); + fireEvent.click(screen.getByRole('button')); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' }); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith('y'); + }); +}); diff --git a/app/shared/src/ui/Select.tsx b/app/shared/src/ui/Select.tsx new file mode 100644 index 000000000..0b3aabc6f --- /dev/null +++ b/app/shared/src/ui/Select.tsx @@ -0,0 +1,167 @@ +import { useState, useRef, useCallback, useEffect, type KeyboardEvent } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './Select.module.css'; + +export interface SelectProps { + value: string; + options: Array<[string, string]>; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export function Select({ value, options, onChange, placeholder, className }: SelectProps) { + const [open, setOpen] = useState(false); + const [focusIdx, setFocusIdx] = useState(0); + const [position, setPosition] = useState<'down' | 'up'>('down'); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + const mountedRef = useRef(false); + + const selectedLabel = options.find(([v]) => v === value)?.[1] ?? placeholder ?? ''; + + const close = useCallback(() => { + setOpen(false); + setFocusIdx(0); + }, []); + + // Click outside + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if ( + triggerRef.current?.contains(e.target as Node) || + dropdownRef.current?.contains(e.target as Node) + ) + return; + close(); + }; + document.addEventListener('mousedown', handler, true); + return () => document.removeEventListener('mousedown', handler, true); + }, [open, close]); + + // Position: flip up if near bottom + useEffect(() => { + if (!open || !triggerRef.current) return; + const anchorRect = triggerRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - anchorRect.bottom; + // Estimate panel height ~ 40px * min(6, options.length) + 16px padding + const approxPanelH = Math.min(6, options.length) * 40 + 16; + setPosition(spaceBelow < approxPanelH + 8 ? 'up' : 'down'); + }, [open, options.length]); + + // Focus selected on open + useEffect(() => { + if (!open) return; + const idx = options.findIndex(([v]) => v === value); + setFocusIdx(idx >= 0 ? idx : 0); + }, [open, value, options]); + + // Restore focus on close + useEffect(() => { + if (!open && mountedRef.current) { + triggerRef.current?.focus(); + } + mountedRef.current = true; + }, [open]); + + const handleKey = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusIdx((i) => (i + 1) % options.length); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusIdx((i) => (i - 1 + options.length) % options.length); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (open && options[focusIdx]) { + onChange(options[focusIdx][0]); + close(); + } else { + setOpen(true); + } + break; + case 'Escape': + e.preventDefault(); + close(); + break; + } + }; + + const triggerClass = [styles.trigger, className].filter(Boolean).join(' '); + + return ( + + + + {open && + createPortal( +
+ {options.map(([optValue, label], idx) => ( + + ))} +
, + document.body, + )} +
+ ); +} diff --git a/app/shared/src/ui/index.ts b/app/shared/src/ui/index.ts index 41981c986..24aa37b27 100644 --- a/app/shared/src/ui/index.ts +++ b/app/shared/src/ui/index.ts @@ -9,3 +9,5 @@ export { SearchInput } from './SearchInput'; export { CollapsibleBlock } from './CollapsibleBlock'; export { TextShimmer } from './TextShimmer'; export { Tooltip } from './Tooltip'; +export { Select } from './Select'; +export type { SelectProps } from './Select';