feat(ui): 替换原生下拉控件

This commit is contained in:
2026-05-12 00:39:44 +08:00
parent 84e3f02752
commit 8f953cd6a1
2 changed files with 82 additions and 26 deletions
+61 -6
View File
@@ -1,3 +1,5 @@
import * as RadixSelect from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
type Variant = 'default' | 'muted' | 'success' | 'warning' | 'danger' | 'info'
@@ -72,17 +74,70 @@ export function Input({ className, ...props }: ComponentPropsWithoutRef<'input'>
)
}
export function Select({ className, children, ...props }: ComponentPropsWithoutRef<'select'>) {
export function Select({
value,
onValueChange,
children,
className,
id,
}: {
value?: string | number
onValueChange?: (value: string) => void
children: ReactNode
className?: string
id?: string
}) {
return (
<select
<RadixSelect.Root value={value?.toString()} onValueChange={onValueChange}>
<RadixSelect.Trigger
id={id}
className={cn(
'flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-white/10 bg-zinc-950/95 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors focus:border-teal-400/60 focus:ring-2 focus:ring-teal-400/10 data-[placeholder]:text-zinc-500',
className,
)}
>
<RadixSelect.Value />
<RadixSelect.Icon asChild>
<ChevronDown className="size-4 opacity-50" />
</RadixSelect.Icon>
</RadixSelect.Trigger>
<RadixSelect.Portal>
<RadixSelect.Content
className="relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-zinc-950 text-zinc-100 shadow-xl shadow-black/40"
position="popper"
sideOffset={4}
>
<RadixSelect.Viewport className="p-1">{children}</RadixSelect.Viewport>
</RadixSelect.Content>
</RadixSelect.Portal>
</RadixSelect.Root>
)
}
export function SelectOption({
value,
children,
className,
}: {
value: string | number
children: ReactNode
className?: string
}) {
return (
<RadixSelect.Item
value={value.toString()}
className={cn(
'h-10 rounded-lg border border-white/10 bg-zinc-950/95 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors [color-scheme:dark] focus:border-teal-400/60 focus:ring-2 focus:ring-teal-400/10',
'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-white/10 focus:text-zinc-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
{children}
</select>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>
<Check className="size-4" />
</RadixSelect.ItemIndicator>
</span>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
</RadixSelect.Item>
)
}
+21 -20
View File
@@ -6,13 +6,14 @@ import { useEffect, useMemo, useState } from 'react'
import { z } from 'zod'
import { orpc } from '@/client/orpc'
import { MotionCardDiv, MotionHeader, MotionSection, MotionTableRow } from '@/components/motion'
import { Badge, Button, Card, Input, SectionTitle, Select } from '@/components/ui'
import { Badge, Button, Card, Input, SectionTitle, Select, SelectOption } from '@/components/ui'
import type { BatteryInfo, BatteryListSort, PowerStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, BATTERY_LIST_SORT_VALUES, POWER_STATUS, POWER_STATUS_VALUES } from '@/domain/battery'
const pageSizeOptions = [20, 50, 100] as const
type PageSizeOption = (typeof pageSizeOptions)[number]
const firstPageCursor = '__FIRST_PAGE__'
const allPowerStatusValue = 'all'
const searchFilterSchema = z.preprocess(
(value) => (typeof value === 'string' ? value.trim() || undefined : value),
@@ -78,7 +79,7 @@ function parseSort(value: string): BatteryListSort {
}
function parsePowerStatus(value: string): PowerStatus | undefined {
if (value === '') return undefined
if (value === allPowerStatusValue) return undefined
const parsed = Number(value)
@@ -326,22 +327,22 @@ function BatteriesPage() {
</label>
<Select
id="power-status-select"
value={search.powerStatus ?? ''}
onChange={(e) => {
value={search.powerStatus ?? allPowerStatusValue}
onValueChange={(value) => {
navigate({
search: (prev) => ({
...prev,
powerStatus: parsePowerStatus(e.target.value),
powerStatus: parsePowerStatus(value),
cursor: undefined,
cursors: [],
}),
})
}}
>
<option value=""></option>
<option value={POWER_STATUS.NOT_CHARGING}></option>
<option value={POWER_STATUS.CHARGING}></option>
<option value={POWER_STATUS.FULL}></option>
<SelectOption value={allPowerStatusValue}></SelectOption>
<SelectOption value={POWER_STATUS.NOT_CHARGING}></SelectOption>
<SelectOption value={POWER_STATUS.CHARGING}></SelectOption>
<SelectOption value={POWER_STATUS.FULL}></SelectOption>
</Select>
</div>
@@ -352,21 +353,21 @@ function BatteriesPage() {
<Select
id="sort-select"
value={search.sort}
onChange={(e) => {
onValueChange={(value) => {
navigate({
search: (prev) => ({
...prev,
sort: parseSort(e.target.value),
sort: parseSort(value),
cursor: undefined,
cursors: [],
}),
})
}}
>
<option value={BATTERY_LIST_SORT.CREATED_AT_DESC}></option>
<option value={BATTERY_LIST_SORT.CREATED_AT_ASC}></option>
<option value={BATTERY_LIST_SORT.POWER_DESC}></option>
<option value={BATTERY_LIST_SORT.POWER_ASC}></option>
<SelectOption value={BATTERY_LIST_SORT.CREATED_AT_DESC}></SelectOption>
<SelectOption value={BATTERY_LIST_SORT.CREATED_AT_ASC}></SelectOption>
<SelectOption value={BATTERY_LIST_SORT.POWER_DESC}></SelectOption>
<SelectOption value={BATTERY_LIST_SORT.POWER_ASC}></SelectOption>
</Select>
</div>
@@ -377,20 +378,20 @@ function BatteriesPage() {
<Select
id="page-size-select"
value={search.pageSize}
onChange={(e) => {
onValueChange={(value) => {
navigate({
search: (prev) => ({
...prev,
pageSize: parsePageSize(e.target.value),
pageSize: parsePageSize(value),
cursor: undefined,
cursors: [],
}),
})
}}
>
<option value="20">20 /</option>
<option value="50">50 /</option>
<option value="100">100 /</option>
<SelectOption value="20">20 /</SelectOption>
<SelectOption value="50">50 /</SelectOption>
<SelectOption value="100">100 /</SelectOption>
</Select>
</div>