♻️ refactor(web): refine data-table cards and pricing page layout

Replace collapsible card details with always-visible label/value rows, add badge-list full display for cards, and polish pricing/channel toolbars and mobile cards.
This commit is contained in:
t0ng7u
2026-07-11 06:02:16 +08:00
parent 0918bdb49a
commit 9d1ca545e2
68 changed files with 1971 additions and 1840 deletions
@@ -0,0 +1,24 @@
/*
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 { createContext } from 'react'
export type BadgeListCellDisplay = 'compact' | 'full'
export const BadgeListCellDisplayContext =
createContext<BadgeListCellDisplay>('compact')
@@ -26,6 +26,8 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeListCellDisplayContext } from './badge-list-cell-context'
interface BadgeListCellProps {
items: React.ReactNode[]
max?: number
@@ -33,18 +35,31 @@ interface BadgeListCellProps {
}
/**
* Table cell renderer for a list of badges with overflow tooltip.
* Displays up to `max` badges inline; remaining items appear in a tooltip.
* Badge collection that stays compact in table cells and can expose every
* item when rendered inside a detail-oriented card.
*/
export function BadgeListCell({
items,
max = 2,
tooltipClassName,
}: BadgeListCellProps) {
const display = React.useContext(BadgeListCellDisplayContext)
if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
if (display === 'full') {
return (
<StatusBadgeList
items={items}
max={items.length}
renderItem={(item) => item}
className='flex-wrap overflow-visible'
/>
)
}
const showTooltip = items.length > max
return (
+5 -1
View File
@@ -20,6 +20,10 @@ 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 {
BadgeListCellDisplayContext,
type BadgeListCellDisplay,
} from './core/badge-list-cell-context'
export { TruncatedCell } from './core/truncated-cell'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
@@ -55,8 +59,8 @@ export {
} from './layout/card-grid'
export { CardRowContent } from './layout/card-row-content'
export {
DataTableCardDetails,
DataTableCardField,
DataTableCardRow,
type DataTableContentMode,
} from './layout/card-field'
export { tableHasCompactMeta } from './layout/card-cell-utils'
+44 -46
View File
@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
import { Button } from '@/components/design-system/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export type DataTableContentMode = 'full' | 'wrap' | 'summary'
const VALUE_WRAP_CLASS =
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
interface DataTableCardFieldProps {
children: ReactNode
className?: string
@@ -39,6 +34,10 @@ interface DataTableCardFieldProps {
valueClassName?: string
}
/**
* Stacked label-above-value field. Prefer {@link DataTableCardRow} for dense
* scannable cards; keep this for multi-line badge collections.
*/
export function DataTableCardField({
children,
className,
@@ -53,7 +52,7 @@ export function DataTableCardField({
className={cn('min-w-0', span === 2 && 'col-span-2', className)}
>
{label && (
<div className='text-muted-foreground mb-1 text-xs leading-none font-medium select-none'>
<div className='text-muted-foreground mb-1.5 text-xs leading-none select-none'>
{label}
</div>
)}
@@ -62,7 +61,7 @@ export function DataTableCardField({
className={cn(
'min-w-0 text-sm leading-snug',
(contentMode === 'full' || contentMode === 'wrap') &&
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal',
VALUE_WRAP_CLASS,
contentMode === 'full' && 'break-all',
valueClassName
)}
@@ -73,49 +72,48 @@ export function DataTableCardField({
)
}
interface DataTableCardDetailsProps {
interface DataTableCardRowProps {
children: ReactNode
className?: string
count?: number
defaultOpen?: boolean
contentMode?: DataTableContentMode
label: ReactNode
valueClassName?: string
}
export function DataTableCardDetails({
/**
* Dense definition-list row: muted label left, value right.
* Always visible — no progressive disclosure / "More" click required.
*/
export function DataTableCardRow({
children,
className,
count,
defaultOpen = false,
}: DataTableCardDetailsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(defaultOpen)
contentMode = 'wrap',
label,
valueClassName,
}: DataTableCardRowProps) {
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className={cn('mt-2', className)}
<div
data-slot='data-table-card-row'
className={cn(
'flex min-h-6 items-start justify-between gap-4 py-0.5',
className
)}
>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
size='xs'
className='text-muted-foreground hover:text-foreground group/details -ml-2'
/>
}
>
{open ? t('Less') : t('More')}
{!open && count != null && count > 0 && (
<span className='tabular-nums'>({count})</span>
<span className='text-muted-foreground shrink-0 pt-0.5 text-xs select-none'>
{label}
</span>
<div
data-slot='data-table-card-value'
className={cn(
'flex min-w-0 flex-wrap items-center justify-end gap-1 text-right text-sm leading-snug',
(contentMode === 'full' || contentMode === 'wrap') &&
VALUE_WRAP_CLASS,
contentMode === 'full' && 'break-all',
valueClassName
)}
<ChevronDown className='size-3.5 transition-transform duration-150 group-data-[panel-open]/details:rotate-180' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-2 border-t pt-2'>
{children}
</div>
</CollapsibleContent>
</Collapsible>
>
{children ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
)
}
+1 -1
View File
@@ -169,7 +169,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
data-slot='data-table-card'
data-state={isSelected ? 'selected' : undefined}
className={cn(
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3.5 py-3 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
props.getRowClassName?.(row)
)}
>
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { Cell, Row } from '@tanstack/react-table'
import { getCellLabel, renderCellContent } from './card-cell-utils'
import { DataTableCardDetails, DataTableCardField } from './card-field'
import { DataTableCardField, DataTableCardRow } from './card-field'
type CardRole = 'title' | 'badge' | 'primary' | 'secondary' | 'hidden'
@@ -43,26 +43,14 @@ function orderCardCells<TData>(
})
}
function CardFields<TData>({ cells }: { cells: Cell<TData, unknown>[] }) {
return cells.map((cell) => {
const meta = cell.column.columnDef.meta
return (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
>
{renderCellContent(cell)}
</DataTableCardField>
)
})
function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
const meta = cell.column.columnDef.meta
return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
}
/**
* Shared row content for both the mobile list and optional desktop card grid.
* Primary values never clip silently; lower-priority values remain available
* through the shared progressive details disclosure.
* All visible fields render immediately — no "More" click to reveal content.
*/
export function CardRowContent<TData>(props: {
row: Row<TData>
@@ -74,67 +62,84 @@ export function CardRowContent<TData>(props: {
const titleCell = cells.find((cell) => getCardRole(cell) === 'title')
const badgeCell = cells.find((cell) => getCardRole(cell) === 'badge')
const actionsCell = cells.find((cell) => cell.column.id === 'actions')
const fieldCells = orderCardCells(
const bodyCells = orderCardCells(
cells.filter(
(cell) =>
cell !== titleCell &&
cell !== badgeCell &&
cell !== actionsCell &&
getCardRole(cell) === 'primary'
)
)
const secondaryCells = orderCardCells(
cells.filter(
(cell) =>
cell !== titleCell &&
cell !== badgeCell &&
cell !== actionsCell &&
getCardRole(cell) === 'secondary'
getCardRole(cell) !== 'hidden'
)
)
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
const wideCells = bodyCells.filter((cell) => isWideField(cell))
return (
<>
<div className='flex min-w-0 flex-col'>
{props.compact && (titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
<div className='min-w-0 flex-1 text-sm font-medium [overflow-wrap:anywhere] break-words whitespace-normal [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_[data-slot=status-badge-label]]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:max-w-full'>
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold [overflow-wrap:anywhere] break-words whitespace-normal [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_[data-slot=status-badge-label]]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:max-w-full'>
{titleCell ? renderCellContent(titleCell) : null}
</div>
{badgeCell && (
<DataTableCardField
contentMode={badgeCell.column.columnDef.meta?.contentMode}
className='max-w-1/2 shrink'
valueClassName='flex justify-end text-right'
>
<div className='max-w-1/2 shrink text-right'>
{renderCellContent(badgeCell)}
</DataTableCardField>
</div>
)}
</div>
)}
{fieldCells.length > 0 && (
<div
className={
props.compact
? 'mt-2 grid grid-cols-2 gap-x-3 gap-y-2'
: 'grid grid-cols-2 gap-x-3 gap-y-2'
}
>
<CardFields cells={fieldCells} />
{!props.compact && (
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{bodyCells.map((cell) => {
const meta = cell.column.columnDef.meta
return (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={meta?.contentMode}
span={meta?.cardSpan}
>
{renderCellContent(cell)}
</DataTableCardField>
)
})}
</div>
)}
{secondaryCells.length > 0 && (
<DataTableCardDetails count={secondaryCells.length}>
<CardFields cells={secondaryCells} />
</DataTableCardDetails>
{props.compact && rowCells.length > 0 && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{rowCells.map((cell) => (
<DataTableCardRow
key={cell.id}
label={getCellLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{renderCellContent(cell)}
</DataTableCardRow>
))}
</div>
)}
{props.compact && wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCellLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{renderCellContent(cell)}
</DataTableCardField>
))}
</div>
)}
{actionsCell && (
<div className='mt-2 -mb-0.5 flex justify-end border-t pt-2'>
<div className='mt-3 flex justify-end border-t pt-2'>
{renderCellContent(actionsCell)}
</div>
)}
</>
</div>
)
}
@@ -150,7 +150,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
<div
key={key}
className={cn(
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3.5 py-3',
getRowClassName?.(row)
)}
>
@@ -55,6 +55,7 @@ export interface DataTableFilterPanelProps<TData> {
searchLoading?: boolean
onReset: () => void
onSearch?: () => void
inlineActions?: boolean
className?: string
}
@@ -144,6 +145,35 @@ export function DataTableFilterPanel<TData>(
<DataTableViewOptions table={props.table} />
) : null
const desktopActions = (
<div className='ms-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
{props.actionStart}
<Button
type='button'
variant={props.onSearch ? 'outline' : 'ghost'}
onClick={props.onReset}
disabled={!props.hasActiveFilters}
className={cn(
!props.onSearch && 'text-muted-foreground hover:text-foreground px-2'
)}
>
{t('Reset')}
</Button>
{props.onSearch && (
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
)}
{props.viewToggle}
{viewOptions}
</div>
)
if (isMobile && props.mobilePinnedFilters != null) {
return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
@@ -264,6 +294,7 @@ export function DataTableFilterPanel<TData>(
{advancedToggle}
</div>
)}
{props.inlineActions && desktopActions}
</div>
{advancedOpen && props.advancedFilters && (
@@ -272,36 +303,12 @@ export function DataTableFilterPanel<TData>(
</div>
)}
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
{props.stats}
<div className='ms-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
{props.actionStart}
<Button
type='button'
variant={props.onSearch ? 'outline' : 'ghost'}
onClick={props.onReset}
disabled={!props.hasActiveFilters}
className={cn(
!props.onSearch &&
'text-muted-foreground hover:text-foreground px-2'
)}
>
{t('Reset')}
</Button>
{props.onSearch && (
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
)}
{props.viewToggle}
{viewOptions}
{(!props.inlineActions || props.stats != null) && (
<div className='mt-2 flex min-w-0 flex-wrap items-center gap-2'>
{props.stats}
{!props.inlineActions && desktopActions}
</div>
</div>
)}
</div>
)
}
+6 -2
View File
@@ -276,8 +276,11 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const primarySearch =
props.customSearch !== undefined ? props.customSearch : searchInput
const useWidePrimarySearch =
filters.length + (props.additionalSearch != null ? 1 : 0) <= 3
const additionalFilterCount =
filters.length + (props.additionalSearch != null ? 1 : 0)
const inlineActions =
additionalFilterCount <= 3 && !hasExpandable && props.leftActions == null
const useWidePrimarySearch = !inlineActions && additionalFilterCount <= 3
const secondaryMobileFilters =
props.additionalSearch != null ||
filterChips.some(Boolean) ||
@@ -320,6 +323,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
searchLoading={props.searchLoading}
onReset={handleReset}
onSearch={hasSearch ? props.onSearch : undefined}
inlineActions={inlineActions}
className={props.className}
/>
)
+5 -1
View File
@@ -28,13 +28,17 @@ import { cn } from '@/lib/utils'
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof ShadcnTabsList>) {
return (
<ShadcnTabsList
data-control-size='default'
variant={variant}
className={cn(
'group-data-horizontal/tabs:h-7 sm:group-data-horizontal/tabs:h-8',
variant === 'line'
? 'group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'
: 'group-data-horizontal/tabs:h-7 sm:group-data-horizontal/tabs:h-8',
className
)}
{...props}
+2
View File
@@ -71,6 +71,8 @@ export function Dialog({
{trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent
className={cn(
// Default width is sm:max-w-2xl. Override with `sm:max-w-*` in
// contentClassName (bare `max-w-*` will not replace the sm: default).
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
contentClassName,
dialogContentMotionClassName
+4 -1
View File
@@ -22,13 +22,16 @@ import { cn } from '@/lib/utils'
export const sideDrawerContentClassName = (className?: string) =>
cn(
// Width: pass `sm:max-w-*` (or `sm:max-w-none`) in className. SheetContent
// defaults to `sm:max-w-sm` for left/right; plain utilities merge correctly.
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
className
)
export const sideDrawerHeaderClassName = (className?: string) =>
cn(
'border-border/70 bg-background/95 border-b px-4 py-3 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4',
// pr-12 reserves space for SheetContent's absolute close button
'border-border/70 bg-background/95 border-b px-4 py-3 pr-12 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4 sm:pr-14',
className
)
@@ -0,0 +1,67 @@
/*
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 type { ReactNode } from 'react'
import { PageTransition } from '@/components/page-transition'
import { cn } from '@/lib/utils'
/** Shared shell for public catalog pages (pricing, rankings, …). */
export const PUBLIC_PAGE_SHELL_CLASS =
'mx-auto w-full max-w-7xl px-4 pt-24 pb-12 sm:px-6 lg:px-8'
export interface PublicPageShellProps {
children: ReactNode
className?: string
}
export function PublicPageShell(props: PublicPageShellProps) {
return (
<PageTransition className={cn(PUBLIC_PAGE_SHELL_CLASS, props.className)}>
{props.children}
</PageTransition>
)
}
export interface PublicPageHeaderProps {
title: ReactNode
description?: ReactNode
/** Full-width slot under the title block (tabs, filters, meta). */
children?: ReactNode
className?: string
}
/**
* Shared page header for public catalog surfaces.
* Title follows the product page-title contract: text-lg / semibold / tight.
*/
export function PublicPageHeader(props: PublicPageHeaderProps) {
return (
<header className={cn('mb-8 space-y-6', props.className)}>
<div className='max-w-3xl'>
<h1 className='text-lg font-semibold tracking-tight'>{props.title}</h1>
{props.description != null && props.description !== '' && (
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
{props.description}
</p>
)}
</div>
{props.children}
</header>
)
}
+5
View File
@@ -26,6 +26,11 @@ export { AppSidebar } from './components/app-sidebar'
export { AuthenticatedLayout } from './components/authenticated-layout'
export { PublicLayout } from './components/public-layout'
export { PublicHeader } from './components/public-header'
export {
PublicPageHeader,
PublicPageShell,
PUBLIC_PAGE_SHELL_CLASS,
} from './components/public-page-header'
export { PublicNavigation } from './components/public-navigation'
export { HeaderLogo } from './components/header-logo'
export { NavLinkItem, NavLinkList } from './components/nav-link-item'
+1 -1
View File
@@ -183,7 +183,7 @@ export function RiskAcknowledgementDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent
className={cn(
'flex max-h-[min(88dvh,760px)] w-[calc(100vw-1.5rem)] !max-w-[44rem] grid-rows-none flex-col gap-0 overflow-hidden !p-0 sm:w-[min(44rem,calc(100vw-3rem))]',
'flex max-h-[min(88dvh,760px)] w-[calc(100vw-1.5rem)] max-w-[44rem] grid-rows-none flex-col gap-0 overflow-hidden p-0 sm:w-[min(44rem,calc(100vw-3rem))] sm:max-w-[44rem]',
className
)}
>
+7 -1
View File
@@ -63,6 +63,11 @@ function AlertDialogContent({
}: AlertDialogPrimitive.Popup.Props & {
size?: 'default' | 'sm'
}) {
// Apply size max-width as plain utilities so callers can override with
// `sm:max-w-*` / `max-w-*` via tailwind-merge. Upstream uses data-[size]
// selectors that silently clamp custom widths (e.g. conflict confirm).
const sizeMaxWidthClass = size === 'sm' ? 'max-w-xs' : 'max-w-xs sm:max-w-sm'
return (
<AlertDialogPortal>
<AlertDialogOverlay />
@@ -70,7 +75,8 @@ function AlertDialogContent({
data-slot='alert-dialog-content'
data-size={size}
className={cn(
'group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm 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',
'group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none 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',
sizeMaxWidthClass,
className
)}
{...props}
+9 -1
View File
@@ -65,6 +65,13 @@ function SheetContent({
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
// Apply default side max-width as a plain utility (not data-[side]-scoped) so
// callers can override with `sm:max-w-*` via tailwind-merge. Upstream shadcn
// uses `data-[side=right]:sm:max-w-sm`, which wins over custom widths by
// specificity and silently clamps wide drawers (e.g. channel mutate).
const sideMaxWidthClass =
side === 'left' || side === 'right' ? 'sm:max-w-sm' : undefined
return (
<SheetPortal>
<SheetOverlay />
@@ -72,7 +79,8 @@ function SheetContent({
data-slot='sheet-content'
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem]',
sideMaxWidthClass,
className
)}
{...props}
+1 -1
View File
@@ -206,7 +206,7 @@ function Sidebar({
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-none p-0 sm:max-w-none [&>button]:hidden'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
+1 -1
View File
@@ -77,7 +77,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-0 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className
)}
{...props}