style: add unified custom select dropdown

This commit is contained in:
azmiao authored and DiheChen committed 2026-11-02 11:04:20 +08:00
1 parent fbe8c516f7
commit d88e93cf91
10 files changed
+266 -38

No files matched your search

+10 -6
View File
@@ -2,7 +2,7 @@ import type { AdminUpdateRoomRequest } from '@/core/api'
import type { RoomListing } from '@/core/realtime'
import { useId, useState } from 'react'
import { api } from '@/core/api'
import { Modal } from '@/shared/ui'
import { Modal, Select } from '@/shared/ui'
import { adminStore } from './admin-store'
type PasswordChange = 'keep' | 'set' | 'remove'
@@ -71,11 +71,15 @@ export function RoomFormDialog({ room, onSaved, onClose }: { room: RoomListing |
{room && (
<label>
密码
<select value={passwordChange} onChange={event => setPasswordChange(event.target.value as PasswordChange)}>
<option value="keep">{room.hasPassword ? '保持现有密码' : '保持无密码'}</option>
<option value="set">{room.hasPassword ? '修改密码' : '设置密码'}</option>
{room.hasPassword && <option value="remove">取消密码</option>}
</select>
<Select
value={passwordChange}
onChange={value => setPasswordChange(value as PasswordChange)}
options={[
{ value: 'keep', label: room.hasPassword ? '保持现有密码' : '保持无密码' },
{ value: 'set', label: room.hasPassword ? '修改密码' : '设置密码' },
...(room.hasPassword ? [{ value: 'remove', label: '取消密码' }] : []),
]}
/>
</label>
)}
{passwordChange === 'set' && (
+13 -6
View File
@@ -4,7 +4,7 @@ import { Heart, ListMusic, Trash2 } from 'lucide-react'
import { roomStore } from '@/core/room'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, SongArtwork, SongList } from '@/shared/ui'
import { EmptyState, Select, SongArtwork, SongList } from '@/shared/ui'
import { likeEntry, removeEntry, setPlayMode } from './actions'
import { sourceName } from './source'
import './queue.css'
@@ -53,11 +53,18 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
播放列表
<span className="text-ui-sm muted">{items.length}</span>
</h1>
<select className="queue-mode" aria-label="播放顺序" value={playMode} disabled={!connected} onChange={event => void setPlayMode(event.target.value as PlayMode)}>
<option value="sequential">顺序播放</option>
<option value="rotation" title="按点歌人轮流播放">轮流播放</option>
<option value="random">随机播放</option>
</select>
<Select
className="queue-mode"
ariaLabel="播放顺序"
value={playMode}
disabled={!connected}
onChange={value => void setPlayMode(value as PlayMode)}
options={[
{ value: 'sequential', label: '顺序播放' },
{ value: 'rotation', label: '轮流播放' },
{ value: 'random', label: '随机播放' },
]}
/>
</div>
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
+2 -1
View File
@@ -1,6 +1,7 @@
.queue-panel { padding: 24px 16px; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; flex: 1; min-height: 0; scrollbar-width: none; }
.queue-panel::-webkit-scrollbar { display: none; }
.queue-mode { width: auto; flex-shrink: 0; padding: 6px 10px; }
.queue-mode { width: auto; flex-shrink: 0; }
.queue-mode .select-trigger { padding: 6px 10px; }
.queue-current { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; padding: 12px; background: var(--color-accent); border-radius: 12px; }
.queue-current .artwork { width: 48px; }
.queue-pending { background: var(--color-surface); }
+9 -8
View File
@@ -8,7 +8,7 @@ import { historyStore } from '@/features/history'
import { enqueue } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, EmptyState, IconButton, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { Artwork, AsyncIconButton, EmptyState, IconButton, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { SearchBox } from './search-box'
import { canOpenPlaylists, keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import './search.css'
@@ -290,16 +290,17 @@ export function SearchPage({ inlineSearchClassName, onSearch, onOpenPlaylist }:
</button>
</div>
{view !== 'recommend' && (
<select
<Select
className="search-source text-ui-caption"
aria-label="音乐平台"
ariaLabel="音乐平台"
value={source}
disabled={!connected}
onChange={event => searchAgain(keywordSearch(keyword, event.target.value === 'qq' ? 'qq' : 'netease', view))}
>
<option value="qq">QQ 音乐</option>
<option value="netease">网易云音乐</option>
</select>
onChange={value => searchAgain(keywordSearch(keyword, value === 'qq' ? 'qq' : 'netease', view))}
options={[
{ value: 'qq', label: 'QQ 音乐' },
{ value: 'netease', label: '网易云音乐' },
]}
/>
)}
</div>
<header className="search-page-heading">
+2 -1
View File
@@ -20,7 +20,8 @@
.search-chip { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 7px 14px; border: 0; border-radius: 999px; background: var(--color-surface); color: var(--color-foreground); white-space: nowrap; }
.search-chip:hover:not(:disabled) { background: var(--color-hover); }
.search-chip[aria-pressed='true'], .search-chip[aria-pressed='true']:hover:not(:disabled) { background: var(--color-foreground); color: var(--color-content); }
.search-source { width: auto; max-width: 144px; flex-shrink: 0; padding: 7px 12px; border-radius: 999px; background: transparent; }
.search-source { width: auto; max-width: 144px; flex-shrink: 0; }
.search-source .select-trigger { padding: 7px 12px; border-radius: 999px; background: transparent; }
.search-page-heading { margin: 16px 0 28px; }
.search-page-heading p { margin-top: 6px; overflow-wrap: anywhere; }
.search-recommend-hint { display: none; align-items: center; gap: 6px; }
+30 -13
View File
@@ -6,7 +6,7 @@ import { errorMessage } from '@/core/errors'
import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, VolumeInput } from '@/shared/ui'
import { notificationStore, Select, VolumeInput } from '@/shared/ui'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
@@ -193,14 +193,22 @@ export function SettingsPage() {
<h2 id="settings-appearance-title" className="text-ui-lg">外观</h2>
<div className="settings-group">
<SettingRow id="settings-theme" title="主题" description="选择界面的明暗外观。">
<select id="settings-theme" aria-describedby="settings-theme-description" value={draft.theme} onChange={event => update('theme', themes.find(value => value === event.target.value) ?? draft.theme)}>
{themes.map(value => <option key={value} value={value}>{themeNames[value]}</option>)}
</select>
<Select
id="settings-theme"
ariaDescribedBy="settings-theme-description"
value={draft.theme}
onChange={value => update('theme', themes.find(theme => theme === value) ?? draft.theme)}
options={themes.map(value => ({ value, label: themeNames[value] }))}
/>
</SettingRow>
<SettingRow id="settings-font-size" title="界面字号" description="调整文字大小,布局和间距保持不变。">
<select id="settings-font-size" aria-describedby="settings-font-size-description" value={draft.fontSize} onChange={event => update('fontSize', Number(event.target.value))}>
{fontSizes.map(size => <option key={size} value={size}>{`${size} px`}</option>)}
</select>
<Select
id="settings-font-size"
ariaDescribedBy="settings-font-size-description"
value={String(draft.fontSize)}
onChange={value => update('fontSize', Number(value))}
options={fontSizes.map(size => ({ value: String(size), label: `${size} px` }))}
/>
</SettingRow>
</div>
</section>
@@ -227,9 +235,13 @@ export function SettingsPage() {
<h2 id="settings-performance-title" className="text-ui-lg">性能</h2>
<div className="settings-group">
<SettingRow id="settings-performance" title="性能模式" description={levelNames[draft.level].description}>
<select id="settings-performance" aria-describedby="settings-performance-description" value={draft.level} onChange={event => update('level', performanceLevels.find(value => value === event.target.value) ?? draft.level)}>
{performanceLevels.map(value => <option key={value} value={value}>{levelNames[value].name}</option>)}
</select>
<Select
id="settings-performance"
ariaDescribedBy="settings-performance-description"
value={draft.level}
onChange={value => update('level', performanceLevels.find(level => level === value) ?? draft.level)}
options={performanceLevels.map(value => ({ value, label: levelNames[value].name }))}
/>
</SettingRow>
<SettingRow id="settings-reduced-motion" title="减少动态效果" description="关闭界面动画和歌词的平滑滚动。">
<input id="settings-reduced-motion" aria-describedby="settings-reduced-motion-description" className="settings-switch" type="checkbox" role="switch" checked={draft.reducedMotion} onChange={event => update('reducedMotion', event.target.checked)} />
@@ -243,9 +255,14 @@ export function SettingsPage() {
<input id="settings-search-history" aria-describedby="settings-search-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.searchesEnabled} onChange={event => update('searchesEnabled', event.target.checked)} />
</SettingRow>
<SettingRow id="settings-search-limit" title="搜索记录数量" description="最多保留的搜索记录条数。">
<select id="settings-search-limit" aria-describedby="settings-search-limit-description" value={draft.searchLimit} disabled={!draft.searchesEnabled} onChange={event => update('searchLimit', Number(event.target.value))}>
{searchLimits.map(limit => <option key={limit} value={limit}>{`${limit} 条`}</option>)}
</select>
<Select
id="settings-search-limit"
ariaDescribedBy="settings-search-limit-description"
value={String(draft.searchLimit)}
disabled={!draft.searchesEnabled}
onChange={value => update('searchLimit', Number(value))}
options={searchLimits.map(limit => ({ value: String(limit), label: `${limit} 条` }))}
/>
</SettingRow>
<SettingRow id="settings-play-history" title="记录播放历史" description="把房间播放过的歌曲保存在这台设备上。">
<input id="settings-play-history" aria-describedby="settings-play-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.playsEnabled} onChange={event => update('playsEnabled', event.target.checked)} />
+5 -3
View File
@@ -14,8 +14,9 @@
.settings-row-title { display: block; font-weight: 600; }
.settings-row-copy p { margin-top: 4px; overflow-wrap: anywhere; }
.settings-row-control { display: flex; justify-content: flex-end; align-items: center; min-width: 0; max-width: 320px; }
.settings-row-control select { width: auto; max-width: 100%; min-width: 128px; padding: 8px 12px; }
.settings-row-control select:disabled, .settings-row-control input:disabled { opacity: .5; cursor: not-allowed; }
.settings-row-control .select { width: auto; max-width: 100%; min-width: 128px; }
.settings-row-control .select-trigger { padding: 8px 12px; }
.settings-row-control .select-trigger:disabled, .settings-row-control input:disabled { opacity: .5; cursor: not-allowed; }
.settings-row-field .settings-row-control { width: 280px; }
.settings-volume-control { display: flex; align-items: center; gap: 16px; width: 100%; }
.settings-volume-control .volume-input-control { display: flex; align-items: center; gap: 4px; }
@@ -40,7 +41,8 @@
.settings-group { padding: 0 16px; }
.settings-row { gap: 16px; min-height: 80px; padding: 16px 0; }
.settings-row-control { max-width: 160px; }
.settings-row-control select { min-width: 100px; padding: 8px; }
.settings-row-control .select { min-width: 100px; }
.settings-row-control .select-trigger { padding: 8px; }
.settings-row-field { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.settings-row-field .settings-row-control { width: 100%; max-width: none; }
.settings-page-footer { gap: 12px; padding: 12px 20px 16px; }
+3
View File
@@ -1,3 +1,4 @@
import './select.css'
import './ui.css'
export { Artwork } from './artwork'
@@ -8,6 +9,8 @@ export { lazyPage } from './lazy-page'
export { Modal } from './modal'
export { notificationStore } from './notification-store'
export { Notifications } from './notifications'
export { Select } from './select'
export type { SelectOption } from './select'
export { Skeleton } from './skeleton'
export { SongArtwork } from './song-artwork'
export { SongList } from './song-list'
+24
View File
@@ -0,0 +1,24 @@
.select { position: relative; min-width: 0; }
.select-trigger { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 40px; padding: 9px 12px; border: 1px solid var(--color-input-border); border-radius: 8px; background: var(--color-input); color: var(--color-foreground); text-align: left; }
.select-trigger:hover:not(:disabled) { border-color: var(--color-border-hover); background: var(--color-input-focused); }
.select-trigger[aria-expanded='true'], .select-trigger[aria-expanded='true']:hover:not(:disabled) { border-color: var(--color-input-border-focused); background: var(--color-input-focused); }
.select-trigger:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
.select-trigger > svg { flex-shrink: 0; color: var(--color-foreground-subtle); transition: transform 150ms ease; }
.select-trigger[aria-expanded='true'] > svg { transform: rotate(180deg); }
.select-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select-menu { position: absolute; top: calc(100% + 8px); left: 0; z-index: 40; display: flex; flex-direction: column; gap: 2px; min-width: 100%; width: max-content; max-width: min(320px, calc(100vw - 32px)); max-height: min(320px, calc(100dvh - 32px)); padding: 6px; overflow-y: auto; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); box-shadow: 0 12px 32px var(--color-shadow); transform-origin: top center; animation: popover-in 200ms cubic-bezier(.2, .8, .2, 1) both; }
.select-option { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; min-width: 0; padding: 9px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; white-space: nowrap; }
.select-option:hover, .select-option[data-active='true'] { background: var(--color-hover); }
.select-option[aria-selected='true'] { color: var(--color-brand); }
.select-option > span { overflow: hidden; text-overflow: ellipsis; }
.select-option > svg { flex-shrink: 0; }
.select-trigger:disabled { cursor: not-allowed; }
:root[data-performance='low'] .select-trigger > svg { transition: none; }
:root[data-performance='low'] .select-menu { animation: fade-in 160ms ease both; }
@media (prefers-reduced-motion: reduce) {
.select-trigger > svg { transition: none; }
.select-menu { animation: none; }
}
@media (max-width: 767px) {
.select-menu { max-width: calc(100vw - 32px); }
}
+168
View File
@@ -0,0 +1,168 @@
import type { KeyboardEvent } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
export interface SelectOption {
value: string
label: string
}
interface SelectProps {
id?: string
value: string
options: readonly SelectOption[]
onChange: (value: string) => void
ariaLabel?: string
ariaDescribedBy?: string
disabled?: boolean
className?: string
}
export function Select({ id, value, options, onChange, ariaLabel, ariaDescribedBy, disabled = false, className = '' }: SelectProps) {
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
const generatedId = useId()
const listboxId = `${id ?? generatedId}-options`
const selectedIndex = Math.max(0, options.findIndex(option => option.value === value))
const selectedOption = options[selectedIndex]
useEffect(() => {
if (open)
listboxRef.current?.focus({ preventScroll: true })
}, [open])
useEffect(() => {
if (!open)
return
const onPointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node))
setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('pointerdown', onPointerDown)
}, [open])
const close = () => {
setOpen(false)
triggerRef.current?.focus()
}
const choose = (index: number) => {
const option = options[index]
if (!option)
return
onChange(option.value)
close()
}
const move = (direction: 1 | -1) => {
if (!options.length)
return
setActiveIndex(index => (index + direction + options.length) % options.length)
}
const openMenu = (index = selectedIndex) => {
setActiveIndex(index)
setOpen(true)
}
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (disabled)
return
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openMenu()
}
else if (event.key === 'ArrowUp') {
event.preventDefault()
openMenu(Math.max(0, options.length - 1))
}
}
const onListboxKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
move(1)
break
case 'ArrowUp':
event.preventDefault()
move(-1)
break
case 'Home':
event.preventDefault()
setActiveIndex(0)
break
case 'End':
event.preventDefault()
setActiveIndex(Math.max(0, options.length - 1))
break
case 'Enter':
case ' ':
event.preventDefault()
choose(activeIndex)
break
case 'Escape':
event.preventDefault()
close()
break
case 'Tab':
setOpen(false)
break
}
}
return (
<div ref={rootRef} className={`select${className ? ` ${className}` : ''}`}>
<button
ref={triggerRef}
id={id}
type="button"
className="select-trigger"
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
disabled={disabled}
onClick={() => open ? close() : openMenu()}
onKeyDown={onTriggerKeyDown}
>
<span className="select-value">{selectedOption?.label ?? value}</span>
<ChevronDown size={16} aria-hidden="true" />
</button>
{open && (
<div
ref={listboxRef}
id={listboxId}
className="select-menu"
role="listbox"
tabIndex={-1}
aria-label={ariaLabel}
aria-activedescendant={`${listboxId}-option-${activeIndex}`}
onKeyDown={onListboxKeyDown}
>
{options.map((option, index) => (
<button
type="button"
id={`${listboxId}-option-${index}`}
className="select-option"
role="option"
aria-selected={option.value === value}
data-active={index === activeIndex}
key={option.value}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(index)}
>
<span>{option.label}</span>
{option.value === value && <Check size={16} aria-hidden="true" />}
</button>
))}
</div>
)}
</div>
)
}