feat(ui): 自绘 Dropdown 替代原生 select(外观设置·临时主题)
全应用唯一的原生 select 换为自绘下拉组件:按钮对齐 .input 样式, 展开层沿用应用菜单语言(选中 --accent-weak、悬停 --hover),复用 useDismiss 点别处/Esc 关闭,支持上下键选行。tests/dropdown 1 例。
This commit is contained in:
73
src/components/Dropdown.tsx
Normal file
73
src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||||
|
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||||
|
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
|
|
||||||
|
export interface DropdownOption { value: string; label: string }
|
||||||
|
|
||||||
|
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||||
|
value: string
|
||||||
|
options: DropdownOption[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
useDismiss(ref, open, () => setOpen(false))
|
||||||
|
|
||||||
|
const current = options.find((o) => o.value === value) ?? options[0]
|
||||||
|
|
||||||
|
// 打开时把焦点放到选中项,便于直接上下键选行
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const idx = Math.max(0, options.findIndex((o) => o.value === value))
|
||||||
|
menuRef.current?.querySelectorAll('button')[idx]?.focus()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open ])
|
||||||
|
|
||||||
|
const pick = (v: string) => { setOpen(false); onChange(v) }
|
||||||
|
|
||||||
|
const onMenuKey = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
||||||
|
e.preventDefault()
|
||||||
|
const buttons = [...(menuRef.current?.querySelectorAll('button') ?? [])]
|
||||||
|
const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
const next = e.key === 'ArrowDown'
|
||||||
|
? Math.min(buttons.length - 1, idx + 1)
|
||||||
|
: Math.max(0, idx - 1)
|
||||||
|
buttons[next]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dropdown" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dropdown-button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<span className="dropdown-label">{current?.label ?? ''}</span>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
className={o.value === value ? 'selected' : undefined}
|
||||||
|
onClick={() => pick(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { DOC_THEMES } from '../themes'
|
import { DOC_THEMES } from '../themes'
|
||||||
|
import { Dropdown } from './Dropdown'
|
||||||
|
|
||||||
/** 外观与设置(阶段 5):文档主题、当前文档临时主题、自定义 CSS、自动保存间隔。 */
|
/** 外观与设置(阶段 5):文档主题、当前文档临时主题、自定义 CSS、自动保存间隔。 */
|
||||||
export function AppearanceModal({ docTheme, override, customCss, autosave, autosaveSeconds, hasTab, onDocTheme, onOverride, onCustomCss, onAutosave, onClose }: {
|
export function AppearanceModal({ docTheme, override, customCss, autosave, autosaveSeconds, hasTab, onDocTheme, onOverride, onCustomCss, onAutosave, onClose }: {
|
||||||
@@ -38,18 +39,18 @@ export function AppearanceModal({ docTheme, override, customCss, autosave, autos
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<label className="modal-field">
|
<div className="modal-field">
|
||||||
<span>当前文档临时主题{hasTab ? '' : '(未打开文档)'}</span>
|
<span>当前文档临时主题{hasTab ? '' : '(未打开文档)'}</span>
|
||||||
<select
|
<Dropdown
|
||||||
className="input"
|
|
||||||
disabled={!hasTab}
|
disabled={!hasTab}
|
||||||
value={override ?? ''}
|
value={override ?? ''}
|
||||||
onChange={(e) => onOverride(e.target.value === '' ? null : e.target.value)}
|
options={[
|
||||||
>
|
{ value: '', label: '跟随全局主题' },
|
||||||
<option value="">跟随全局主题</option>
|
...DOC_THEMES.map((t) => ({ value: t.id, label: t.name })),
|
||||||
{DOC_THEMES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
]}
|
||||||
</select>
|
onChange={(v) => onOverride(v === '' ? null : v)}
|
||||||
</label>
|
/>
|
||||||
|
</div>
|
||||||
<label className="modal-field">
|
<label className="modal-field">
|
||||||
<span>自定义 CSS(即时生效;建议以 .milkdown 开头限定作用范围)</span>
|
<span>自定义 CSS(即时生效;建议以 .milkdown 开头限定作用范围)</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -304,7 +304,18 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
|||||||
.modal-actions.left button { text-align: left; }
|
.modal-actions.left button { text-align: left; }
|
||||||
.ws-search { display: flex; flex-direction: column; gap: 8px; padding: 10px; min-height: 0; }
|
.ws-search { display: flex; flex-direction: column; gap: 8px; padding: 10px; min-height: 0; }
|
||||||
.ws-search-box { display: flex; gap: 6px; }
|
.ws-search-box { display: flex; gap: 6px; }
|
||||||
.ws-search-box .input { flex: 1; min-width: 0; }
|
.ws-search-box /* 自绘下拉(Dropdown 组件):按钮对齐 .input,展开层沿用应用菜单语言 */
|
||||||
|
.dropdown { position: relative; }
|
||||||
|
.dropdown-button { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: 6px; padding: 5px 9px; font-size: 13px; font-family: inherit; cursor: pointer; text-align: left; }
|
||||||
|
.dropdown-button:hover:not(:disabled) { border-color: var(--accent); }
|
||||||
|
.dropdown-button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
||||||
|
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; }
|
||||||
|
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||||
|
.dropdown-menu button.selected { background: var(--accent-weak); }
|
||||||
|
.input { flex: 1; min-width: 0; }
|
||||||
.ws-search-case { font-size: 12px; color: var(--text-dim); display: flex; gap: 5px; align-items: center; }
|
.ws-search-case { font-size: 12px; color: var(--text-dim); display: flex; gap: 5px; align-items: center; }
|
||||||
.ws-search-summary { font-size: 12px; color: var(--text-dim); }
|
.ws-search-summary { font-size: 12px; color: var(--text-dim); }
|
||||||
.ws-search-results { overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
.ws-search-results { overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|||||||
40
tests/dropdown.test.tsx
Normal file
40
tests/dropdown.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 自绘下拉回归:展开/选中/点别处关闭(替代原生 select)。
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { act } from 'react-dom/test-utils'
|
||||||
|
import { Dropdown } from '../src/components/Dropdown'
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ value: '', label: '跟随全局主题' },
|
||||||
|
{ value: 'github', label: 'GitHub' },
|
||||||
|
{ value: 'notion', label: 'Notion' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('Dropdown', () => {
|
||||||
|
it('展开列出选项且标出选中项;点击选项回调并关闭;点别处关闭', async () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const root = createRoot(container)
|
||||||
|
const onChange = vi.fn()
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Dropdown value="github" options={OPTIONS} onChange={onChange} />)
|
||||||
|
})
|
||||||
|
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||||
|
expect(button.textContent).toContain('GitHub')
|
||||||
|
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
const menu = container.querySelector('.dropdown-menu')
|
||||||
|
expect(menu).not.toBeNull()
|
||||||
|
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
||||||
|
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
||||||
|
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
expect(onChange).toHaveBeenCalledWith('notion')
|
||||||
|
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||||
|
// 再次展开,点别处关闭
|
||||||
|
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
expect(container.querySelector('.dropdown-menu')).not.toBeNull()
|
||||||
|
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||||
|
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||||
|
await act(async () => { root.unmount() })
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user