♻️ 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:
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)}
|
||||
>
|
||||
|
||||
+36
-29
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user