feat(web): migrate shared select to radix

This commit is contained in:
dongmucat 2026-03-20 11:34:09 +08:00 • committed by Xudong Sun
parent c116ac2167
commit c36b74fda6
8 changed files with 317 additions and 93 deletions

View file

@ -15,7 +15,13 @@ import {
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select'
interface AddNamespaceMemberDialogProps {
slug: string
@ -212,16 +218,17 @@ export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberD
<div className="space-y-2">
<Label htmlFor="member-role">{t('members.roleLabel')}</Label>
<Select
id="member-role"
value={role}
onChange={(event) => setRole(event.target.value as NamespaceRole)}
>
{ROLE_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(option === 'ADMIN' ? 'members.roleAdmin' : 'members.roleMember')}
</option>
))}
<Select value={role} onValueChange={(value) => setRole(value as NamespaceRole)}>
<SelectTrigger id="member-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(option === 'ADMIN' ? 'members.roleAdmin' : 'members.roleMember')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

View file

@ -14,7 +14,13 @@ import {
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select'
import { centeredToastOptions, toast } from '@/shared/lib/toast'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types'
@ -176,18 +182,22 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
<div className="space-y-2">
<Label htmlFor="token-expiration">{t('createToken.expirationLabel')}</Label>
<Select
id="token-expiration"
value={expirationMode}
onChange={(e) => {
setExpirationMode(e.target.value as TokenExpirationMode)
onValueChange={(value) => {
setExpirationMode(value as TokenExpirationMode)
setExpiresAtError(null)
}}
>
<option value="never">{t('createToken.expirationNever')}</option>
<option value="7d">{t('createToken.expiration7d')}</option>
<option value="30d">{t('createToken.expiration30d')}</option>
<option value="90d">{t('createToken.expiration90d')}</option>
<option value="custom">{t('createToken.expirationCustom')}</option>
<SelectTrigger id="token-expiration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{t('createToken.expirationNever')}</SelectItem>
<SelectItem value="7d">{t('createToken.expiration7d')}</SelectItem>
<SelectItem value="30d">{t('createToken.expiration30d')}</SelectItem>
<SelectItem value="90d">{t('createToken.expiration90d')}</SelectItem>
<SelectItem value="custom">{t('createToken.expirationCustom')}</SelectItem>
</SelectContent>
</Select>
{expirationMode === 'custom' ? (
<Input

View file

@ -19,7 +19,13 @@ import { centeredToastOptions, toast } from '@/shared/lib/toast'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Label } from '@/shared/ui/label'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select'
import { Input } from '@/shared/ui/input'
import type { ApiToken } from '@/api/types'
import { resolveTokenExpiresAt, toLocalDateTimeInputValue, type TokenExpirationMode } from './token-expiration'
@ -276,18 +282,22 @@ export function TokenList() {
<div className="space-y-2">
<Label htmlFor="token-expiration-mode">{t('createToken.expirationLabel')}</Label>
<Select
id="token-expiration-mode"
value={expirationDialog.mode}
onChange={(event) => setExpirationDialog((current) => ({
onValueChange={(value) => setExpirationDialog((current) => ({
...current,
mode: event.target.value as TokenExpirationMode,
mode: value as TokenExpirationMode,
}))}
>
<option value="never">{t('createToken.expirationNever')}</option>
<option value="7d">{t('createToken.expiration7d')}</option>
<option value="30d">{t('createToken.expiration30d')}</option>
<option value="90d">{t('createToken.expiration90d')}</option>
<option value="custom">{t('createToken.expirationCustom')}</option>
<SelectTrigger id="token-expiration-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{t('createToken.expirationNever')}</SelectItem>
<SelectItem value="7d">{t('createToken.expiration7d')}</SelectItem>
<SelectItem value="30d">{t('createToken.expiration30d')}</SelectItem>
<SelectItem value="90d">{t('createToken.expiration90d')}</SelectItem>
<SelectItem value="custom">{t('createToken.expirationCustom')}</SelectItem>
</SelectContent>
</Select>
</div>
{expirationDialog.mode === 'custom' ? (

View file

@ -4,7 +4,14 @@ import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
normalizeSelectValue,
} from '@/shared/ui/select'
import {
Table,
TableBody,
@ -42,6 +49,7 @@ const ACTION_OPTIONS = [
*/
export function AuditLogPage() {
const { t, i18n } = useTranslation()
const allActionFilterValue = '__all_actions__'
const [actionFilter, setActionFilter] = useState<string>('')
const [userIdFilter, setUserIdFilter] = useState('')
const [requestIdFilter, setRequestIdFilter] = useState('')
@ -105,15 +113,26 @@ export function AuditLogPage() {
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Select value={actionFilter} onChange={(e) => {
setActionFilter(e.target.value)
setPage(0)
}} className="w-[200px]">
{ACTION_OPTIONS.map((option) => (
<option key={option.value || 'all'} value={option.value}>
{t(option.labelKey)}
</option>
))}
<Select
value={normalizeSelectValue(actionFilter) ?? allActionFilterValue}
onValueChange={(value) => {
setActionFilter(value === allActionFilterValue ? '' : value)
setPage(0)
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((option) => (
<SelectItem
key={option.value || allActionFilterValue}
value={option.value || allActionFilterValue}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t('auditLog.userIdPlaceholder')}

View file

@ -4,7 +4,14 @@ import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
normalizeSelectValue,
} from '@/shared/ui/select'
import {
Table,
TableBody,
@ -31,6 +38,7 @@ import type { AdminUser } from '@/features/admin/use-admin-users'
*/
export function AdminUsersPage() {
const { t, i18n } = useTranslation()
const allStatusFilterValue = '__all_statuses__'
const roleOptions = [
{ value: 'USER', label: t('adminUsers.roleUser') },
{ value: 'SKILL_ADMIN', label: t('adminUsers.roleReviewer') },
@ -154,11 +162,19 @@ export function AdminUsersPage() {
</div>
<div className="space-y-2">
<Label htmlFor="admin-user-status">{t('adminUsers.filterLabel')}</Label>
<Select id="admin-user-status" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{t('adminUsers.filterAll')}</option>
<option value="ACTIVE">{t('adminUsers.filterActive')}</option>
<option value="PENDING">{t('adminUsers.filterPending')}</option>
<option value="DISABLED">{t('adminUsers.filterDisabled')}</option>
<Select
value={normalizeSelectValue(statusFilter) ?? allStatusFilterValue}
onValueChange={(value) => setStatusFilter(value === allStatusFilterValue ? '' : value)}
>
<SelectTrigger id="admin-user-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={allStatusFilterValue}>{t('adminUsers.filterAll')}</SelectItem>
<SelectItem value="ACTIVE">{t('adminUsers.filterActive')}</SelectItem>
<SelectItem value="PENDING">{t('adminUsers.filterPending')}</SelectItem>
<SelectItem value="DISABLED">{t('adminUsers.filterDisabled')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
@ -288,12 +304,17 @@ export function AdminUsersPage() {
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="role">{t('adminUsers.roleLabel')}</Label>
<Select id="role" value={newRole} onChange={(e) => setNewRole(e.target.value)}>
{roleOptions.map((roleOption) => (
<option key={roleOption.value} value={roleOption.value}>
{roleOption.label}
</option>
))}
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((roleOption) => (
<SelectItem key={roleOption.value} value={roleOption.value}>
{roleOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

View file

@ -8,7 +8,13 @@ import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select'
import {
useMyNamespaces,
useNamespaceDetail,
@ -192,17 +198,21 @@ export function NamespaceMembersPage() {
{canManageMembers && !isOwner ? (
<div className="flex items-center gap-2">
<Select
className="w-36"
value={roleValue}
onChange={(event) => {
onValueChange={(value) => {
setDraftRoles((current) => ({
...current,
[member.userId]: event.target.value,
[member.userId]: value,
}))
}}
>
<option value="MEMBER">{t('members.roleMember')}</option>
<option value="ADMIN">{t('members.roleAdmin')}</option>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">{t('members.roleMember')}</SelectItem>
<SelectItem value="ADMIN">{t('members.roleAdmin')}</SelectItem>
</SelectContent>
</Select>
<Button
type="button"

View file

@ -3,7 +3,14 @@ import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { UploadZone } from '@/features/publish/upload-zone'
import { Button } from '@/shared/ui/button'
import { Select } from '@/shared/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
normalizeSelectValue,
} from '@/shared/ui/select'
import { Label } from '@/shared/ui/label'
import { Card } from '@/shared/ui/card'
import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries'
@ -147,30 +154,34 @@ export function PublishPage() {
<div className="h-11 animate-shimmer rounded-lg" />
) : (
<Select
id="namespace"
value={namespaceSlug}
onChange={(e) => setNamespaceSlug(e.target.value)}
value={normalizeSelectValue(namespaceSlug)}
onValueChange={setNamespaceSlug}
>
<option value="">{t('publish.selectNamespace')}</option>
{namespaces?.map((ns) => (
<option key={ns.id} value={ns.slug}>
{ns.displayName} (@{ns.slug})
</option>
))}
<SelectTrigger id="namespace">
<SelectValue placeholder={t('publish.selectNamespace')} />
</SelectTrigger>
<SelectContent>
{namespaces?.map((ns) => (
<SelectItem key={ns.id} value={ns.slug}>
{ns.displayName} (@{ns.slug})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-3">
<Label htmlFor="visibility" className="text-sm font-semibold font-heading">{t('publish.visibility')}</Label>
<Select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
<option value="PUBLIC">{t('publish.visibilityOptions.public')}</option>
<option value="NAMESPACE_ONLY">{namespaceOnlyLabel}</option>
<option value="PRIVATE">{t('publish.visibilityOptions.private')}</option>
<Select value={visibility} onValueChange={setVisibility}>
<SelectTrigger id="visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PUBLIC">{t('publish.visibilityOptions.public')}</SelectItem>
<SelectItem value="NAMESPACE_ONLY">{namespaceOnlyLabel}</SelectItem>
<SelectItem value="PRIVATE">{t('publish.visibilityOptions.private')}</SelectItem>
</SelectContent>
</Select>
</div>

View file

@ -1,19 +1,27 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
export const SELECT_TRIGGER_CLASS_NAME = cn(
'flex h-11 w-full rounded-lg border border-border/60 bg-secondary/50 px-4 py-2 text-sm text-foreground',
'flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-secondary/50 px-4 py-2 text-sm text-foreground',
'ring-offset-background transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50',
'disabled:cursor-not-allowed disabled:opacity-50'
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1'
)
export const SELECT_CONTENT_CLASS_NAME = cn(
'z-50 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md'
'z-50 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
)
export const SELECT_ITEM_CLASS_NAME = cn(
'relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none',
'relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-8 text-sm outline-none',
'focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
)
@ -21,22 +29,150 @@ export function normalizeSelectValue(value?: string | null) {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
className={cn(SELECT_TRIGGER_CLASS_NAME, className)}
ref={ref}
{...props}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(SELECT_TRIGGER_CLASS_NAME, className)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
SELECT_CONTENT_CLASS_NAME,
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</select>
)
}
)
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
Select.displayName = 'Select'
SelectContent.displayName = SelectPrimitive.Content.displayName
export { Select }
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(SELECT_ITEM_CLASS_NAME, className)}
{...props}
>
<span className="absolute left-3 flex h-4 w-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
}