From d88e93cf91d679e0de869e3b9a09e14ef78fb493 Mon Sep 17 00:00:00 2001 From: azmiao <2362020227@qq.com> Date: Mon, 2 Nov 2026 10:23:00 +0800 Subject: [PATCH] style: add unified custom select dropdown --- web/src/features/admin/room-form-dialog.tsx | 16 +- web/src/features/queue/queue-page.tsx | 19 ++- web/src/features/queue/queue.css | 3 +- web/src/features/search/search-page.tsx | 17 +- web/src/features/search/search.css | 3 +- web/src/features/settings/settings-page.tsx | 43 +++-- web/src/features/settings/settings.css | 8 +- web/src/shared/ui/index.ts | 3 + web/src/shared/ui/select.css | 24 +++ web/src/shared/ui/select.tsx | 168 ++++++++++++++++++++ 10 files changed, 266 insertions(+), 38 deletions(-) create mode 100644 web/src/shared/ui/select.css create mode 100644 web/src/shared/ui/select.tsx diff --git a/web/src/features/admin/room-form-dialog.tsx b/web/src/features/admin/room-form-dialog.tsx index 1dd2ff6..c3c48d2 100644 --- a/web/src/features/admin/room-form-dialog.tsx +++ b/web/src/features/admin/room-form-dialog.tsx @@ -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 && ( 密码 - setPasswordChange(event.target.value as PasswordChange)}> - {room.hasPassword ? '保持现有密码' : '保持无密码'} - {room.hasPassword ? '修改密码' : '设置密码'} - {room.hasPassword && 取消密码} - + setPasswordChange(value as PasswordChange)} + options={[ + { value: 'keep', label: room.hasPassword ? '保持现有密码' : '保持无密码' }, + { value: 'set', label: room.hasPassword ? '修改密码' : '设置密码' }, + ...(room.hasPassword ? [{ value: 'remove', label: '取消密码' }] : []), + ]} + /> )} {passwordChange === 'set' && ( diff --git a/web/src/features/queue/queue-page.tsx b/web/src/features/queue/queue-page.tsx index e3994d4..fcbbd7e 100644 --- a/web/src/features/queue/queue-page.tsx +++ b/web/src/features/queue/queue-page.tsx @@ -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 }) { 播放列表 {items.length} - void setPlayMode(event.target.value as PlayMode)}> - 顺序播放 - 轮流播放 - 随机播放 - + void setPlayMode(value as PlayMode)} + options={[ + { value: 'sequential', label: '顺序播放' }, + { value: 'rotation', label: '轮流播放' }, + { value: 'random', label: '随机播放' }, + ]} + /> {nowPlaying && } {pending && } diff --git a/web/src/features/queue/queue.css b/web/src/features/queue/queue.css index e5ca61b..92fcb17 100644 --- a/web/src/features/queue/queue.css +++ b/web/src/features/queue/queue.css @@ -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); } diff --git a/web/src/features/search/search-page.tsx b/web/src/features/search/search-page.tsx index e4824c2..b72c859 100644 --- a/web/src/features/search/search-page.tsx +++ b/web/src/features/search/search-page.tsx @@ -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 }: {view !== 'recommend' && ( - searchAgain(keywordSearch(keyword, event.target.value === 'qq' ? 'qq' : 'netease', view))} - > - QQ 音乐 - 网易云音乐 - + onChange={value => searchAgain(keywordSearch(keyword, value === 'qq' ? 'qq' : 'netease', view))} + options={[ + { value: 'qq', label: 'QQ 音乐' }, + { value: 'netease', label: '网易云音乐' }, + ]} + /> )} diff --git a/web/src/features/search/search.css b/web/src/features/search/search.css index ab7903c..e0e15aa 100644 --- a/web/src/features/search/search.css +++ b/web/src/features/search/search.css @@ -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; } diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index 5b5ee63..14b0095 100644 --- a/web/src/features/settings/settings-page.tsx +++ b/web/src/features/settings/settings-page.tsx @@ -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() { 外观 - update('theme', themes.find(value => value === event.target.value) ?? draft.theme)}> - {themes.map(value => {themeNames[value]})} - + update('theme', themes.find(theme => theme === value) ?? draft.theme)} + options={themes.map(value => ({ value, label: themeNames[value] }))} + /> - update('fontSize', Number(event.target.value))}> - {fontSizes.map(size => {`${size} px`})} - + update('fontSize', Number(value))} + options={fontSizes.map(size => ({ value: String(size), label: `${size} px` }))} + /> @@ -227,9 +235,13 @@ export function SettingsPage() { 性能 - update('level', performanceLevels.find(value => value === event.target.value) ?? draft.level)}> - {performanceLevels.map(value => {levelNames[value].name})} - + update('level', performanceLevels.find(level => level === value) ?? draft.level)} + options={performanceLevels.map(value => ({ value, label: levelNames[value].name }))} + /> update('reducedMotion', event.target.checked)} /> @@ -243,9 +255,14 @@ export function SettingsPage() { update('searchesEnabled', event.target.checked)} /> - update('searchLimit', Number(event.target.value))}> - {searchLimits.map(limit => {`${limit} 条`})} - + update('searchLimit', Number(value))} + options={searchLimits.map(limit => ({ value: String(limit), label: `${limit} 条` }))} + /> update('playsEnabled', event.target.checked)} /> diff --git a/web/src/features/settings/settings.css b/web/src/features/settings/settings.css index 8ec73bc..d12db87 100644 --- a/web/src/features/settings/settings.css +++ b/web/src/features/settings/settings.css @@ -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; } diff --git a/web/src/shared/ui/index.ts b/web/src/shared/ui/index.ts index f0bee99..f6f43a7 100644 --- a/web/src/shared/ui/index.ts +++ b/web/src/shared/ui/index.ts @@ -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' diff --git a/web/src/shared/ui/select.css b/web/src/shared/ui/select.css new file mode 100644 index 0000000..4f293a7 --- /dev/null +++ b/web/src/shared/ui/select.css @@ -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); } +} diff --git a/web/src/shared/ui/select.tsx b/web/src/shared/ui/select.tsx new file mode 100644 index 0000000..4053300 --- /dev/null +++ b/web/src/shared/ui/select.tsx @@ -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(null) + const triggerRef = useRef(null) + const listboxRef = useRef(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) => { + 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) => { + 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 ( + + open ? close() : openMenu()} + onKeyDown={onTriggerKeyDown} + > + {selectedOption?.label ?? value} + + + {open && ( + + {options.map((option, index) => ( + setActiveIndex(index)} + onClick={() => choose(index)} + > + {option.label} + {option.value === value && } + + ))} + + )} + + ) +}