style: add unified custom select dropdown
This commit is contained in:
1 parent
fbe8c516f7
commit
d88e93cf91
10 files changed
+266
-38
No files matched your search
@@ -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' && (
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user