♻️ refactor(web): consolidate design-system primitives and responsive data views
This commit is contained in:
+3
-3
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -48,13 +48,13 @@ export const Action = ({
|
||||
label,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
size = 'icon',
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground relative size-9 p-1.5',
|
||||
'text-muted-foreground hover:text-foreground relative',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
|
||||
+5
-11
@@ -22,7 +22,7 @@ import { type LucideIcon, XIcon } from 'lucide-react'
|
||||
import type { ComponentProps, HTMLAttributes } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -63,17 +63,14 @@ export type ArtifactCloseProps = ComponentProps<typeof Button>
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = 'sm',
|
||||
size = 'icon',
|
||||
variant = 'ghost',
|
||||
...props
|
||||
}: ArtifactCloseProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground size-8 p-0',
|
||||
className
|
||||
)}
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
@@ -124,16 +121,13 @@ export const ArtifactAction = ({
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = 'sm',
|
||||
size = 'icon',
|
||||
variant = 'ghost',
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground size-8 p-0',
|
||||
className
|
||||
)}
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
|
||||
+5
-5
@@ -32,7 +32,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BranchContextType = {
|
||||
@@ -178,14 +178,14 @@ export const BranchPrevious = ({
|
||||
<Button
|
||||
aria-label={t('Previous branch')}
|
||||
className={cn(
|
||||
'text-muted-foreground size-7 shrink-0 transition-colors',
|
||||
'text-muted-foreground shrink-0 transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
@@ -209,14 +209,14 @@ export const BranchNext = ({
|
||||
<Button
|
||||
aria-label={t('Next branch')}
|
||||
className={cn(
|
||||
'text-muted-foreground size-7 shrink-0 transition-colors',
|
||||
'text-muted-foreground shrink-0 transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
|
||||
+5
-7
@@ -46,7 +46,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage } from 'shiki'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -507,9 +507,8 @@ export const CodeBlock = ({
|
||||
render={
|
||||
<Button
|
||||
aria-label={isCodeCollapsed ? t('Expand') : t('Collapse')}
|
||||
className='size-8'
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
size='icon-sm'
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
@@ -532,9 +531,8 @@ export const CodeBlock = ({
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
className='size-8'
|
||||
onClick={downloadCode}
|
||||
size='icon-sm'
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
@@ -641,9 +639,9 @@ export const CodeBlockCopyButton = ({
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon-sm'
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ import {
|
||||
useContext,
|
||||
} from 'react'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Workaround for missing types in 'ai' package
|
||||
@@ -169,5 +169,5 @@ export const ConfirmationActions = ({
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className='h-8 px-3 text-sm' type='button' {...props} />
|
||||
<Button type='button' {...props} />
|
||||
)
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUsage } from 'tokenlens'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { type ComponentProps, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import {
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ import { ChevronsUpDownIcon } from 'lucide-react'
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
@@ -156,7 +156,7 @@ export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
className={cn('size-8', className)}
|
||||
className={cn(className)}
|
||||
data-slot='plan-trigger'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
|
||||
+16
-15
@@ -55,7 +55,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -64,7 +64,20 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
} from '@/components/design-system/command'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '@/components/design-system/input-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -76,19 +89,6 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '@/components/ui/input-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ============================================================================
|
||||
@@ -326,6 +326,7 @@ export function PromptInputAttachment({
|
||||
</div>
|
||||
<Button
|
||||
aria-label={t('Remove attachment')}
|
||||
size='icon-xs'
|
||||
className='absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { ChevronDownIcon, PaperclipIcon } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -236,7 +236,7 @@ export const QueueSectionTrigger = ({
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left',
|
||||
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto sm:h-auto w-full justify-between px-3 py-2 text-left',
|
||||
className
|
||||
)}
|
||||
type='button'
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
+5
-5
@@ -29,13 +29,13 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -135,10 +135,10 @@ export const WebPreviewNavigationButton = ({
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
className='hover:text-foreground h-8 w-8 p-0'
|
||||
className='hover:text-foreground'
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size='sm'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
/>
|
||||
@@ -185,7 +185,7 @@ export const WebPreviewUrl = ({
|
||||
|
||||
return (
|
||||
<Input
|
||||
className='h-8 flex-1 text-sm'
|
||||
className='flex-1 text-sm'
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('Enter URL...')}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
} from '@/components/design-system/command'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
|
||||
+3
-5
@@ -32,13 +32,14 @@ import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
|
||||
import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
|
||||
import { IconThemeLight } from '@/assets/custom/icon-theme-light'
|
||||
import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { useSidebar } from '@/components/design-system/sidebar'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -62,8 +63,6 @@ import {
|
||||
} from '@/lib/theme-customization'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
const Item = RadioPrimitive.Root
|
||||
|
||||
export function ConfigDrawer() {
|
||||
@@ -145,9 +144,8 @@ function SectionTitle(props: {
|
||||
{props.title}
|
||||
{props.showReset && props.onReset && (
|
||||
<Button
|
||||
size='icon'
|
||||
size='icon-xs'
|
||||
variant='secondary'
|
||||
className='size-4'
|
||||
onClick={props.onReset}
|
||||
aria-label='Reset'
|
||||
>
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
} from '@/components/design-system/alert-dialog'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ import { Check, Copy } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -35,7 +35,7 @@ interface CopyButtonProps {
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
variant?: 'ghost' | 'outline' | 'default' | 'secondary' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-xs'
|
||||
tooltip?: string
|
||||
successTooltip?: string
|
||||
'aria-label'?: string
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -58,8 +58,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='data-popup-open:bg-accent -ms-3 h-8'
|
||||
className='data-popup-open:bg-accent -ms-3'
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function isContentSizedColumn(columnId: string): boolean {
|
||||
return columnId === 'actions'
|
||||
export function isContentSizedColumn(
|
||||
columnId: string,
|
||||
contentSized = false
|
||||
): boolean {
|
||||
return contentSized || columnId === 'actions'
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ export function DataTableColgroup<TData>({
|
||||
}) {
|
||||
const columns = table.getVisibleLeafColumns()
|
||||
const sizedColumns = columns.filter(
|
||||
(column) => !isContentSizedColumn(column.id)
|
||||
(column) =>
|
||||
!isContentSizedColumn(column.id, column.columnDef.meta?.contentSized)
|
||||
)
|
||||
const totalSize = sizedColumns.reduce((sum, col) => sum + col.getSize(), 0)
|
||||
|
||||
@@ -38,7 +39,8 @@ export function DataTableColgroup<TData>({
|
||||
table,
|
||||
column.id,
|
||||
column.getSize(),
|
||||
totalSize
|
||||
totalSize,
|
||||
column.columnDef.meta?.contentSized
|
||||
)
|
||||
|
||||
return <col key={column.id} style={{ width }} />
|
||||
@@ -51,9 +53,10 @@ function getColumnWidth<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
columnId: string,
|
||||
columnSize: number,
|
||||
totalSize: number
|
||||
totalSize: number,
|
||||
contentSized?: boolean
|
||||
) {
|
||||
if (isContentSizedColumn(columnId)) {
|
||||
if (isContentSizedColumn(columnId, contentSized)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/design-system/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableColumnHeader } from './column-header'
|
||||
@@ -245,7 +249,10 @@ function shouldRenderColumnResizer<TData>(
|
||||
table.options.enableColumnResizing === true &&
|
||||
!header.isPlaceholder &&
|
||||
header.column.getCanResize() &&
|
||||
!isContentSizedColumn(header.column.id)
|
||||
!isContentSizedColumn(
|
||||
header.column.id,
|
||||
header.column.columnDef.meta?.contentSized
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,7 +260,13 @@ function getHeaderSizeStyle<TData>(
|
||||
header: Header<TData, unknown>,
|
||||
applyHeaderSize: boolean | undefined
|
||||
) {
|
||||
if (!applyHeaderSize || isContentSizedColumn(header.column.id)) {
|
||||
if (
|
||||
!applyHeaderSize ||
|
||||
isContentSizedColumn(
|
||||
header.column.id,
|
||||
header.column.columnDef.meta?.contentSized
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
+12
-62
@@ -16,25 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { TableCell, TableRow } from '@/components/design-system/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TruncatedCell } from './truncated-cell'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
cellRenderColumns?: TanstackTable<TData>['options']['columns']
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & {
|
||||
@@ -46,13 +39,8 @@ function DataTableRowInner<TData>({
|
||||
isSelected,
|
||||
className,
|
||||
getColumnClassName,
|
||||
cellRenderColumns,
|
||||
...rowProps
|
||||
}: DataTableRowInnerProps<TData>) {
|
||||
// Destructured only to keep it out of `rowProps` (it is not a valid DOM attr)
|
||||
// and to feed the memo comparator below; it is intentionally unused here.
|
||||
void cellRenderColumns
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
@@ -60,19 +48,25 @@ function DataTableRowInner<TData>({
|
||||
{...rowProps}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const renderedCell = renderCellContent(cell)
|
||||
const contentMode = cell.column.columnDef.meta?.contentMode ?? 'wrap'
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-column-id={cell.column.id}
|
||||
data-content-mode={contentMode}
|
||||
className={cn(
|
||||
'max-w-full min-w-0',
|
||||
renderedCell.isPrimitive && 'overflow-hidden',
|
||||
contentMode === 'full' &&
|
||||
'max-w-none overflow-visible [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_[data-slot=status-badge]]:max-w-none [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip',
|
||||
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',
|
||||
contentMode === 'summary' &&
|
||||
'whitespace-normal break-words [overflow-wrap:anywhere]',
|
||||
getColumnClassName?.(cell.column.id, 'cell')
|
||||
)}
|
||||
>
|
||||
{renderedCell.content}
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
@@ -80,50 +74,6 @@ function DataTableRowInner<TData>({
|
||||
)
|
||||
}
|
||||
|
||||
const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => {
|
||||
// Do not read row.getIsSelected() inside the comparator: TanStack row objects
|
||||
// keep a stable reference while their selection state mutates, so reading it
|
||||
// here compares identical live values and misses selection changes. Selection
|
||||
// is lifted to the `isSelected` prop, captured per render in DataTableRow.
|
||||
//
|
||||
// Column cell renderers (and getColumnClassName) can close over external
|
||||
// state while the row stays stable, so column definitions and the class
|
||||
// resolver are part of the render identity and must be compared too.
|
||||
return (
|
||||
prev.row === next.row &&
|
||||
prev.className === next.className &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.getColumnClassName === next.getColumnClassName &&
|
||||
prev.cellRenderColumns === next.cellRenderColumns
|
||||
)
|
||||
}) as typeof DataTableRowInner
|
||||
|
||||
export function DataTableRow<TData>(props: DataTableRowProps<TData>) {
|
||||
return (
|
||||
<MemoizedDataTableRow {...props} isSelected={props.row.getIsSelected()} />
|
||||
)
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>) {
|
||||
const content = flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) {
|
||||
return { content, isPrimitive: false }
|
||||
}
|
||||
|
||||
return {
|
||||
content: (
|
||||
<TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
),
|
||||
isPrimitive: true,
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
return null
|
||||
return <DataTableRowInner {...props} isSelected={props.row.getIsSelected()} />
|
||||
}
|
||||
|
||||
+22
-10
@@ -19,7 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@/components/design-system/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
@@ -49,10 +54,7 @@ export { DataTableRowActionMenu } from './row-action-menu'
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
const rows = props.rows ?? props.table.getRowModel().rows
|
||||
const colSpan = React.useMemo(
|
||||
() => props.table.getVisibleLeafColumns().length,
|
||||
[props.table]
|
||||
)
|
||||
const colSpan = props.table.getVisibleLeafColumns().length
|
||||
const columnClassName = useResolvedColumnClassName(
|
||||
props.table,
|
||||
props.getColumnClassName,
|
||||
@@ -62,7 +64,7 @@ export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border',
|
||||
'w-full min-w-0 overflow-hidden rounded-lg border',
|
||||
props.containerClassName
|
||||
)}
|
||||
{...props.containerProps}
|
||||
@@ -101,7 +103,15 @@ function UnifiedTableView<TData>({
|
||||
|
||||
return (
|
||||
<div className={props.tableContainerClassName}>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
<Table
|
||||
className={props.tableClassName}
|
||||
style={tableSizing.style}
|
||||
containerProps={{
|
||||
role: props.scrollLabel ? 'region' : undefined,
|
||||
tabIndex: 0,
|
||||
'aria-label': props.scrollLabel,
|
||||
}}
|
||||
>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
@@ -138,12 +148,15 @@ function SplitHeaderTableView<TData>({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-auto',
|
||||
'min-h-0 min-w-0 flex-1 overscroll-contain overflow-auto',
|
||||
'**:data-[slot=table-header]:[--table-header-bg:var(--table-header)]',
|
||||
'**:data-[slot=table-header]:bg-(--table-header-bg)',
|
||||
props.splitHeaderScrollClassName,
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
role={props.scrollLabel ? 'region' : undefined}
|
||||
tabIndex={0}
|
||||
aria-label={props.scrollLabel}
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
@@ -157,7 +170,7 @@ function SplitHeaderTableView<TData>({
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={cn('sticky top-0 z-10', props.tableHeaderClassName)}
|
||||
className={cn('sticky top-0 z-20', props.tableHeaderClassName)}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
@@ -325,7 +338,6 @@ function renderDefaultRow<TData>(
|
||||
row={row}
|
||||
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
|
||||
getColumnClassName={getColumnClassName}
|
||||
cellRenderColumns={props.table.options.columns}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-8
@@ -25,7 +25,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
} from '@/components/design-system/select'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
@@ -83,7 +83,7 @@ export function DataTablePagination<TData>({
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
|
||||
<SelectTrigger className='text-foreground w-[64px] font-medium tabular-nums sm:w-[70px]'>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top' alignItemWithTrigger={false}>
|
||||
@@ -100,8 +100,9 @@ export function DataTablePagination<TData>({
|
||||
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
@@ -109,8 +110,9 @@ export function DataTablePagination<TData>({
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
@@ -128,7 +130,7 @@ export function DataTablePagination<TData>({
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-8 min-w-8 px-2 tabular-nums',
|
||||
'min-w-8 px-2 tabular-nums',
|
||||
currentPage === pageNumber
|
||||
? 'font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
@@ -145,8 +147,9 @@ export function DataTablePagination<TData>({
|
||||
))}
|
||||
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
@@ -154,8 +157,9 @@ export function DataTablePagination<TData>({
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -42,7 +42,7 @@ export function DataTableRowActionMenu(props: DataTableRowActionMenuProps) {
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
className='data-popup-open:bg-muted'
|
||||
aria-label={props.ariaLabel}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TableRow, TableCell } from '@/components/design-system/table'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
|
||||
interface TableEmptyProps {
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,10 @@ export function getTableSizeStyle<TData>(
|
||||
): React.CSSProperties {
|
||||
const width = table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((column) => !isContentSizedColumn(column.id))
|
||||
.filter(
|
||||
(column) =>
|
||||
!isContentSizedColumn(column.id, column.columnDef.meta?.contentSized)
|
||||
)
|
||||
.reduce((total, column) => total + column.getSize(), 0)
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { TableRow, TableCell } from '@/components/design-system/table'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SKELETON_WIDTHS = [
|
||||
|
||||
@@ -50,7 +50,7 @@ export function TruncatedCell({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
'block max-w-full min-w-0 whitespace-normal break-words [overflow-wrap:anywhere]',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
@@ -64,7 +64,8 @@ export function TruncatedCell({
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
<span
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
@@ -73,7 +74,9 @@ export function TruncatedCell({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={cn('truncate', contentClassName)}>{children}</div>
|
||||
<span className={cn('block truncate', contentClassName)}>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
|
||||
@@ -42,6 +42,7 @@ export type DataTableViewProps<TData> = {
|
||||
rows?: Row<TData>[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
scrollLabel?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
emptyContent?: React.ReactNode
|
||||
|
||||
+15
-1
@@ -23,6 +23,12 @@ 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 {
|
||||
DataTableFilterField,
|
||||
DataTableFilterInput,
|
||||
DataTableFilterPanel,
|
||||
type DataTableFilterPanelProps,
|
||||
} from './toolbar/filter-panel'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
export {
|
||||
StaticDataTable,
|
||||
@@ -38,13 +44,21 @@ export {
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from './core/data-table-view'
|
||||
export { MobileCardList } from './layout/mobile-card-list'
|
||||
export {
|
||||
MobileCardList,
|
||||
type MobileCardListProps,
|
||||
} from './layout/mobile-card-list'
|
||||
export {
|
||||
DataTableCardGrid,
|
||||
type DataTableCardGridProps,
|
||||
type DataTableCardHelpers,
|
||||
} from './layout/card-grid'
|
||||
export { CardRowContent } from './layout/card-row-content'
|
||||
export {
|
||||
DataTableCardDetails,
|
||||
DataTableCardField,
|
||||
type DataTableContentMode,
|
||||
} from './layout/card-field'
|
||||
export { tableHasCompactMeta } from './layout/card-cell-utils'
|
||||
export {
|
||||
DataTablePage,
|
||||
|
||||
@@ -46,14 +46,10 @@ export function renderCellContent<TData>(
|
||||
return cell.getValue() as ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any visible column declares `mobileTitle`/`mobileBadge` meta. When
|
||||
* true the compact two-tier layout is used; otherwise the condensed
|
||||
* label:value fallback layout is used.
|
||||
*/
|
||||
/** Whether visible columns declare a title/status card hierarchy. */
|
||||
export function tableHasCompactMeta<TData>(table: Table<TData>): boolean {
|
||||
return table.getVisibleLeafColumns().some((col) => {
|
||||
const meta = col.columnDef.meta
|
||||
return Boolean(meta?.mobileTitle || meta?.mobileBadge)
|
||||
return meta?.cardRole === 'title' || meta?.cardRole === 'badge'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
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 { ChevronDown } from 'lucide-react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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'
|
||||
|
||||
interface DataTableCardFieldProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentMode?: DataTableContentMode
|
||||
label?: ReactNode
|
||||
span?: 1 | 2
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export function DataTableCardField({
|
||||
children,
|
||||
className,
|
||||
contentMode = 'wrap',
|
||||
label,
|
||||
span = 1,
|
||||
valueClassName,
|
||||
}: DataTableCardFieldProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='data-table-card-field'
|
||||
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'>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-slot='data-table-card-value'
|
||||
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',
|
||||
contentMode === 'full' && 'break-all',
|
||||
valueClassName
|
||||
)}
|
||||
>
|
||||
{children ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataTableCardDetailsProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
count?: number
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export function DataTableCardDetails({
|
||||
children,
|
||||
className,
|
||||
count,
|
||||
defaultOpen = false,
|
||||
}: DataTableCardDetailsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={cn('mt-2', 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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import { CardRowContent } from './card-row-content'
|
||||
/** Helpers passed to a custom {@link DataTableCardGridProps.renderCard}. */
|
||||
export type DataTableCardHelpers = {
|
||||
/**
|
||||
* Whether the table declares compact card meta (`mobileTitle`/`mobileBadge`).
|
||||
* Whether the table declares title/status `cardRole` metadata.
|
||||
* Provided so custom renderers can match the default layout decision.
|
||||
*/
|
||||
compact: boolean
|
||||
@@ -53,6 +53,7 @@ export interface DataTableCardGridProps<TData> {
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
/**
|
||||
@@ -151,6 +152,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{props.emptyAction}
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
|
||||
+95
-169
@@ -17,18 +17,24 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Cell, Row } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
|
||||
import { getCellLabel, renderCellContent } from './card-cell-utils'
|
||||
import { DataTableCardDetails, DataTableCardField } from './card-field'
|
||||
|
||||
type CardRole = 'title' | 'badge' | 'primary' | 'secondary' | 'hidden'
|
||||
|
||||
function getCardRole<TData>(cell: Cell<TData, unknown>): CardRole {
|
||||
const meta = cell.column.columnDef.meta
|
||||
if (meta?.cardRole) return meta.cardRole
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function orderCardCells<TData>(
|
||||
cells: Cell<TData, unknown>[]
|
||||
): Cell<TData, unknown>[] {
|
||||
return [...cells].sort((a, b) => {
|
||||
const aOrder = a.column.columnDef.meta?.mobileOrder
|
||||
const bOrder = b.column.columnDef.meta?.mobileOrder
|
||||
const aOrder = a.column.columnDef.meta?.cardOrder
|
||||
const bOrder = b.column.columnDef.meta?.cardOrder
|
||||
|
||||
if (aOrder == null && bOrder == null) return 0
|
||||
if (aOrder == null) return 1
|
||||
@@ -37,178 +43,98 @@ function orderCardCells<TData>(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared, column-meta-driven card content rendering for TanStack rows.
|
||||
*
|
||||
* Both {@link MobileCardList} (mobile) and {@link DataTableCardGrid} (desktop
|
||||
* card view) render the same inner content; only the surrounding container
|
||||
* differs (single bordered list vs. responsive grid of cards). Keeping the
|
||||
* per-row content here guarantees the two stay visually consistent.
|
||||
*
|
||||
* Column meta extensions (see `card-cell-utils.ts`):
|
||||
* - `mobileTitle` — card header (left, larger text)
|
||||
* - `mobileBadge` — inline with title (right, e.g. status badge)
|
||||
* - `mobileHidden` — hidden in card content
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compact content — structured layout with title header + side-by-side fields.
|
||||
* Used when columns define mobileTitle or mobileBadge meta.
|
||||
*
|
||||
* Visual structure:
|
||||
* [Title content] [Badge]
|
||||
* [Field1 label] [Field2 label]
|
||||
* [Field1 value] [Field2 value]
|
||||
* [Actions ⋯]
|
||||
*/
|
||||
function CompactContent<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
// Read each cell's meta once, then reuse for all categorisation checks.
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
|
||||
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const fieldCells = orderCardCells(
|
||||
allCells.filter(
|
||||
(c, i) =>
|
||||
c !== titleCell &&
|
||||
c !== badgeCell &&
|
||||
c !== actionsCell &&
|
||||
!cellMetas[i]?.mobileHidden
|
||||
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>
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: Title + Badge */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
|
||||
{renderCellContent(titleCell)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
|
||||
{renderCellContent(badgeCell)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Key fields wrap into compact columns instead of squeezing */}
|
||||
{fieldCells.length > 0 && (
|
||||
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
|
||||
{fieldCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
return (
|
||||
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
|
||||
{label && (
|
||||
<div className='text-muted-foreground mb-0.5 text-xs leading-none select-none'>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden text-xs'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{actionsCell && (
|
||||
<div className='mt-1 -mb-0.5 flex justify-end'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback content — condensed label:value pairs for tables without
|
||||
* mobileTitle/mobileBadge. Still respects mobileHidden.
|
||||
*/
|
||||
function FallbackContent<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const contentCells = orderCardCells(
|
||||
allCells.filter(
|
||||
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div key={cell.id} className='flex justify-end overflow-hidden'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell)}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className='flex items-start justify-between gap-2 overflow-hidden'
|
||||
>
|
||||
<span className='text-muted-foreground shrink-0 text-xs font-medium select-none'>
|
||||
{label}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{actionsCell && (
|
||||
<div className='-mb-0.5 flex justify-end pt-0.5'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single row's card content, auto-selecting the compact or fallback
|
||||
* layout. Callers compute `compact` once per table (via `tableHasCompactMeta`)
|
||||
* and pass it down to avoid recomputation per row.
|
||||
* 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.
|
||||
*/
|
||||
export function CardRowContent<TData>(props: {
|
||||
row: Row<TData>
|
||||
compact: boolean
|
||||
}) {
|
||||
return props.compact ? (
|
||||
<CompactContent row={props.row} />
|
||||
) : (
|
||||
<FallbackContent row={props.row} />
|
||||
const cells = props.row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
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(
|
||||
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'
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{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'>
|
||||
{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'
|
||||
>
|
||||
{renderCellContent(badgeCell)}
|
||||
</DataTableCardField>
|
||||
)}
|
||||
</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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secondaryCells.length > 0 && (
|
||||
<DataTableCardDetails count={secondaryCells.length}>
|
||||
<CardFields cells={secondaryCells} />
|
||||
</DataTableCardDetails>
|
||||
)}
|
||||
|
||||
{actionsCell && (
|
||||
<div className='mt-2 -mb-0.5 flex justify-end border-t pt-2'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ export type DataTablePageProps<TData> = {
|
||||
*/
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
|
||||
/**
|
||||
* Localized accessible name for the keyboard-scrollable table region.
|
||||
*/
|
||||
tableLabel?: string
|
||||
|
||||
/**
|
||||
* Initial loading state — renders {@link TableSkeleton} or mobile skeleton.
|
||||
*/
|
||||
@@ -319,6 +324,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full min-w-0',
|
||||
props.fixedHeight !== false
|
||||
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
|
||||
: 'space-y-2.5 sm:space-y-3',
|
||||
@@ -406,6 +412,7 @@ function renderMobile<TData>(
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
scrollLabel={props.tableLabel}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
@@ -417,11 +424,7 @@ function renderMobile<TData>(
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
containerClassName={props.tableClassName}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
@@ -435,6 +438,7 @@ function renderMobile<TData>(
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
renderCard={props.renderCard}
|
||||
gridClassName={props.cardGridClassName ?? 'grid grid-cols-1 gap-3'}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
@@ -449,6 +453,8 @@ function renderMobile<TData>(
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
getRowKey={props.mobileProps?.getRowKey}
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
@@ -456,7 +462,17 @@ function renderMobile<TData>(
|
||||
}
|
||||
}
|
||||
|
||||
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
|
||||
return (
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{mobileContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDesktop<TData>(
|
||||
@@ -478,8 +494,9 @@ function renderDesktop<TData>(
|
||||
className={cn(
|
||||
fixedHeight && 'min-h-0 flex-1 overflow-y-auto',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60'
|
||||
isFetchingOnly && 'opacity-60'
|
||||
)}
|
||||
aria-busy={props.isLoading || props.isFetching || undefined}
|
||||
>
|
||||
<DataTableCardGrid
|
||||
table={props.table}
|
||||
@@ -487,6 +504,7 @@ function renderDesktop<TData>(
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
renderCard={props.renderCard}
|
||||
gridClassName={props.cardGridClassName}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
@@ -504,6 +522,7 @@ function renderDesktop<TData>(
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
scrollLabel={props.tableLabel}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
@@ -520,9 +539,12 @@ function renderDesktop<TData>(
|
||||
containerClassName={cn(
|
||||
fixedHeight && 'min-h-0 flex-1',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
isFetchingOnly && 'opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
containerProps={{
|
||||
'aria-busy': props.isLoading || props.isFetching || undefined,
|
||||
}}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
|
||||
@@ -34,13 +34,16 @@ import { cn } from '@/lib/utils'
|
||||
import { tableHasCompactMeta } from './card-cell-utils'
|
||||
import { CardRowContent } from './card-row-content'
|
||||
|
||||
interface MobileCardListProps<TData> {
|
||||
export interface MobileCardListProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
renderCard?: (row: Row<TData>) => React.ReactNode
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
@@ -93,7 +96,7 @@ function FallbackListSkeleton() {
|
||||
*
|
||||
* Per-row content is shared with the desktop card view via
|
||||
* {@link CardRowContent}; see `card-row-content.tsx` for the column-meta
|
||||
* extensions (`mobileTitle`, `mobileBadge`, `mobileHidden`).
|
||||
* extensions (`cardRole`, `cardOrder`, `cardSpan`, `contentMode`).
|
||||
*/
|
||||
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
const {
|
||||
@@ -128,18 +131,19 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
{props.emptyIcon ?? <Database className='size-6' />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{props.emptyAction}
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
<div className='min-w-0 divide-y overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const key = getRowKey ? getRowKey(row) : row.id
|
||||
return (
|
||||
@@ -150,7 +154,11 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
getRowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
<CardRowContent row={row} compact={hasCompactMeta} />
|
||||
{props.renderCard ? (
|
||||
props.renderCard(row)
|
||||
) : (
|
||||
<CardRowContent row={row} compact={hasCompactMeta} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/design-system/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TruncatedCell } from '../core/truncated-cell'
|
||||
|
||||
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
|
||||
@@ -21,8 +21,8 @@ import { X } from 'lucide-react'
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -184,9 +184,8 @@ export function DataTableBulkActions<TData>({
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
size='icon-xs'
|
||||
onClick={handleClearSelection}
|
||||
className='size-6'
|
||||
aria-label={t('Clear selection')}
|
||||
title={t('Clear selection (Escape)')}
|
||||
/>
|
||||
|
||||
@@ -16,13 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import type { Column } from '@tanstack/react-table'
|
||||
import { Check as CheckIcon, ChevronDown } from 'lucide-react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -31,13 +30,12 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
} from '@/components/design-system/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
@@ -46,15 +44,15 @@ type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconNode?: React.ReactNode
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
iconNode?: ReactNode
|
||||
count?: number
|
||||
}[]
|
||||
/** Enable single select mode (only one option can be selected at a time) */
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
function DataTableFacetedFilterInner<TData, TValue>({
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
@@ -64,6 +62,21 @@ function DataTableFacetedFilterInner<TData, TValue>({
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue() as string[] | undefined
|
||||
const selectedValues = new Set(filterValue)
|
||||
const resolvedTitle = title ?? t('Filter')
|
||||
const selectedOptions = options.filter((option) =>
|
||||
selectedValues.has(option.value)
|
||||
)
|
||||
|
||||
let selectedSummary: string | null = null
|
||||
if (selectedValues.size === 1 && selectedOptions.length === 1) {
|
||||
selectedSummary = t(selectedOptions[0].label)
|
||||
} else if (selectedValues.size === 2 && selectedOptions.length === 2) {
|
||||
selectedSummary = selectedOptions
|
||||
.map((option) => t(option.label))
|
||||
.join(', ')
|
||||
} else if (selectedValues.size > 0) {
|
||||
selectedSummary = `${selectedValues.size} ${t('selected')}`
|
||||
}
|
||||
|
||||
const handleOptionSelect = (optionValue: string) => {
|
||||
const nextSelectedValues = getNextSelectedValues(
|
||||
@@ -81,57 +94,69 @@ function DataTableFacetedFilterInner<TData, TValue>({
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant='outline' size='sm' className='h-8 border-dashed' />
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full min-w-0 justify-start font-normal'
|
||||
aria-label={
|
||||
selectedSummary
|
||||
? `${resolvedTitle}: ${selectedSummary}`
|
||||
: resolvedTitle
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PlusCircledIcon className='size-4' />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<span className='text-muted-foreground min-w-0 truncate text-left'>
|
||||
{resolvedTitle}
|
||||
</span>
|
||||
{selectedSummary && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal lg:hidden'
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className='hidden space-x-1 lg:flex'>
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{selectedValues.size} {t('selected')}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
key={option.value}
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{t(option.label)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<span className='text-muted-foreground' aria-hidden='true'>
|
||||
:
|
||||
</span>
|
||||
<span className='text-foreground min-w-0 flex-1 truncate text-left'>
|
||||
{selectedSummary}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown className='text-muted-foreground ms-auto size-3.5 shrink-0 transition-transform duration-150 group-data-[popup-open]/button:rotate-180' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='max-w-[360px] min-w-[200px] p-0' align='start'>
|
||||
<PopoverContent
|
||||
className='w-(--anchor-width) max-w-[360px] min-w-52 p-0'
|
||||
align='start'
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandInput
|
||||
placeholder={resolvedTitle}
|
||||
aria-label={resolvedTitle}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
let optionIcon: ReactNode = null
|
||||
if (option.iconNode) {
|
||||
optionIcon = (
|
||||
<span className='text-muted-foreground flex size-4 items-center justify-center'>
|
||||
{option.iconNode}
|
||||
</span>
|
||||
)
|
||||
} else if (option.icon) {
|
||||
optionIcon = (
|
||||
<option.icon className='text-muted-foreground size-4' />
|
||||
)
|
||||
}
|
||||
|
||||
const optionCount =
|
||||
typeof option.count === 'number'
|
||||
? option.count
|
||||
: facets?.get(option.value)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleOptionSelect(option.value)}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -141,30 +166,23 @@ function DataTableFacetedFilterInner<TData, TValue>({
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('text-background h-4 w-4')} />
|
||||
<CheckIcon
|
||||
className={cn('text-background h-4 w-4')}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
{option.iconNode ? (
|
||||
<span className='text-muted-foreground flex size-4 items-center justify-center'>
|
||||
{option.iconNode}
|
||||
</span>
|
||||
) : option.icon ? (
|
||||
<option.icon className='text-muted-foreground size-4' />
|
||||
) : null}
|
||||
{optionIcon}
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate'
|
||||
title={t(option.label)}
|
||||
>
|
||||
{t(option.label)}
|
||||
</span>
|
||||
{typeof option.count === 'number' ? (
|
||||
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center font-mono text-xs'>
|
||||
{option.count}
|
||||
{optionCount != null && (
|
||||
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center text-xs tabular-nums'>
|
||||
{optionCount}
|
||||
</span>
|
||||
) : facets?.get(option.value) ? (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
@@ -189,10 +207,6 @@ function DataTableFacetedFilterInner<TData, TValue>({
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTableFacetedFilter = React.memo(
|
||||
DataTableFacetedFilterInner
|
||||
) as typeof DataTableFacetedFilterInner
|
||||
|
||||
function getNextSelectedValues(
|
||||
selectedValues: Set<string>,
|
||||
optionValue: string,
|
||||
@@ -209,5 +223,5 @@ function getNextSelectedValues(
|
||||
nextSelectedValues.add(optionValue)
|
||||
}
|
||||
|
||||
return Array.from(nextSelectedValues)
|
||||
return [...nextSelectedValues]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
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 { Table } from '@tanstack/react-table'
|
||||
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
export interface DataTableFilterPanelProps<TData> {
|
||||
table: Table<TData>
|
||||
primaryFilters: ReactNode
|
||||
advancedFilters?: ReactNode
|
||||
mobilePinnedFilters?: ReactNode
|
||||
mobileFilters?: ReactNode
|
||||
mobileFilterCount?: number
|
||||
stats?: ReactNode
|
||||
actionStart?: ReactNode
|
||||
viewToggle?: ReactNode
|
||||
hideViewOptions?: boolean
|
||||
hasActiveFilters: boolean
|
||||
hasAdvancedActiveFilters?: boolean
|
||||
advancedFilterCount?: number
|
||||
searchLoading?: boolean
|
||||
onReset: () => void
|
||||
onSearch?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface DataTableFilterFieldProps {
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTableFilterField(props: DataTableFilterFieldProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 [&>button]:w-full [&>input]:w-full [&_[data-slot=select-trigger]]:w-full [&_[data-slot=select-trigger]]:text-sm [&_[data-slot=select-value]]:leading-5',
|
||||
props.wide && 'sm:col-span-2',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTableFilterInput(props: ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
className={cn('min-w-0 text-sm leading-5', props.className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTableFilterPanel<TData>(
|
||||
props: DataTableFilterPanelProps<TData>
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const hasAdvancedFilters = props.advancedFilters != null
|
||||
const hasMobileFilters =
|
||||
props.mobileFilters != null || props.advancedFilters != null
|
||||
const activeAdvancedCount =
|
||||
props.advancedFilterCount ?? (props.hasAdvancedActiveFilters ? 1 : 0)
|
||||
const activeMobileFilterCount = props.mobileFilterCount ?? activeAdvancedCount
|
||||
|
||||
const handleMobileReset = () => {
|
||||
props.onReset()
|
||||
setMobileFiltersOpen(false)
|
||||
}
|
||||
|
||||
const handleMobileSubmit = () => {
|
||||
props.onSearch?.()
|
||||
setMobileFiltersOpen(false)
|
||||
}
|
||||
|
||||
const advancedToggle = hasAdvancedFilters ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
aria-expanded={advancedOpen}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
props.hasAdvancedActiveFilters &&
|
||||
!advancedOpen &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{advancedOpen ? t('Collapse') : t('Expand')}
|
||||
{activeAdvancedCount > 0 && (
|
||||
<Badge variant='secondary' className='ml-0.5'>
|
||||
{activeAdvancedCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const viewOptions = !props.hideViewOptions ? (
|
||||
<DataTableViewOptions table={props.table} />
|
||||
) : null
|
||||
|
||||
if (isMobile && props.mobilePinnedFilters != null) {
|
||||
return (
|
||||
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card/50 min-w-0 rounded-lg border p-2.5',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='grid min-w-0 gap-2'>{props.mobilePinnedFilters}</div>
|
||||
|
||||
<div className='mt-2 flex min-w-0 flex-col gap-2'>
|
||||
{props.stats}
|
||||
<div className='flex flex-wrap items-center justify-end gap-1.5'>
|
||||
{props.actionStart}
|
||||
{hasMobileFilters && (
|
||||
<DrawerTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
activeMobileFilterCount > 0 &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{t('Filter')}
|
||||
{activeMobileFilterCount > 0 && (
|
||||
<Badge variant='secondary' className='ml-0.5'>
|
||||
{activeMobileFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!hasMobileFilters && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={props.onReset}
|
||||
disabled={!props.hasActiveFilters}
|
||||
className='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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasMobileFilters && (
|
||||
<DrawerContent className='max-h-[85dvh] p-0'>
|
||||
<div className='mx-auto flex w-full max-w-md flex-1 flex-col overflow-hidden'>
|
||||
<DrawerHeader className='border-border/70 border-b px-4 py-3 text-left'>
|
||||
<DrawerTitle>{t('Filter')}</DrawerTitle>
|
||||
<DrawerDescription>{t('Filters')}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 py-3'>
|
||||
{props.mobileFilters ?? (
|
||||
<>
|
||||
{props.primaryFilters}
|
||||
{props.advancedFilters}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DrawerFooter className='border-border/70 grid grid-cols-2 gap-2 border-t px-4 py-3'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleMobileReset}
|
||||
disabled={!props.hasActiveFilters}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleMobileSubmit}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{props.onSearch ? t('Search') : t('Done')}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card/50 min-w-0 rounded-lg border p-2.5 sm:p-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-wrap items-start gap-2'>
|
||||
<div className='grid min-w-0 flex-1 grid-cols-1 gap-2 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] [&>input]:w-full'>
|
||||
{props.primaryFilters}
|
||||
</div>
|
||||
{advancedToggle && (
|
||||
<div className='flex shrink-0 items-center justify-end'>
|
||||
{advancedToggle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{advancedOpen && props.advancedFilters && (
|
||||
<div className='mt-2 grid grid-cols-1 gap-2 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] [&>input]:w-full'>
|
||||
{props.advancedFilters}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+64
-136
@@ -17,18 +17,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { DataTableFilterField, DataTableFilterPanel } from './filter-panel'
|
||||
|
||||
type FilterDef = {
|
||||
columnId: string
|
||||
@@ -153,7 +150,6 @@ export type DataTableToolbarProps<TData> = {
|
||||
*/
|
||||
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isSearchComposing, setIsSearchComposing] = useState(false)
|
||||
|
||||
const filters = props.filters ?? []
|
||||
@@ -246,32 +242,29 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const searchInput = (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onCompositionStart={handleSearchCompositionStart}
|
||||
onCompositionEnd={handleSearchCompositionEnd}
|
||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||
className='w-full'
|
||||
/>
|
||||
)
|
||||
|
||||
const filterChips = React.useMemo(
|
||||
() =>
|
||||
filters.map((filter) => {
|
||||
const column = props.table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.filters, props.table]
|
||||
)
|
||||
const filterChips = filters.map((filter) => {
|
||||
const column = props.table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFilterField key={filter.columnId}>
|
||||
<DataTableFacetedFilter
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
)
|
||||
})
|
||||
|
||||
const handleReset = () => {
|
||||
setIsSearchComposing(false)
|
||||
@@ -281,118 +274,53 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
props.onReset?.()
|
||||
}
|
||||
|
||||
// Reset: outline text-only for form mode (always visible, disabled when
|
||||
// nothing to reset); ghost text + X for filter-as-you-type mode (only
|
||||
// visible when active filters exist).
|
||||
let resetButton: ReactNode = null
|
||||
if (hasSearch) {
|
||||
resetButton = (
|
||||
<Button variant='outline' onClick={handleReset} disabled={!isFiltered}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)
|
||||
} else if (isFiltered) {
|
||||
resetButton = (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleReset}
|
||||
className='text-muted-foreground hover:text-foreground gap-1 px-2'
|
||||
>
|
||||
{t('Reset')}
|
||||
<Cross2Icon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const searchButton = hasSearch ? (
|
||||
<Button onClick={props.onSearch} disabled={props.searchLoading}>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const viewOptionsNode = !props.hideViewOptions ? (
|
||||
<DataTableViewOptions table={props.table} />
|
||||
) : null
|
||||
|
||||
const viewToggleNode = props.viewToggle ?? null
|
||||
|
||||
const expandToggle = hasExpandable ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
props.hasExpandedActiveFilters &&
|
||||
!expanded &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const hasLeftActions = props.leftActions != null
|
||||
|
||||
if (hasLeftActions) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', props.className)}>
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasExpandable && (
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.expandable}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.leftActions}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const primarySearch =
|
||||
props.customSearch !== undefined ? props.customSearch : searchInput
|
||||
const useWidePrimarySearch =
|
||||
filters.length + (props.additionalSearch != null ? 1 : 0) <= 3
|
||||
const secondaryMobileFilters =
|
||||
props.additionalSearch != null ||
|
||||
filterChips.some(Boolean) ||
|
||||
hasExpandable ? (
|
||||
<>
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
{props.expandable}
|
||||
</>
|
||||
) : undefined
|
||||
const nonSearchColumnFilterCount = props.table
|
||||
.getState()
|
||||
.columnFilters.filter((filter) => filter.id !== props.searchKey).length
|
||||
const mobileFilterCount =
|
||||
nonSearchColumnFilterCount + (props.hasExpandedActiveFilters ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 sm:gap-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
{expanded && hasExpandable && props.expandable}
|
||||
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
<DataTableFilterPanel
|
||||
table={props.table}
|
||||
primaryFilters={
|
||||
<>
|
||||
<DataTableFilterField wide={useWidePrimarySearch}>
|
||||
{primarySearch}
|
||||
</DataTableFilterField>
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
</>
|
||||
}
|
||||
advancedFilters={props.expandable}
|
||||
mobilePinnedFilters={primarySearch}
|
||||
mobileFilters={secondaryMobileFilters}
|
||||
mobileFilterCount={mobileFilterCount}
|
||||
stats={props.leftActions}
|
||||
actionStart={props.preActions}
|
||||
viewToggle={props.viewToggle}
|
||||
hideViewOptions={props.hideViewOptions}
|
||||
hasActiveFilters={isFiltered}
|
||||
hasAdvancedActiveFilters={props.hasExpandedActiveFilters}
|
||||
advancedFilterCount={props.hasExpandedActiveFilters ? 1 : 0}
|
||||
searchLoading={props.searchLoading}
|
||||
onReset={handleReset}
|
||||
onSearch={hasSearch ? props.onSearch : undefined}
|
||||
className={props.className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
|
||||
role='group'
|
||||
aria-label={t('View mode')}
|
||||
className={cn(
|
||||
'bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5',
|
||||
'bg-muted/60 inline-flex h-7 items-center rounded-lg border p-0.5 sm:h-8',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { type Table } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
|
||||
+2
-2
@@ -21,9 +21,9 @@ import * as React from 'react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Design-system adapters
|
||||
|
||||
This directory is the product-policy boundary above the shadcn source in
|
||||
`src/components/ui/`.
|
||||
|
||||
## Import rule
|
||||
|
||||
- Application and feature code imports policy-bearing controls from
|
||||
`@/components/design-system/*`.
|
||||
- Files in this directory may import `@/components/ui/*`.
|
||||
- Do not make `components/ui` depend on this directory.
|
||||
- Do not proxy a shadcn component unless it carries a stable cross-application
|
||||
policy. Components without product policy continue to be imported directly
|
||||
from `components/ui`.
|
||||
|
||||
The lint configuration enforces this rule for the managed controls.
|
||||
|
||||
## Responsive sizing
|
||||
|
||||
Default controls are 28px below the `sm` breakpoint and 32px at `sm` and above.
|
||||
Dense and micro sizes remain fixed:
|
||||
|
||||
- `sm` / `icon-sm`: 28px
|
||||
- `xs` / `icon-xs`: 24px
|
||||
- `xl`: 40px below `sm`, 44px at `sm` and above
|
||||
|
||||
Call sites should select a semantic size and use `className` only for layout.
|
||||
They must not pin a managed control to a fixed `h-*` or `size-*` value.
|
||||
|
||||
## Updating shadcn
|
||||
|
||||
Keep `components.json` pointed at `@/components/ui`. Preview upstream changes
|
||||
before merging:
|
||||
|
||||
```sh
|
||||
bunx --bun shadcn@latest add <component> --dry-run
|
||||
bunx --bun shadcn@latest add <component> --diff <file>
|
||||
```
|
||||
|
||||
Merge the upstream source into `components/ui`, then verify that the adapter's
|
||||
prop forwarding and class overrides still match the upstream API. Never use
|
||||
`--overwrite` without explicit approval.
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot='alert-dialog-action'
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'default',
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot='alert-dialog-cancel'
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button as ShadcnButton } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const responsiveButtonSizeVariants = cva('', {
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
"h-7 gap-1 px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 sm:h-8 sm:gap-1.5 sm:text-sm sm:has-data-[icon=inline-end]:pr-2 sm:has-data-[icon=inline-start]:pl-2 sm:[&_svg:not([class*='size-'])]:size-4",
|
||||
xs: null,
|
||||
sm: null,
|
||||
lg: 'h-8 sm:h-9',
|
||||
xl: 'h-10 gap-2 px-4 sm:h-11 sm:px-5',
|
||||
icon: 'size-7 sm:size-8',
|
||||
'icon-xs': null,
|
||||
'icon-sm': null,
|
||||
'icon-lg': 'size-8 sm:size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
type ButtonSize = NonNullable<
|
||||
VariantProps<typeof responsiveButtonSizeVariants>['size']
|
||||
>
|
||||
|
||||
type ButtonProps = Omit<React.ComponentProps<typeof ShadcnButton>, 'size'> & {
|
||||
size?: ButtonSize
|
||||
}
|
||||
|
||||
function Button({ className, size = 'default', ...props }: ButtonProps) {
|
||||
return (
|
||||
<ShadcnButton
|
||||
data-control-size={size}
|
||||
size={size === 'xl' ? 'lg' : size}
|
||||
className={cn(responsiveButtonSizeVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
||||
export type { ButtonProps, ButtonSize }
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 {
|
||||
ComboboxInput as ShadcnComboboxInput,
|
||||
type ComboboxInputOption,
|
||||
} from '@/components/ui/combobox-input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnComboboxInput>) {
|
||||
return (
|
||||
<ShadcnComboboxInput className={cn('h-7 sm:h-8', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { ComboboxInput }
|
||||
export type { ComboboxInputOption }
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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 { Combobox as ComboboxPrimitive } from '@base-ui/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
ComboboxInput as LegacyComboboxInput,
|
||||
type ComboboxInputOption,
|
||||
} from '@/components/design-system/combobox-input'
|
||||
import {
|
||||
ComboboxChip,
|
||||
ComboboxChips as ShadcnComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent as ShadcnComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput as ShadcnComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from '@/components/ui/combobox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type LegacyComboboxProps = {
|
||||
options: ComboboxInputOption[]
|
||||
value?: string
|
||||
onValueChange?: (value: string | null) => void
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
allowCustomValue?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
openOnFocus?: boolean
|
||||
}
|
||||
|
||||
function Combobox(props: LegacyComboboxProps): React.ReactElement
|
||||
function Combobox<Value, Multiple extends boolean | undefined = false>(
|
||||
props: ComboboxPrimitive.Root.Props<Value, Multiple>
|
||||
): React.ReactElement
|
||||
function Combobox(
|
||||
props:
|
||||
| ComboboxPrimitive.Root.Props<unknown, boolean | undefined>
|
||||
| LegacyComboboxProps
|
||||
) {
|
||||
if ('options' in props) {
|
||||
return (
|
||||
<LegacyComboboxInput
|
||||
id={props.id}
|
||||
options={props.options}
|
||||
value={props.value ?? ''}
|
||||
onValueChange={(value) => props.onValueChange?.(value)}
|
||||
placeholder={props.searchPlaceholder ?? props.placeholder}
|
||||
emptyText={props.emptyText}
|
||||
className={props.className}
|
||||
allowCustomValue={props.allowCustomValue}
|
||||
openOnFocus={props.openOnFocus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <ComboboxPrimitive.Root {...props} />
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnComboboxInput>) {
|
||||
return (
|
||||
<ShadcnComboboxInput className={cn('h-7 sm:h-8', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnComboboxContent>) {
|
||||
return (
|
||||
<ShadcnComboboxContent
|
||||
className={cn(
|
||||
'*:data-[slot=input-group]:h-7 sm:*:data-[slot=input-group]:h-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnComboboxChips>) {
|
||||
return (
|
||||
<ShadcnComboboxChips
|
||||
className={cn('min-h-7 sm:min-h-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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 { SearchIcon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from '@/components/design-system/input-group'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot='command-input-wrapper' className='p-1 pb-0'>
|
||||
<InputGroup className='border-input/30 bg-input/30 rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!'>
|
||||
<CommandPrimitive.Input
|
||||
data-slot='command-input'
|
||||
className={cn(
|
||||
'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<HugeiconsIcon
|
||||
icon={SearchIcon}
|
||||
strokeWidth={2}
|
||||
className='size-4 shrink-0 opacity-50'
|
||||
/>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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 { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot='dialog-footer'
|
||||
className={cn(
|
||||
'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant='outline' />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
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 {
|
||||
InputGroup as ShadcnInputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton as ShadcnInputGroupButton,
|
||||
InputGroupInput as ShadcnInputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from '@/components/ui/input-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function InputGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnInputGroup>) {
|
||||
return (
|
||||
<ShadcnInputGroup
|
||||
data-control-size='default'
|
||||
className={cn('h-7 sm:h-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
size = 'xs',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnInputGroupButton>) {
|
||||
return (
|
||||
<ShadcnInputGroupButton
|
||||
data-control-size={size}
|
||||
size={size}
|
||||
className={cn(
|
||||
size === 'sm' && 'h-7 sm:h-8',
|
||||
size === 'icon-sm' && 'size-7 sm:size-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnInputGroupInput>) {
|
||||
return (
|
||||
<ShadcnInputGroupInput className={cn('h-full', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 { Input as ShadcnInput } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnInput>) {
|
||||
return (
|
||||
<ShadcnInput
|
||||
data-control-size='default'
|
||||
className={cn('h-7 sm:h-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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 {
|
||||
Pagination as ShadcnPagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Pagination({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnPagination>) {
|
||||
return (
|
||||
<ShadcnPagination
|
||||
className={cn(
|
||||
'[&_[data-slot=pagination-link]]:h-7 [&_[data-slot=pagination-link]:not(:has(span))]:w-7 [&_[data-slot=pagination-ellipsis]]:size-7 sm:[&_[data-slot=pagination-link]]:h-8 sm:[&_[data-slot=pagination-link]:not(:has(span))]:w-8 sm:[&_[data-slot=pagination-ellipsis]]:size-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger as ShadcnSelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnSelectTrigger>) {
|
||||
return (
|
||||
<ShadcnSelectTrigger
|
||||
data-control-size={size}
|
||||
size={size}
|
||||
className={cn(
|
||||
size === 'default' && 'h-7 text-[0.8rem] sm:h-8 sm:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
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 {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput as ShadcnSidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger as ShadcnSidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnSidebarTrigger>) {
|
||||
return (
|
||||
<ShadcnSidebarTrigger
|
||||
className={cn('size-7 sm:size-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnSidebarInput>) {
|
||||
return (
|
||||
<ShadcnSidebarInput className={cn('h-7 sm:h-8', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead as ShadcnTableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TableHead({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnTableHead>) {
|
||||
return <ShadcnTableHead className={cn('h-9 sm:h-10', className)} {...props} />
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
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 {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList as ShadcnTabsList,
|
||||
TabsTrigger as ShadcnTabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnTabsList>) {
|
||||
return (
|
||||
<ShadcnTabsList
|
||||
data-control-size='default'
|
||||
className={cn(
|
||||
'group-data-horizontal/tabs:h-7 sm:group-data-horizontal/tabs:h-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnTabsTrigger>) {
|
||||
return (
|
||||
<ShadcnTabsTrigger
|
||||
className={cn('text-[0.8rem] sm:text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger }
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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 {
|
||||
ToggleGroup as ShadcnToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@/components/ui/toggle-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ShadcnToggleGroup>) {
|
||||
return (
|
||||
<ShadcnToggleGroup
|
||||
data-control-size={size}
|
||||
size={size}
|
||||
className={cn(
|
||||
size === 'default' &&
|
||||
"[&_[data-slot=toggle-group-item]]:h-7 [&_[data-slot=toggle-group-item]]:min-w-7 [&_[data-slot=toggle-group-item]]:text-[0.8rem] [&_[data-slot=toggle-group-item]_svg:not([class*='size-'])]:size-3.5 sm:[&_[data-slot=toggle-group-item]]:h-8 sm:[&_[data-slot=toggle-group-item]]:min-w-8 sm:[&_[data-slot=toggle-group-item]]:text-sm sm:[&_[data-slot=toggle-group-item]_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
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 { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Toggle as ShadcnToggle } from '@/components/ui/toggle'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const responsiveToggleSizeVariants = cva('', {
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
"h-7 min-w-7 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 sm:h-8 sm:min-w-8 sm:text-sm sm:has-data-[icon=inline-end]:pr-2 sm:has-data-[icon=inline-start]:pl-2 sm:[&_svg:not([class*='size-'])]:size-4",
|
||||
sm: null,
|
||||
lg: 'h-8 min-w-8 sm:h-9 sm:min-w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
type ToggleSize = NonNullable<
|
||||
VariantProps<typeof responsiveToggleSizeVariants>['size']
|
||||
>
|
||||
|
||||
type ToggleProps = Omit<React.ComponentProps<typeof ShadcnToggle>, 'size'> & {
|
||||
size?: ToggleSize
|
||||
}
|
||||
|
||||
function Toggle({ className, size = 'default', ...props }: ToggleProps) {
|
||||
return (
|
||||
<ShadcnToggle
|
||||
data-control-size={size}
|
||||
size={size}
|
||||
className={cn(responsiveToggleSizeVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle }
|
||||
export type { ToggleProps, ToggleSize }
|
||||
+1
-1
@@ -26,7 +26,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
} from '@/components/design-system/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogRoot> & {
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ import { AlertTriangle, type LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -61,7 +61,7 @@ export function ErrorState(props: ErrorStateProps) {
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{props.onRetry != null && (
|
||||
<Button variant='outline' size='sm' onClick={props.onRetry}>
|
||||
<Button variant='outline' onClick={props.onRetry}>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+17
-35
@@ -22,23 +22,18 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||
|
||||
type GroupBadgeProps = Omit<
|
||||
StatusBadgeProps,
|
||||
'autoColor' | 'label' | 'variant'
|
||||
> & {
|
||||
type GroupBadgeProps = Omit<StatusBadgeProps, 'children' | 'variant'> & {
|
||||
group?: string | null
|
||||
label?: string
|
||||
ratio?: number | null
|
||||
}
|
||||
|
||||
function getGroupRatioClassName(ratio: number): string {
|
||||
if (ratio > 1) {
|
||||
return 'bg-warning/10 text-warning'
|
||||
}
|
||||
if (ratio < 1) {
|
||||
return 'bg-info/10 text-info'
|
||||
}
|
||||
return 'bg-muted text-muted-foreground'
|
||||
function getGroupRatioVariant(
|
||||
ratio: number
|
||||
): NonNullable<StatusBadgeProps['variant']> {
|
||||
if (ratio > 1) return 'warning'
|
||||
if (ratio < 1) return 'info'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function getGroupLabel(params: {
|
||||
@@ -56,19 +51,10 @@ function getGroupLabel(params: {
|
||||
|
||||
export function GroupBadge(props: GroupBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
group,
|
||||
label: labelOverride,
|
||||
ratio,
|
||||
copyable = false,
|
||||
showDot,
|
||||
className,
|
||||
...badgeProps
|
||||
} = props
|
||||
const { group, label: labelOverride, ratio, className, ...badgeProps } = props
|
||||
const groupName = group?.trim()
|
||||
const isAutoGroup = groupName === 'auto'
|
||||
const isEmptyGroup = !groupName
|
||||
const isSpecialGroup = isAutoGroup || isEmptyGroup
|
||||
const label = getGroupLabel({
|
||||
labelOverride,
|
||||
groupName,
|
||||
@@ -80,13 +66,11 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
const badge = (
|
||||
<StatusBadge
|
||||
{...badgeProps}
|
||||
copyable={copyable}
|
||||
label={label}
|
||||
showDot={showDot ?? (isSpecialGroup ? false : undefined)}
|
||||
variant={isSpecialGroup ? 'neutral' : undefined}
|
||||
autoColor={isSpecialGroup ? undefined : groupName}
|
||||
variant='neutral'
|
||||
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||
/>
|
||||
>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
)
|
||||
|
||||
if (ratio == null) {
|
||||
@@ -96,14 +80,12 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
return (
|
||||
<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 shrink-0 items-center rounded-full px-1.5 text-xs leading-none font-medium tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
<StatusBadge
|
||||
variant={getGroupRatioVariant(ratio)}
|
||||
className='shrink-0 tabular-nums'
|
||||
>
|
||||
<span>{ratio}x</span>
|
||||
</span>
|
||||
{ratio}x
|
||||
</StatusBadge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-3
@@ -26,7 +26,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -242,8 +242,7 @@ export function JsonCodeEditor({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
size='xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||
>
|
||||
|
||||
+3
-7
@@ -20,8 +20,8 @@ import { Code, Table, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -192,7 +192,6 @@ export function JsonEditor({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={toggleMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -212,8 +211,7 @@ export function JsonEditor({
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto p-0'
|
||||
className='h-auto p-0 sm:h-auto'
|
||||
onClick={handleFillTemplate}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -260,7 +258,6 @@ export function JsonEditor({
|
||||
aria-label='Delete row'
|
||||
onClick={() => handleDeleteRow(row.id)}
|
||||
disabled={disabled}
|
||||
className='h-10 w-10'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' aria-hidden='true' />
|
||||
</Button>
|
||||
@@ -275,7 +272,6 @@ export function JsonEditor({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={disabled}
|
||||
className='w-full'
|
||||
|
||||
+2
-4
@@ -20,7 +20,7 @@ import { Languages, Check } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -55,9 +55,7 @@ export function LanguageSwitcher() {
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant='ghost' size='icon' className='h-9 w-9' />}
|
||||
>
|
||||
<DropdownMenuTrigger render={<Button variant='ghost' size='icon' />}>
|
||||
<Languages className='size-[1.2rem]' />
|
||||
<span className='sr-only'>{t('Change language')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -18,7 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||
|
||||
import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarRail,
|
||||
} from '@/components/design-system/sidebar'
|
||||
import { useLayout } from '@/context/layout-provider'
|
||||
import { useSidebarView } from '@/hooks/use-sidebar-view'
|
||||
import { MOTION_TRANSITION, MOTION_VARIANTS } from '@/lib/motion'
|
||||
@@ -52,7 +56,13 @@ export function AppSidebar() {
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
{view && <SidebarViewHeader view={view} />}
|
||||
|
||||
<SidebarContent className='py-2'>
|
||||
<SidebarContent
|
||||
className={
|
||||
view
|
||||
? 'py-2'
|
||||
: 'py-2 md:pt-[calc(var(--app-header-height,3rem)+0.5rem)]'
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode='wait' initial={false}>
|
||||
<motion.div
|
||||
key={key}
|
||||
|
||||
@@ -16,9 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from '@/components/design-system/sidebar'
|
||||
import { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { LayoutProvider } from '@/context/layout-provider'
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { getCookie } from '@/lib/cookies'
|
||||
@@ -46,7 +49,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
className={cn(
|
||||
'@container/content',
|
||||
'h-[calc(100svh-var(--app-header-height,0px))]',
|
||||
'min-h-0 overflow-hidden',
|
||||
'min-h-0 min-w-0 overflow-hidden',
|
||||
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,14 @@ import { useMemo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/design-system/sidebar'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -33,14 +41,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { fetchActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
|
||||
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
|
||||
import {
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { SidebarTrigger } from '@/components/design-system/sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type HeaderProps = React.HTMLAttributes<HTMLElement>
|
||||
@@ -31,7 +31,7 @@ export function Header({ className, children, ...props }: HeaderProps) {
|
||||
{...props}
|
||||
>
|
||||
<div className='flex h-full items-center gap-1.5 px-2 sm:gap-2 sm:px-3'>
|
||||
<SidebarTrigger variant='ghost' className='size-8' />
|
||||
<SidebarTrigger variant='ghost' size='icon' />
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -21,9 +21,9 @@ import { X, User, Wallet, LogOut } from 'lucide-react'
|
||||
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import useDialogState from '@/hooks/use-dialog'
|
||||
import { useUserDisplay } from '@/hooks/use-user-display'
|
||||
@@ -135,7 +135,7 @@ function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) {
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setSignOutOpen(true)}
|
||||
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent'
|
||||
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent sm:h-auto'
|
||||
>
|
||||
<LogOut className='size-4' />
|
||||
{t('Sign out')}
|
||||
@@ -159,8 +159,7 @@ function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-10 w-full'
|
||||
className='w-full'
|
||||
render={<Link to='/sign-in' onClick={onNavigate} />}
|
||||
>
|
||||
{t('Sign in')}
|
||||
|
||||
+11
-11
@@ -20,6 +20,17 @@ import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { type ReactNode, useState, useEffect } from 'react'
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/design-system/sidebar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -35,17 +46,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
import { checkIsActive } from '../lib/url-utils'
|
||||
import {
|
||||
|
||||
@@ -20,12 +20,12 @@ import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
@@ -286,8 +286,7 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
<ProfileDropdown />
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-8 rounded-lg px-3.5 text-xs font-medium'
|
||||
className='font-medium'
|
||||
render={<Link to='/sign-in' />}
|
||||
>
|
||||
{t('Sign in')}
|
||||
@@ -307,7 +306,6 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-9'
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label={t('Toggle navigation menu')}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
} from '@/components/design-system/sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SidebarView } from '../types'
|
||||
@@ -46,7 +46,7 @@ export function SidebarViewHeader(props: SidebarViewHeaderProps) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarHeader className='border-sidebar-border border-b px-2 py-2'>
|
||||
<SidebarHeader className='border-sidebar-border border-b px-2 py-2 md:mt-[var(--app-header-height,3rem)]'>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
} from '@/components/design-system/sidebar'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -58,7 +58,7 @@ export function TopNav({ className, links, ...props }: TopNavProps) {
|
||||
<div className='lg:hidden'>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button size='icon' variant='outline' className='size-7' />}
|
||||
render={<Button size='icon' variant='outline' />}
|
||||
>
|
||||
<Menu />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { CircleQuestionMark } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -49,7 +49,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
|
||||
className='max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -72,7 +72,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
|
||||
</Popover>
|
||||
<CopyButton
|
||||
value={props.fullValue}
|
||||
className='size-7'
|
||||
size='icon-sm'
|
||||
iconClassName='size-3.5'
|
||||
tooltip={props.copyTooltip}
|
||||
aria-label={props.copyAriaLabel}
|
||||
|
||||
+32
-34
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
} from '@/components/design-system/command'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
@@ -92,12 +92,11 @@ const ModelTriggerButton = React.forwardRef<
|
||||
ref={ref}
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
size='sm'
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 border px-3 font-medium',
|
||||
'flex items-center gap-2 border px-3 font-medium',
|
||||
'justify-center p-0 sm:w-auto sm:justify-start sm:px-3',
|
||||
'w-8',
|
||||
'w-7 sm:w-auto',
|
||||
'bg-background text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:!ring-0 focus:!outline-none',
|
||||
@@ -128,12 +127,11 @@ const GroupTriggerButton = React.forwardRef<
|
||||
ref={ref}
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
size='sm'
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 border px-3 font-medium',
|
||||
'flex items-center gap-2 border px-3 font-medium',
|
||||
'justify-center p-0 sm:w-auto sm:justify-start sm:px-3',
|
||||
'w-8',
|
||||
'w-7 sm:w-auto',
|
||||
'bg-background text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:!ring-0 focus:!outline-none',
|
||||
@@ -230,7 +228,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
{!isMobile && (
|
||||
<CommandInput
|
||||
placeholder={t('Search models...')}
|
||||
className='h-9'
|
||||
className=''
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
@@ -300,14 +298,16 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<ModelTriggerButton
|
||||
currentLabel={currentModel?.label || t('Model')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerTrigger
|
||||
render={
|
||||
<ModelTriggerButton
|
||||
currentLabel={currentModel?.label || t('Model')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DrawerContent className='flex max-h-[80vh] min-h-[60vh] flex-col'>
|
||||
<DrawerHeader className='flex-shrink-0 pb-4'>
|
||||
<DrawerTitle className='flex items-center gap-2 text-left text-lg font-medium'>
|
||||
@@ -336,7 +336,6 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
>
|
||||
{renderModelCommandContent()}
|
||||
</PopoverContent>
|
||||
@@ -394,7 +393,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
return searchableFields.includes(searchTerm) ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder={t('Search groups...')} className='h-9' />
|
||||
<CommandInput placeholder={t('Search groups...')} className='' />
|
||||
<CommandEmpty>{t('No group found.')}</CommandEmpty>
|
||||
<CommandList
|
||||
className={isMobile ? '!max-h-full flex-1 p-2' : 'max-h-[240px]'}
|
||||
@@ -448,14 +447,16 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<GroupTriggerButton
|
||||
currentLabel={currentGroup?.label || t('Group')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerTrigger
|
||||
render={
|
||||
<GroupTriggerButton
|
||||
currentLabel={currentGroup?.label || t('Group')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DrawerContent className='max-h-[80vh]'>
|
||||
<DrawerHeader className='pb-4 text-left'>
|
||||
<DrawerTitle>{t('Choose Group')}</DrawerTitle>
|
||||
@@ -468,7 +469,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
variant='outline'
|
||||
onClick={() => handleGroupChange(group.value)}
|
||||
className={cn(
|
||||
'flex h-auto w-full items-center justify-between rounded-lg p-4 text-left whitespace-normal',
|
||||
'flex h-auto sm:h-auto w-full items-center justify-between rounded-lg p-4 text-left whitespace-normal',
|
||||
'border-border hover:bg-accent',
|
||||
selectedGroup === group.value
|
||||
? 'bg-accent border-primary/20'
|
||||
@@ -526,7 +527,6 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
>
|
||||
{renderGroupCommandContent()}
|
||||
</PopoverContent>
|
||||
@@ -644,14 +644,13 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
<Button
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'h-8 max-w-[15rem] justify-start gap-2 border px-2.5 font-medium shadow-none',
|
||||
'max-w-[15rem] justify-start gap-2 border px-2.5 font-medium shadow-none',
|
||||
'bg-background/80 hover:bg-accent/70 text-foreground',
|
||||
'focus:!ring-0 focus:!outline-none',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
role='combobox'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<CpuIcon className='text-muted-foreground size-4 shrink-0' />
|
||||
@@ -725,7 +724,7 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
shouldFilter={false}
|
||||
>
|
||||
<CommandInput
|
||||
className='h-8 text-[13px]'
|
||||
className='text-[13px]'
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={t('Search models...')}
|
||||
value={searchQuery}
|
||||
@@ -804,7 +803,7 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{renderTrigger()}</DrawerTrigger>
|
||||
<DrawerTrigger render={renderTrigger()} />
|
||||
<DrawerContent className='flex max-h-[80vh] min-h-[60vh] flex-col'>
|
||||
<DrawerHeader className='pb-3 text-left'>
|
||||
<DrawerTitle>{t('Select Model')}</DrawerTitle>
|
||||
@@ -823,7 +822,6 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
'bg-popover z-50 w-[34rem] max-w-[calc(100vw-2rem)] rounded-xl border p-0 shadow-lg',
|
||||
modelGroupSelectorLayoutClasses.desktopPanel
|
||||
)}
|
||||
collisionPadding={8}
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
>
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from '@/components/ui/combobox'
|
||||
} from '@/components/design-system/combobox'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
+9
-6
@@ -20,9 +20,15 @@ import type { TFunction } from 'i18next'
|
||||
import { Bell, Megaphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/design-system/tabs'
|
||||
import { RichContent } from '@/components/rich-content'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -39,7 +45,6 @@ import {
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { getAnnouncementColorClass } from '@/lib/colors'
|
||||
import { formatDateTimeObject } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -309,7 +314,7 @@ export function NotificationPopover({
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn('relative size-9', className)}
|
||||
className={cn('relative', className)}
|
||||
aria-label={t('Notifications')}
|
||||
/>
|
||||
}
|
||||
@@ -366,9 +371,7 @@ export function NotificationPopover({
|
||||
</Tabs>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button size='sm' onClick={() => onOpenChange(false)}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t('Close')}</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
+4
-5
@@ -19,11 +19,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
type PasswordInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'type'
|
||||
@@ -49,10 +48,10 @@ export function PasswordInput({
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
size='icon-xs'
|
||||
variant='ghost'
|
||||
disabled={disabled}
|
||||
className='text-muted-foreground absolute end-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md'
|
||||
className='text-muted-foreground absolute end-1 top-1/2 -translate-y-1/2'
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
aria-label='Toggle password visibility'
|
||||
>
|
||||
|
||||
+2
-2
@@ -21,9 +21,9 @@ import { User, Wallet, LogOut, Settings } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -59,7 +59,7 @@ export function ProfileDropdown() {
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant='ghost' className='relative size-6 p-0' />}
|
||||
render={<Button size='icon' variant='ghost' className='relative' />}
|
||||
>
|
||||
<Avatar className='size-6'>
|
||||
<AvatarFallback
|
||||
|
||||
+17
-20
@@ -21,38 +21,35 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||
|
||||
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
|
||||
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'variant'> & {
|
||||
iconKey?: string | null
|
||||
iconSize?: number
|
||||
label: string
|
||||
/** Color the label text by provider name. Set false for a neutral label. */
|
||||
colorText?: boolean
|
||||
}
|
||||
|
||||
export function ProviderBadge({
|
||||
className,
|
||||
iconKey,
|
||||
iconSize = 14,
|
||||
label,
|
||||
colorText = true,
|
||||
...badgeProps
|
||||
}: ProviderBadgeProps) {
|
||||
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
|
||||
const icon = iconKey ? getLobeIcon(iconKey, 12) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot='provider-badge'
|
||||
className={cn('flex max-w-full min-w-0 items-center gap-1.5', className)}
|
||||
<StatusBadge
|
||||
{...badgeProps}
|
||||
variant='neutral'
|
||||
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||
>
|
||||
{icon && <span className='flex shrink-0 items-center'>{icon}</span>}
|
||||
<StatusBadge
|
||||
label={label}
|
||||
autoColor={colorText ? label : undefined}
|
||||
variant={colorText ? undefined : 'neutral'}
|
||||
size='sm'
|
||||
className='min-w-0 shrink overflow-hidden'
|
||||
{...badgeProps}
|
||||
/>
|
||||
</div>
|
||||
{icon && (
|
||||
<span
|
||||
data-icon='inline-start'
|
||||
className='flex items-center'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
} from '@/components/design-system/alert-dialog'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
+2
-3
@@ -19,11 +19,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type SearchProps = {
|
||||
className?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
@@ -38,7 +37,7 @@ export function Search({ className = '', placeholder }: SearchProps) {
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'bg-muted/25 group text-muted-foreground hover:bg-accent relative h-8 w-full flex-1 justify-start rounded-md text-sm font-normal shadow-none sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
|
||||
'bg-muted/25 group text-muted-foreground hover:bg-accent relative w-full flex-1 justify-start rounded-md font-normal shadow-none sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
|
||||
className
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
+178
-208
@@ -1,4 +1,3 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
@@ -18,200 +17,203 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { mergeProps } from '@base-ui/react/merge-props'
|
||||
import { useRender } from '@base-ui/react/use-render'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/* Color discipline: badges speak exactly five voices — success / warning /
|
||||
* danger / info / neutral. Legacy hue keys (blue, purple, teal, ...) remain
|
||||
* valid variant names for compatibility, but they all resolve to one of the
|
||||
* five semantic tokens so tables stop reading as rainbows. Chart hues stay
|
||||
* reserved for data visualization. */
|
||||
export const dotColorMap = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
danger: 'bg-destructive',
|
||||
info: 'bg-info',
|
||||
neutral: 'bg-neutral',
|
||||
purple: 'bg-neutral',
|
||||
amber: 'bg-warning',
|
||||
blue: 'bg-neutral',
|
||||
cyan: 'bg-neutral',
|
||||
green: 'bg-success',
|
||||
grey: 'bg-neutral',
|
||||
indigo: 'bg-neutral',
|
||||
'light-blue': 'bg-info',
|
||||
'light-green': 'bg-success',
|
||||
lime: 'bg-neutral',
|
||||
orange: 'bg-warning',
|
||||
pink: 'bg-neutral',
|
||||
red: 'bg-destructive',
|
||||
teal: 'bg-neutral',
|
||||
violet: 'bg-neutral',
|
||||
yellow: 'bg-warning',
|
||||
} as const
|
||||
export type StatusVariant =
|
||||
| 'neutral'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'destructive'
|
||||
|
||||
export const textColorMap = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-destructive',
|
||||
info: 'text-info',
|
||||
neutral: 'text-muted-foreground',
|
||||
purple: 'text-muted-foreground',
|
||||
amber: 'text-warning',
|
||||
blue: 'text-muted-foreground',
|
||||
cyan: 'text-muted-foreground',
|
||||
green: 'text-success',
|
||||
grey: 'text-muted-foreground',
|
||||
indigo: 'text-muted-foreground',
|
||||
'light-blue': 'text-info',
|
||||
'light-green': 'text-success',
|
||||
lime: 'text-muted-foreground',
|
||||
orange: 'text-warning',
|
||||
pink: 'text-muted-foreground',
|
||||
red: 'text-destructive',
|
||||
teal: 'text-muted-foreground',
|
||||
violet: 'text-muted-foreground',
|
||||
yellow: 'text-warning',
|
||||
} as const
|
||||
export type StatusBadgeAppearance = 'soft' | 'outline' | 'plain'
|
||||
|
||||
export type StatusVariant = keyof typeof dotColorMap
|
||||
export const statusBadgeVariants = cva(
|
||||
'focus-visible:ring-ring/50 inline-flex w-fit max-w-full min-w-0 shrink items-center justify-center overflow-hidden rounded-md text-ellipsis whitespace-nowrap font-medium tracking-normal outline-none transition-[color,background-color,border-color,filter] duration-150 focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_[data-icon]]:pointer-events-none [&_[data-icon]]:size-3 [&_[data-icon]]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
|
||||
md: 'h-6 gap-1.5 px-2 text-sm leading-none',
|
||||
},
|
||||
variant: {
|
||||
neutral: 'text-muted-foreground',
|
||||
info: 'text-status-info',
|
||||
success: 'text-status-success',
|
||||
warning: 'text-status-warning',
|
||||
destructive: 'text-status-destructive',
|
||||
},
|
||||
appearance: {
|
||||
soft: 'border',
|
||||
outline: 'border bg-transparent',
|
||||
plain:
|
||||
'h-auto rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'neutral',
|
||||
appearance: 'soft',
|
||||
className: 'border-border/60 bg-muted/40',
|
||||
},
|
||||
{
|
||||
variant: 'info',
|
||||
appearance: 'soft',
|
||||
className: 'border-info/25 bg-info/10',
|
||||
},
|
||||
{
|
||||
variant: 'success',
|
||||
appearance: 'soft',
|
||||
className: 'border-success/25 bg-success/10',
|
||||
},
|
||||
{
|
||||
variant: 'warning',
|
||||
appearance: 'soft',
|
||||
className: 'border-warning/30 bg-warning/10',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
appearance: 'soft',
|
||||
className: 'border-destructive/25 bg-destructive/10',
|
||||
},
|
||||
{
|
||||
variant: 'neutral',
|
||||
appearance: 'outline',
|
||||
className: 'border-border',
|
||||
},
|
||||
{
|
||||
variant: 'info',
|
||||
appearance: 'outline',
|
||||
className: 'border-info/40',
|
||||
},
|
||||
{
|
||||
variant: 'success',
|
||||
appearance: 'outline',
|
||||
className: 'border-success/40',
|
||||
},
|
||||
{
|
||||
variant: 'warning',
|
||||
appearance: 'outline',
|
||||
className: 'border-warning/45',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
appearance: 'outline',
|
||||
className: 'border-destructive/40',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
appearance: 'soft',
|
||||
size: 'sm',
|
||||
variant: 'neutral',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/** Soft tinted pill surface per semantic voice — the one sanctioned recipe
|
||||
* for badges that need a filled background (timing pills, duration pills).
|
||||
* Border/background/text always come from the same token so light and dark
|
||||
* modes stay consistent without `!important` overrides. */
|
||||
export const tintedBadgeClassMap = {
|
||||
success: 'border border-success/25 bg-success/10 text-success',
|
||||
warning: 'border border-warning/30 bg-warning/10 text-warning',
|
||||
danger: 'border border-destructive/25 bg-destructive/10 text-destructive',
|
||||
info: 'border border-info/25 bg-info/10 text-info',
|
||||
neutral: 'border border-border/60 bg-muted/30 text-muted-foreground',
|
||||
} as const
|
||||
|
||||
/** Controls the visual style of the badge.
|
||||
* - `badge` — default pill with background and padding (default)
|
||||
* - `text` — plain text, no background or padding, only color
|
||||
* - `underline`— plain text with a bottom border underline
|
||||
*/
|
||||
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')
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'h-5 gap-1 text-sm leading-none',
|
||||
md: 'h-5 gap-1 text-sm leading-none',
|
||||
lg: 'h-6 gap-1.5 text-sm leading-none',
|
||||
} as const
|
||||
|
||||
const textSizeMap = {
|
||||
sm: 'gap-1 text-sm leading-none',
|
||||
md: 'gap-1 text-sm leading-none',
|
||||
lg: 'gap-1.5 text-sm leading-none',
|
||||
} as const
|
||||
|
||||
export interface StatusBadgeProps extends Omit<
|
||||
React.HTMLAttributes<HTMLSpanElement>,
|
||||
'children'
|
||||
> {
|
||||
label?: string
|
||||
children?: React.ReactNode
|
||||
icon?: LucideIcon
|
||||
pulse?: boolean
|
||||
/** Kept for compatibility. Badges no longer render leading dots. */
|
||||
showDot?: boolean
|
||||
variant?: StatusVariant | null
|
||||
size?: 'sm' | 'md' | 'lg' | null
|
||||
copyable?: boolean
|
||||
copyText?: string
|
||||
/** Deprecated: categorical identity is conveyed by label/icon, not hue.
|
||||
* Accepted for call-site compatibility; renders as `neutral`. */
|
||||
autoColor?: string
|
||||
/** Visual style. Defaults to 'badge'. Can be overridden via StatusBadgeTypeContext. */
|
||||
type?: StatusBadgeType
|
||||
}
|
||||
export type StatusBadgeProps = useRender.ComponentProps<'span'> &
|
||||
VariantProps<typeof statusBadgeVariants>
|
||||
|
||||
export function StatusBadge({
|
||||
label,
|
||||
appearance: appearanceProp,
|
||||
children,
|
||||
icon: Icon,
|
||||
variant,
|
||||
size = 'sm',
|
||||
pulse = false,
|
||||
showDot = false,
|
||||
copyable = true,
|
||||
copyText,
|
||||
autoColor,
|
||||
type: typeProp,
|
||||
className,
|
||||
onClick,
|
||||
render,
|
||||
size: sizeProp,
|
||||
variant: variantProp,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const contextType = React.useContext(StatusBadgeTypeContext)
|
||||
const type = typeProp ?? contextType
|
||||
const appearance = appearanceProp ?? 'soft'
|
||||
const size = sizeProp ?? 'sm'
|
||||
const variant = variantProp ?? 'neutral'
|
||||
const leadingIcons: React.ReactNode[] = []
|
||||
const trailingIcons: React.ReactNode[] = []
|
||||
const labelChildren: React.ReactNode[] = []
|
||||
|
||||
// String-hash rainbow coloring is retired; `autoColor` intentionally falls
|
||||
// through to the neutral voice (see prop doc above).
|
||||
void autoColor
|
||||
const computedVariant: StatusVariant = variant ?? 'neutral'
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLSpanElement>) => {
|
||||
if (copyable) {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(copyText || label || '')
|
||||
for (const child of React.Children.toArray(children)) {
|
||||
if (React.isValidElement<{ 'data-icon'?: string }>(child)) {
|
||||
const iconPlacement = child.props['data-icon']
|
||||
if (iconPlacement === 'inline-start') {
|
||||
leadingIcons.push(child)
|
||||
continue
|
||||
}
|
||||
if (iconPlacement === 'inline-end') {
|
||||
trailingIcons.push(child)
|
||||
continue
|
||||
}
|
||||
}
|
||||
onClick?.(e)
|
||||
labelChildren.push(child)
|
||||
}
|
||||
|
||||
const content =
|
||||
children ??
|
||||
(label ? (
|
||||
<span className='min-w-0 truncate leading-normal'>{label}</span>
|
||||
) : null)
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
props: mergeProps<'span'>(
|
||||
{
|
||||
className: cn(
|
||||
statusBadgeVariants({ appearance, size, variant }),
|
||||
className
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{leadingIcons}
|
||||
{labelChildren.length > 0 && (
|
||||
<span data-slot='status-badge-label' className='min-w-0 truncate'>
|
||||
{labelChildren}
|
||||
</span>
|
||||
)}
|
||||
{trailingIcons}
|
||||
</>
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
appearance,
|
||||
size,
|
||||
slot: 'status-badge',
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const isBadge = type === 'badge'
|
||||
const title = copyable
|
||||
? `Click to copy: ${copyText || label || ''}`
|
||||
: label || undefined
|
||||
export type CopyableStatusBadgeProps = Omit<
|
||||
StatusBadgeProps,
|
||||
'onClick' | 'render'
|
||||
> & {
|
||||
value: string
|
||||
}
|
||||
|
||||
export function CopyableStatusBadge({
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: CopyableStatusBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot='status-badge'
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full min-w-0 shrink items-center font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
isBadge
|
||||
? cn('rounded-md', sizeMap[size ?? 'sm'])
|
||||
: cn(
|
||||
textSizeMap[size ?? 'sm'],
|
||||
type === 'underline' && 'border-b border-current pb-px'
|
||||
),
|
||||
textColorMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
copyable &&
|
||||
'cursor-copy hover:brightness-95 active:scale-95 dark:hover:brightness-110',
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
<StatusBadge
|
||||
{...props}
|
||||
render={<button type='button' />}
|
||||
className={cn('cursor-copy hover:brightness-95', className)}
|
||||
aria-label={ariaLabel ?? `${t('Copy')}: ${value}`}
|
||||
title={t('Copy to clipboard')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copyToClipboard(value)
|
||||
}}
|
||||
>
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-1.5 shrink-0 rounded-full',
|
||||
dotColorMap[computedVariant]
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||
{content}
|
||||
</span>
|
||||
{children}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,9 +241,7 @@ export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||
...domProps
|
||||
} = props
|
||||
|
||||
if (items.length === 0) {
|
||||
return empty
|
||||
}
|
||||
if (items.length === 0) return empty
|
||||
|
||||
const displayed = items.slice(0, max)
|
||||
const remaining = items.length - max
|
||||
@@ -260,40 +260,10 @@ export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||
</React.Fragment>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<StatusBadge
|
||||
label={moreLabel?.(remaining) ?? `+${remaining}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
<StatusBadge className='shrink-0'>
|
||||
{moreLabel?.(remaining) ?? `+${remaining}`}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const statusPresets = {
|
||||
active: {
|
||||
variant: 'success' as const,
|
||||
label: 'Active',
|
||||
},
|
||||
inactive: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Inactive',
|
||||
},
|
||||
invited: {
|
||||
variant: 'info' as const,
|
||||
label: 'Invited',
|
||||
},
|
||||
suspended: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Suspended',
|
||||
},
|
||||
pending: {
|
||||
variant: 'warning' as const,
|
||||
label: 'Pending',
|
||||
pulse: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type StatusPreset = keyof typeof statusPresets
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@ import { X } from 'lucide-react'
|
||||
import { useState, useRef, type KeyboardEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TagInputProps {
|
||||
|
||||
+7
-7
@@ -20,7 +20,7 @@ import { Monitor, Sun, MoonStar } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -44,13 +44,13 @@ export function ThemeQuickSwitcher() {
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
role='radio'
|
||||
aria-label={t('System')}
|
||||
aria-checked={theme === 'system'}
|
||||
onClick={() => setTheme('system')}
|
||||
className={cn(
|
||||
'relative size-7',
|
||||
'relative',
|
||||
theme === 'system' && 'text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -71,13 +71,13 @@ export function ThemeQuickSwitcher() {
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
role='radio'
|
||||
aria-label={t('Light')}
|
||||
aria-checked={theme === 'light'}
|
||||
onClick={() => setTheme('light')}
|
||||
className={cn(
|
||||
'relative size-7',
|
||||
'relative',
|
||||
theme === 'light' && 'text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -98,13 +98,13 @@ export function ThemeQuickSwitcher() {
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
role='radio'
|
||||
aria-label={t('Dark')}
|
||||
aria-checked={theme === 'dark'}
|
||||
onClick={() => setTheme('dark')}
|
||||
className={cn(
|
||||
'relative size-7',
|
||||
'relative',
|
||||
theme === 'dark' && 'text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
|
||||
+2
-4
@@ -20,7 +20,7 @@ import { Check, Moon, Sun } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -44,9 +44,7 @@ export function ThemeSwitch() {
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant='ghost' size='icon' className='h-9 w-9' />}
|
||||
>
|
||||
<DropdownMenuTrigger render={<Button variant='ghost' size='icon' />}>
|
||||
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
|
||||
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
|
||||
<span className='sr-only'>{t('Toggle theme')}</span>
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ function AccordionTrigger({
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot='accordion-trigger'
|
||||
className={cn(
|
||||
'group/accordion-trigger focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4',
|
||||
'group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -88,7 +88,7 @@ function AccordionContent({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'[&_a]:hover:text-foreground h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
|
||||
'h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
+5
-5
@@ -48,7 +48,7 @@ function AlertDialogOverlay({
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot='alert-dialog-overlay'
|
||||
className={cn(
|
||||
'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
|
||||
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -70,7 +70,7 @@ function AlertDialogContent({
|
||||
data-slot='alert-dialog-content'
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 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 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -103,7 +103,7 @@ function AlertDialogFooter({
|
||||
<div
|
||||
data-slot='alert-dialog-footer'
|
||||
className={cn(
|
||||
'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -119,7 +119,7 @@ function AlertDialogMedia({
|
||||
<div
|
||||
data-slot='alert-dialog-media'
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -151,7 +151,7 @@ function AlertDialogDescription({
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot='alert-dialog-description'
|
||||
className={cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
|
||||
'text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
<div
|
||||
data-slot='alert-title'
|
||||
className={cn(
|
||||
'[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
|
||||
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,7 +73,7 @@ function AlertDescription({
|
||||
<div
|
||||
data-slot='alert-description'
|
||||
className={cn(
|
||||
'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
|
||||
'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+5
-5
@@ -33,7 +33,7 @@ function Avatar({
|
||||
data-slot='avatar'
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
|
||||
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -62,7 +62,7 @@ function AvatarFallback({
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot='avatar-fallback'
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
|
||||
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -75,7 +75,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
<span
|
||||
data-slot='avatar-badge'
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
|
||||
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
|
||||
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||
@@ -91,7 +91,7 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
<div
|
||||
data-slot='avatar-group'
|
||||
className={cn(
|
||||
'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2',
|
||||
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -107,7 +107,7 @@ function AvatarGroupCount({
|
||||
<div
|
||||
data-slot='avatar-group-count'
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
|
||||
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
<ol
|
||||
data-slot='breadcrumb-list'
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word',
|
||||
'flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -88,7 +88,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
role='link'
|
||||
aria-disabled='true'
|
||||
aria-current='page'
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ function ButtonGroupSeparator({
|
||||
data-slot='button-group-separator'
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
|
||||
'relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user