mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-03 04:43:01 +00:00
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:
+2
-4
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
}
|
||||
|
||||
Vendored
+3
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user