feat(FE-Storyless): add collapsible functionality and improve image handling

This commit is contained in:
rstubryan
2025-11-13 14:54:37 +07:00
parent b3f4e42f1a
commit 5648b51c2e
2 changed files with 133 additions and 35 deletions
+127 -33
View File
@@ -1,9 +1,11 @@
'use client'; 'use client';
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode, useState } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import Image from 'next/image'; import Image from 'next/image';
import Collapse from './Collapse';
import { Icon } from '@iconify/react';
export interface CardProps export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> { extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
@@ -11,8 +13,13 @@ export interface CardProps
subtitle?: string; subtitle?: string;
image?: string; image?: string;
imageAlt?: string; imageAlt?: string;
imageWidth?: number;
imageHeight?: number;
actions?: ReactNode; actions?: ReactNode;
footer?: ReactNode; footer?: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
image?: string; image?: string;
@@ -21,6 +28,7 @@ export interface CardProps
subtitle?: string; subtitle?: string;
actions?: string; actions?: string;
footer?: string; footer?: string;
collapsible?: string;
}; };
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full'; variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
@@ -31,14 +39,27 @@ const Card = ({
subtitle, subtitle,
image, image,
imageAlt, imageAlt,
imageWidth,
imageHeight,
actions, actions,
footer, footer,
collapsible,
defaultCollapsed = false,
onCollapsedChange,
className, className,
variant = 'default', variant = 'default',
size = 'md', size = 'md',
children, children,
...props ...props
}: CardProps) => { }: CardProps) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const handleCollapsedChange = (open: boolean) => {
const collapsed = !open;
setIsCollapsed(collapsed);
onCollapsedChange?.(collapsed);
};
const getCardClasses = () => { const getCardClasses = () => {
const baseClasses = 'card bg-base-100'; const baseClasses = 'card bg-base-100';
@@ -64,11 +85,31 @@ const Card = ({
); );
}; };
const getImageDimensions = () => {
if (variant === 'image-full') {
return {
width: imageWidth || 128,
height: imageHeight || 128,
};
}
const cardWidths = {
sm: 256, // w-64
md: 384, // w-96
lg: 448, // w-[28rem]
};
return {
width: imageWidth || cardWidths[size],
height: imageHeight || 192,
};
};
const getImageClasses = () => { const getImageClasses = () => {
if (variant === 'image-full') { if (variant === 'image-full') {
return cn('w-32 h-32 object-cover', className?.image); return cn('object-cover', className?.image);
} }
return cn('h-48 object-cover', className?.image); return cn('w-full object-cover', className?.image);
}; };
const getBodyClasses = () => { const getBodyClasses = () => {
@@ -103,45 +144,98 @@ const Card = ({
return cn('border-t border-base-300 mt-4 pt-4', className?.footer); return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
}; };
const renderCardContent = () => {
const hasContent = children || actions || footer;
const titleContent = (
<div className='group flex items-center !justify-between w-full'>
<div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
</div>
{collapsible && (
<button
onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
>
<Icon
icon={
isCollapsed
? 'material-symbols:expand-more'
: 'material-symbols:expand-less'
}
width={20}
/>
</button>
)}
</div>
);
const cardContent = (
<div className='space-y-4'>
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
return (
<>
{image && (
<figure>
<Image
src={image}
alt={imageAlt || title || 'Card image'}
width={getImageDimensions().width}
height={getImageDimensions().height}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{collapsible && hasContent ? (
<Collapse
variant='default'
bordered={false}
open={!isCollapsed}
onOpenChange={handleCollapsedChange}
title={titleContent}
titleClassName='w-full cursor-pointer'
contentClassName='p-0'
fullWidth={true}
>
{cardContent}
</Collapse>
) : (
<>
{(title || subtitle) && (
<div className='mb-4'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && (
<p className={getSubtitleClasses()}>{subtitle}</p>
)}
</div>
)}
{hasContent && cardContent}
</>
)}
</div>
</>
);
};
if (variant === 'image-full' && image) { if (variant === 'image-full' && image) {
return ( return (
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
<figure> {renderCardContent()}
<Image
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div> </div>
); );
} }
return ( return (
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
{image && ( {renderCardContent()}
<figure>
<Image
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div> </div>
); );
}; };
+6 -2
View File
@@ -26,6 +26,9 @@ export type CollapseProps = {
disabled?: boolean; disabled?: boolean;
/** Allow only one open at a time by switching to radio input */ /** Allow only one open at a time by switching to radio input */
asRadio?: boolean; asRadio?: boolean;
/** Force full width instead of auto-fit when collapsed
* (Khusus justify-between dan justify-end) */
fullWidth?: boolean;
/** Extra classnames */ /** Extra classnames */
className?: string; className?: string;
titleClassName?: string; titleClassName?: string;
@@ -44,6 +47,7 @@ export const Collapse = ({
bordered, bordered,
disabled, disabled,
asRadio = false, asRadio = false,
fullWidth,
className, className,
titleClassName, titleClassName,
contentClassName, contentClassName,
@@ -68,9 +72,9 @@ export const Collapse = ({
'collapse', 'collapse',
variant === 'arrow' && 'collapse-arrow', variant === 'arrow' && 'collapse-arrow',
variant === 'plus' && 'collapse-plus', variant === 'plus' && 'collapse-plus',
bordered && 'border base-content/20 border-opacity-20 rounded', bordered && 'border base-content/20 border-opacity-20 rounded-box',
disabled && 'opacity-60 pointer-events-none', disabled && 'opacity-60 pointer-events-none',
!open && 'w-fit', !fullWidth && !open && 'w-fit',
className className
); );