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' import type { ComponentPropsWithoutRef, ReactNode } from 'react'
type Variant = 'default' | 'muted' | 'success' | 'warning' | 'danger' | 'info' 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 ( return (
<select <RadixSelect.Root value={value?.toString()} onValueChange={onValueChange}>
<RadixSelect.Trigger
id={id}
className={cn( 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', '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, className,
)} )}
{...props}
> >
{children} <RadixSelect.Value />
</select> <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(
'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,
)}
>
<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 { z } from 'zod'
import { orpc } from '@/client/orpc' import { orpc } from '@/client/orpc'
import { MotionCardDiv, MotionHeader, MotionSection, MotionTableRow } from '@/components/motion' 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 type { BatteryInfo, BatteryListSort, PowerStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, BATTERY_LIST_SORT_VALUES, POWER_STATUS, POWER_STATUS_VALUES } 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 const pageSizeOptions = [20, 50, 100] as const
type PageSizeOption = (typeof pageSizeOptions)[number] type PageSizeOption = (typeof pageSizeOptions)[number]
const firstPageCursor = '__FIRST_PAGE__' const firstPageCursor = '__FIRST_PAGE__'
const allPowerStatusValue = 'all'
const searchFilterSchema = z.preprocess( const searchFilterSchema = z.preprocess(
(value) => (typeof value === 'string' ? value.trim() || undefined : value), (value) => (typeof value === 'string' ? value.trim() || undefined : value),
@@ -78,7 +79,7 @@ function parseSort(value: string): BatteryListSort {
} }
function parsePowerStatus(value: string): PowerStatus | undefined { function parsePowerStatus(value: string): PowerStatus | undefined {
if (value === '') return undefined if (value === allPowerStatusValue) return undefined
const parsed = Number(value) const parsed = Number(value)
@@ -326,22 +327,22 @@ function BatteriesPage() {
</label> </label>
<Select <Select
id="power-status-select" id="power-status-select"
value={search.powerStatus ?? ''} value={search.powerStatus ?? allPowerStatusValue}
onChange={(e) => { onValueChange={(value) => {
navigate({ navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
powerStatus: parsePowerStatus(e.target.value), powerStatus: parsePowerStatus(value),
cursor: undefined, cursor: undefined,
cursors: [], cursors: [],
}), }),
}) })
}} }}
> >
<option value=""></option> <SelectOption value={allPowerStatusValue}></SelectOption>
<option value={POWER_STATUS.NOT_CHARGING}></option> <SelectOption value={POWER_STATUS.NOT_CHARGING}></SelectOption>
<option value={POWER_STATUS.CHARGING}></option> <SelectOption value={POWER_STATUS.CHARGING}></SelectOption>
<option value={POWER_STATUS.FULL}></option> <SelectOption value={POWER_STATUS.FULL}></SelectOption>
</Select> </Select>
</div> </div>
@@ -352,21 +353,21 @@ function BatteriesPage() {
<Select <Select
id="sort-select" id="sort-select"
value={search.sort} value={search.sort}
onChange={(e) => { onValueChange={(value) => {
navigate({ navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
sort: parseSort(e.target.value), sort: parseSort(value),
cursor: undefined, cursor: undefined,
cursors: [], cursors: [],
}), }),
}) })
}} }}
> >
<option value={BATTERY_LIST_SORT.CREATED_AT_DESC}></option> <SelectOption value={BATTERY_LIST_SORT.CREATED_AT_DESC}></SelectOption>
<option value={BATTERY_LIST_SORT.CREATED_AT_ASC}></option> <SelectOption value={BATTERY_LIST_SORT.CREATED_AT_ASC}></SelectOption>
<option value={BATTERY_LIST_SORT.POWER_DESC}></option> <SelectOption value={BATTERY_LIST_SORT.POWER_DESC}></SelectOption>
<option value={BATTERY_LIST_SORT.POWER_ASC}></option> <SelectOption value={BATTERY_LIST_SORT.POWER_ASC}></SelectOption>
</Select> </Select>
</div> </div>
@@ -377,20 +378,20 @@ function BatteriesPage() {
<Select <Select
id="page-size-select" id="page-size-select"
value={search.pageSize} value={search.pageSize}
onChange={(e) => { onValueChange={(value) => {
navigate({ navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
pageSize: parsePageSize(e.target.value), pageSize: parsePageSize(value),
cursor: undefined, cursor: undefined,
cursors: [], cursors: [],
}), }),
}) })
}} }}
> >
<option value="20">20 /</option> <SelectOption value="20">20 /</SelectOption>
<option value="50">50 /</option> <SelectOption value="50">50 /</SelectOption>
<option value="100">100 /</option> <SelectOption value="100">100 /</SelectOption>
</Select> </Select>
</div> </div>