fix(web): prevent list cell text and badge overflow (#5510)
* fix(ui): prevent table cell text overflow - add default truncation with hover details for text cells in shared and static data tables to prevent content from spilling into adjacent columns. - adjust API key group, model, and IP restriction columns to fix badge overlap and left alignment drift. - reuse a shared truncated cell component and add width constraints for composite badge cells. * fix(table): prevent badge content from overflowing columns - make table text and badge cells shrink within constrained columns so long values truncate instead of bleeding into adjacent cells. - add a shared BadgeCell wrapper to keep badge alignment consistent across API keys and other list pages. - update affected list views to use constrained wrappers for group, provider, pricing, OAuth, and API info values.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BadgeCellProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function BadgeCell({ className, ...props }: BadgeCellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-ml-1.5 flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -17,13 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { StatusBadgeList } from '@/components/status-badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { StatusBadgeList } from '@/components/status-badge'
|
||||
|
||||
interface BadgeListCellProps {
|
||||
items: React.ReactNode[]
|
||||
@@ -50,7 +50,7 @@ export function BadgeListCell({
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='-ml-1.5' />}>
|
||||
<TooltipTrigger render={<div className='-ml-1.5 max-w-full' />}>
|
||||
<StatusBadgeList
|
||||
items={items}
|
||||
max={max}
|
||||
|
||||
@@ -17,8 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import { flexRender, type Cell, type Row } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { TruncatedCell } from './truncated-cell'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableRowProps<TData> = {
|
||||
@@ -42,9 +44,12 @@ function DataTableRowInner<TData>({
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getColumnClassName?.(cell.column.id, 'cell')}
|
||||
className={cn(
|
||||
'max-w-full min-w-0 overflow-hidden',
|
||||
getColumnClassName?.(cell.column.id, 'cell')
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{renderCellContent(cell)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -61,3 +66,28 @@ export const DataTableRow = React.memo(DataTableRowInner, (prev, next) => {
|
||||
prev.row.getIsSelected() === next.row.getIsSelected()
|
||||
)
|
||||
}) as typeof DataTableRowInner
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>) {
|
||||
const content = flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) return content
|
||||
|
||||
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
if (
|
||||
React.isValidElement<{ children?: React.ReactNode }>(content) &&
|
||||
(typeof content.props.children === 'string' ||
|
||||
typeof content.props.children === 'number')
|
||||
) {
|
||||
return String(content.props.children)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type TruncatedCellProps = {
|
||||
children: React.ReactNode
|
||||
cellClassName?: string
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
tooltipClassName?: string
|
||||
tooltipContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export function TruncatedCell({
|
||||
children,
|
||||
cellClassName,
|
||||
className,
|
||||
contentClassName,
|
||||
side = 'top',
|
||||
tooltipClassName,
|
||||
tooltipContent,
|
||||
}: TruncatedCellProps) {
|
||||
const content = tooltipContent ?? getTextContent(children)
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={cn('truncate', contentClassName)}>{children}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
className={cn('max-w-xs break-all', tooltipClassName)}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function getTextContent(node: React.ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||
if (Array.isArray(node)) return node.map(getTextContent).join('')
|
||||
return ''
|
||||
}
|
||||
@@ -18,7 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { DataTablePagination } from './core/pagination'
|
||||
export { DataTableColumnHeader } from './core/column-header'
|
||||
export { BadgeCell } from './core/badge-cell'
|
||||
export { BadgeListCell } from './core/badge-list-cell'
|
||||
export { TruncatedCell } from './core/truncated-cell'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { TruncatedCell } from '../core/truncated-cell'
|
||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
|
||||
type StaticDataTableBaseProps = {
|
||||
@@ -163,15 +164,47 @@ function StaticDataTableRow<TData>({
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={getStaticCellClassName(column, row, index)}
|
||||
className={cn(
|
||||
'max-w-full min-w-0 overflow-hidden',
|
||||
getStaticCellClassName(column, row, index)
|
||||
)}
|
||||
>
|
||||
{column.cell?.(row, index)}
|
||||
{renderStaticCellContent(column, row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStaticCellContent<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
const content = column.cell?.(row, index)
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) return content
|
||||
|
||||
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
if (
|
||||
React.isValidElement<{ children?: React.ReactNode }>(content) &&
|
||||
(typeof content.props.children === 'string' ||
|
||||
typeof content.props.children === 'number')
|
||||
) {
|
||||
return String(content.props.children)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getStaticCellClassName<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
|
||||
+5
-3
@@ -60,6 +60,7 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
ratio,
|
||||
copyable = false,
|
||||
showDot,
|
||||
className,
|
||||
...badgeProps
|
||||
} = props
|
||||
const groupName = group?.trim()
|
||||
@@ -82,6 +83,7 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
showDot={showDot ?? (isSpecialGroup ? false : undefined)}
|
||||
variant={isSpecialGroup ? 'neutral' : undefined}
|
||||
autoColor={isSpecialGroup ? undefined : groupName}
|
||||
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -90,11 +92,11 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='inline-flex items-center gap-2 text-xs'>
|
||||
{badge}
|
||||
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
|
||||
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
|
||||
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
>
|
||||
|
||||
+13
-5
@@ -22,6 +22,7 @@ import { type LucideIcon } from 'lucide-react'
|
||||
import { stringToColor } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
|
||||
export const dotColorMap = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
@@ -81,7 +82,8 @@ export type StatusBadgeType = 'badge' | 'text' | 'underline'
|
||||
|
||||
/** Context that lets ancestor components (e.g. MobileCardList field area)
|
||||
* override the badge type without modifying every call site. */
|
||||
export const StatusBadgeTypeContext = React.createContext<StatusBadgeType>('badge')
|
||||
export const StatusBadgeTypeContext =
|
||||
React.createContext<StatusBadgeType>('badge')
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
|
||||
@@ -153,15 +155,21 @@ export function StatusBadge({
|
||||
) : null)
|
||||
|
||||
const isBadge = type === 'badge'
|
||||
const title = copyable
|
||||
? `Click to copy: ${copyText || label || ''}`
|
||||
: label || undefined
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot='status-badge'
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
'inline-flex w-fit max-w-full min-w-0 shrink items-center font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
isBadge
|
||||
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
|
||||
: cn(textSizeMap[size ?? 'sm'], type === 'underline' && 'border-b border-current pb-px'),
|
||||
: cn(
|
||||
textSizeMap[size ?? 'sm'],
|
||||
type === 'underline' && 'border-b border-current pb-px'
|
||||
),
|
||||
textColorMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
copyable &&
|
||||
@@ -169,7 +177,7 @@ export function StatusBadge({
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{showDot && (
|
||||
@@ -221,7 +229,7 @@ export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-1 overflow-hidden',
|
||||
'flex max-w-full min-w-0 items-center gap-1 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...domProps}
|
||||
|
||||
+4
-20
@@ -1,10 +1,5 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { TruncatedCell } from '@/components/data-table'
|
||||
|
||||
interface TruncatedTextProps {
|
||||
text: string
|
||||
@@ -20,19 +15,8 @@ export function TruncatedText({
|
||||
side = 'top',
|
||||
}: TruncatedTextProps) {
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className={cn('block truncate', maxWidth, className)} />
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className='max-w-xs break-all'>
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TruncatedCell className={cn(maxWidth, className)} side={side}>
|
||||
{text}
|
||||
</TruncatedCell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user