fix(web): repair UI-refactor regressions via design-system layer

Rework the earlier dropdown onSelect bridge: keep components/ui pristine
and use Base UI's native closeOnClick={false} + onClick at the nine menu
item call sites instead. Move product policies dropped by the refactor
into the design-system adapters: Button restores nativeButton detection
for render={<Link/>}, Select restores the mobile inline (no portal)
popup and the select-item-text wrapping slot.

Also restore behaviors lost in the refactor: page-size preference falls
back to and mirrors the legacy classic-theme key, background refresh
blocks interaction with stale rows again, pricing table rows are
keyboard accessible, and the pricing card grid resets to page 1 when
filters change.
This commit is contained in:
t0ng7u
2026-07-12 03:23:42 +08:00
parent baf668c48d
commit fdcd86c851
15 changed files with 171 additions and 136 deletions
+2 -4
View File
@@ -411,10 +411,8 @@ export const PromptInputActionAddAttachments = ({
return (
<DropdownMenuItem
{...props}
onSelect={(e) => {
e.preventDefault()
attachments.openFileDialog()
}}
closeOnClick={false}
onClick={() => attachments.openFileDialog()}
>
<ImageIcon className='mr-2 size-4' /> {resolvedLabel}
</DropdownMenuItem>
@@ -467,7 +467,7 @@ function renderMobile<TData>(
aria-busy={props.isLoading || props.isFetching || undefined}
className={cn(
'min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto transition-opacity duration-150',
isFetchingOnly && 'opacity-60'
isFetchingOnly && 'pointer-events-none opacity-60'
)}
>
{mobileContent}
@@ -494,7 +494,7 @@ function renderDesktop<TData>(
className={cn(
fixedHeight && 'min-h-0 flex-1 overflow-y-auto',
'transition-opacity duration-150',
isFetchingOnly && 'opacity-60'
isFetchingOnly && 'pointer-events-none opacity-60'
)}
aria-busy={props.isLoading || props.isFetching || undefined}
>
@@ -539,7 +539,7 @@ function renderDesktop<TData>(
containerClassName={cn(
fixedHeight && 'min-h-0 flex-1',
'transition-opacity duration-150',
isFetchingOnly && 'opacity-60',
isFetchingOnly && 'pointer-events-none opacity-60',
props.tableClassName
)}
containerProps={{
+22 -1
View File
@@ -50,12 +50,33 @@ type ButtonProps = Omit<React.ComponentProps<typeof ShadcnButton>, 'size'> & {
size?: ButtonSize
}
function Button({ className, size = 'default', ...props }: ButtonProps) {
// When rendering a non-<button> element (e.g. <Link>), Base UI needs
// nativeButton={false} to attach role='button' and keyboard semantics
// instead of assuming a native button.
function isNativeButtonRender(
render: React.ComponentProps<typeof ShadcnButton>['render']
) {
if (!render || !React.isValidElement(render)) {
return true
}
return render.type === 'button'
}
function Button({
className,
size = 'default',
nativeButton,
render,
...props
}: ButtonProps) {
return (
<ShadcnButton
data-control-size={size}
size={size === 'xl' ? 'lg' : size}
className={cn(responsiveButtonSizeVariants({ size }), className)}
nativeButton={nativeButton ?? isNativeButtonRender(render)}
render={render}
{...props}
/>
)
+93 -2
View File
@@ -16,13 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Select as SelectPrimitive } from '@base-ui/react/select'
import { Tick02Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import * as React from 'react'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
@@ -30,6 +31,7 @@ import {
SelectTrigger as ShadcnSelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
function SelectTrigger({
@@ -50,6 +52,95 @@ function SelectTrigger({
)
}
// Product policy: on mobile the popup renders inline (no portal) so selects
// inside sheets/drawers are not dismissed by the overlay's outside-click and
// focus management.
function SelectContent({
className,
children,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
>) {
const isMobile = useMediaQuery('(max-width: 640px)')
const content = (
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className='isolate z-50'
>
<SelectPrimitive.Popup
data-slot='select-content'
data-align-trigger={alignItemWithTrigger}
className={cn(
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
)
if (isMobile) {
return content
}
return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>
}
// Product policy: the item text carries data-slot='select-item-text' as a
// stable hook so call sites can opt long labels into wrapping, e.g.
// [&_[data-slot=select-item-text]]:whitespace-normal.
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot='select-item'
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText
data-slot='select-item-text'
className='flex flex-1 shrink-0 gap-2 whitespace-nowrap'
>
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className='pointer-events-none absolute right-2 flex size-4 items-center justify-center' />
}
>
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className='pointer-events-none'
/>
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
export {
Select,
SelectContent,
-25
View File
@@ -1,25 +0,0 @@
import type * as React from 'react'
export type DropdownMenuItemSelectEvent = React.MouseEvent<HTMLElement> & {
preventBaseUIHandler?: () => void
}
export type DropdownMenuItemSelectHandler = (
event: DropdownMenuItemSelectEvent
) => void
export function handleDropdownMenuItemSelect(
event: DropdownMenuItemSelectEvent,
onClick?: React.MouseEventHandler<HTMLElement>,
onSelect?: DropdownMenuItemSelectHandler
) {
onClick?.(event)
if (!event.defaultPrevented) {
onSelect?.(event)
}
if (event.defaultPrevented) {
event.preventBaseUIHandler?.()
}
}
-51
View File
@@ -1,51 +0,0 @@
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { handleDropdownMenuItemSelect } from './dropdown-menu-events'
function createMenuEvent() {
let defaultPrevented = false
let baseUIHandlerPrevented = false
return {
get defaultPrevented() {
return defaultPrevented
},
preventDefault() {
defaultPrevented = true
},
preventBaseUIHandler() {
baseUIHandlerPrevented = true
},
get baseUIHandlerPrevented() {
return baseUIHandlerPrevented
},
} as unknown as Parameters<typeof handleDropdownMenuItemSelect>[0] & {
baseUIHandlerPrevented: boolean
}
}
describe('DropdownMenuItem onSelect compatibility', () => {
test('calls the Radix-style onSelect handler on item click', () => {
const event = createMenuEvent()
let selected = false
handleDropdownMenuItemSelect(event, undefined, () => {
selected = true
})
assert.equal(selected, true)
assert.equal(event.baseUIHandlerPrevented, false)
})
test('keeps the Base UI menu open when onSelect prevents default', () => {
const event = createMenuEvent()
handleDropdownMenuItemSelect(event, undefined, (selectEvent) => {
selectEvent.preventDefault()
})
assert.equal(event.defaultPrevented, true)
assert.equal(event.baseUIHandlerPrevented, true)
})
})
+1 -17
View File
@@ -23,11 +23,6 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
import {
handleDropdownMenuItemSelect,
type DropdownMenuItemSelectHandler,
} from './dropdown-menu-events'
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot='dropdown-menu' {...props} />
}
@@ -102,21 +97,11 @@ function DropdownMenuItem({
className,
inset,
variant = 'default',
onClick,
onSelect,
...props
}: Omit<MenuPrimitive.Item.Props, 'onSelect'> & {
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: 'default' | 'destructive'
onSelect?: DropdownMenuItemSelectHandler
}) {
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
handleDropdownMenuItemSelect(event, onClick, onSelect)
},
[onClick, onSelect]
)
return (
<MenuPrimitive.Item
data-slot='dropdown-menu-item'
@@ -126,7 +111,6 @@ function DropdownMenuItem({
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
onClick={onClick || onSelect ? handleClick : undefined}
{...props}
/>
)
@@ -245,10 +245,8 @@ export function ChannelsPrimaryButtons() {
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setShowConsistencyDialog(true)
}}
closeOnClick={false}
onClick={() => setShowConsistencyDialog(true)}
>
{t('Repair Channel Consistency')}
<DropdownMenuShortcut>
@@ -259,8 +257,8 @@ export function ChannelsPrimaryButtons() {
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
closeOnClick={false}
onClick={() => {
if (!canEditSensitive) return
setShowDeleteDialog(true)
}}
@@ -367,8 +367,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{/* Delete */}
<DropdownMenuItem
disabled={!canEditSensitive}
onSelect={(e) => {
e.preventDefault()
closeOnClick={false}
onClick={() => {
if (!canEditSensitive) return
setDeleteConfirmOpen(true)
}}
@@ -108,10 +108,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
closeOnClick={false}
onClick={() => setDeleteConfirmOpen(true)}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
@@ -47,6 +47,12 @@ export function ModelCardGrid(props: ModelCardGridProps) {
const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
const currentPage = Math.min(page, totalPages)
// Search/filter/sort changes replace the models array; jump back to the
// first page so users see the best matches instead of a mid-result page.
useEffect(() => {
setPage(1)
}, [props.models])
const perfQuery = useQuery({
queryKey: ['perf-metrics-summary', 24],
queryFn: () => getPerfMetricsSummary(24),
@@ -87,6 +87,15 @@ export function PricingTable(props: PricingTableProps) {
[onModelClick]
)
const handleRowKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTableRowElement>, model: PricingModel) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
handleRowClick(model)
},
[handleRowClick]
)
return (
<div className='space-y-4'>
<DataTableView
@@ -103,8 +112,11 @@ export function PricingTable(props: PricingTableProps) {
<DataTableRow
key={row.id}
row={row}
className='hover:bg-muted/30 cursor-pointer transition-colors'
tabIndex={0}
aria-label={`${t('View details')}: ${row.original.model_name}`}
className='hover:bg-muted/30 focus-visible:ring-ring cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none'
onClick={() => handleRowClick(row.original)}
onKeyDown={(event) => handleRowKeyDown(event, row.original)}
/>
)}
/>
@@ -199,10 +199,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
)}
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setBindingDialogOpen(true)
}}
closeOnClick={false}
onClick={() => setBindingDialogOpen(true)}
>
{t('Manage Bindings')}
<DropdownMenuShortcut>
@@ -211,10 +209,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setSubscriptionsDialogOpen(true)
}}
closeOnClick={false}
onClick={() => setSubscriptionsDialogOpen(true)}
>
{t('Manage Subscriptions')}
<DropdownMenuShortcut>
@@ -225,10 +221,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setResetPasskeyOpen(true)
}}
closeOnClick={false}
onClick={() => setResetPasskeyOpen(true)}
disabled={isRoot}
>
{t('Reset Passkey')}
@@ -238,10 +232,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setResetTwoFAOpen(true)
}}
closeOnClick={false}
onClick={() => setResetTwoFAOpen(true)}
disabled={isRoot}
>
{t('Reset 2FA')}
+11 -1
View File
@@ -25,11 +25,20 @@ import { useEffect, useMemo, useState } from 'react'
type SearchRecord = Record<string, unknown>
// Legacy global key shared with the classic theme (raw number string). Used
// as a read fallback so preferences saved before the per-table keys existed
// are not lost, and mirrored on write so the choice still carries over from
// and to classic.
const LEGACY_PAGE_SIZE_STORAGE_KEY = 'page-size'
function getStoredPageSize(storageKey: string | undefined): number | undefined {
if (!storageKey || typeof window === 'undefined') return undefined
try {
const n = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10)
const raw =
window.localStorage.getItem(storageKey) ??
window.localStorage.getItem(LEGACY_PAGE_SIZE_STORAGE_KEY)
const n = Number.parseInt(raw ?? '', 10)
return n > 0 ? n : undefined // n > 0 also rejects NaN
} catch {
return undefined
@@ -41,6 +50,7 @@ function setStoredPageSize(storageKey: string | undefined, size: number) {
try {
window.localStorage.setItem(storageKey, String(size))
window.localStorage.setItem(LEGACY_PAGE_SIZE_STORAGE_KEY, String(size))
} catch {
/* ignore */
}
+3 -2
View File
@@ -32,8 +32,9 @@ declare module '@tanstack/react-table' {
*/
contentMode?: 'full' | 'wrap' | 'summary'
/**
* Responsive card placement. Secondary fields stay accessible behind the
* shared details disclosure; only duplicate/technical fields use hidden.
* Responsive card placement. Primary and secondary fields are both always
* visible (secondary marks lower-emphasis details); only duplicate or
* technical fields use hidden.
*/
cardRole?: 'title' | 'badge' | 'primary' | 'secondary' | 'hidden'
cardOrder?: number