diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c20ab4..5cf90cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,9 @@ ## [0.17.1](https://github.com/frontapp/front-ui-kit/compare/v0.17.0...v0.17.1) (2025-10-22) - ### Bug Fixes -* dropdown border color ([#293](https://github.com/frontapp/front-ui-kit/issues/293)) ([eb7a37c](https://github.com/frontapp/front-ui-kit/commit/eb7a37c8a5cb898eab020828ba9e03fee19c1879)) +- dropdown border color ([#293](https://github.com/frontapp/front-ui-kit/issues/293)) ([eb7a37c](https://github.com/frontapp/front-ui-kit/commit/eb7a37c8a5cb898eab020828ba9e03fee19c1879)) ## [0.17.0](https://github.com/frontapp/front-ui-kit/compare/v0.16.1...v0.17.0) (2025-10-22) diff --git a/src/components/card/__docs__/docs.mdx b/src/components/card/__docs__/docs.mdx new file mode 100644 index 00000000..a14156e1 --- /dev/null +++ b/src/components/card/__docs__/docs.mdx @@ -0,0 +1,88 @@ +import {Meta} from '@storybook/blocks'; +import {Card} from '../card'; + + + +# Card + +A flexible container component that can be used to group related content together. Cards are commonly used to display information in a structured and visually appealing way. + +## Basic Usage + +```tsx + + Card Title + This is the main content of the card. + Card footer content + +``` + +## Features + +- **Flexible Layout**: Cards can contain header, body, and footer sections +- **Multiple Sizes**: Support for small, medium, and large sizes +- **Customizable Styling**: Options for shadows, borders, and click interactions +- **Accessible**: Proper semantic structure and keyboard navigation support + +## Props + +### Card Props + +| Prop | Type | Default | Description | +| ------------- | ----------------- | -------- | --------------------------------- | +| `children` | `ReactNode` | - | Content to render inside the card | +| `size` | `VisualSizesEnum` | `MEDIUM` | The size of the card | +| `hasShadow` | `boolean` | `true` | Whether the card has a shadow | +| `hasBorder` | `boolean` | `false` | Whether the card has a border | +| `className` | `string` | - | Class name for custom styling | +| `isClickable` | `boolean` | `false` | Whether the card is clickable | +| `onClick` | `() => void` | - | Called when the card is clicked | + +### CardHeader Props + +| Prop | Type | Default | Description | +| ----------- | ----------------- | -------- | -------------------------------------- | +| `children` | `ReactNode` | - | Content to render inside the header | +| `size` | `VisualSizesEnum` | `MEDIUM` | The size of the header | +| `className` | `string` | - | Class name for custom styling | +| `hasBorder` | `boolean` | `false` | Whether the header has a bottom border | + +### CardBody Props + +| Prop | Type | Default | Description | +| ------------ | ----------------- | -------- | --------------------------------- | +| `children` | `ReactNode` | - | Content to render inside the body | +| `size` | `VisualSizesEnum` | `MEDIUM` | The size of the body | +| `className` | `string` | - | Class name for custom styling | +| `hasPadding` | `boolean` | `false` | Whether the body has padding | + +### CardFooter Props + +| Prop | Type | Default | Description | +| ----------- | ----------------- | -------- | ----------------------------------- | +| `children` | `ReactNode` | - | Content to render inside the footer | +| `size` | `VisualSizesEnum` | `MEDIUM` | The size of the footer | +| `className` | `string` | - | Class name for custom styling | +| `hasBorder` | `boolean` | `false` | Whether the footer has a top border | + +## Examples + +### Basic Card + +A simple card with all three sections. + +### Card with Border + +A card with a visible border instead of a shadow. + +### Clickable Card + +A card that responds to click events with hover effects. + +### Different Sizes + +Cards in small, medium, and large sizes. + +### Card with Custom Content + +Examples of cards with various content types like images, buttons, and text. diff --git a/src/components/card/__docs__/index.stories.tsx b/src/components/card/__docs__/index.stories.tsx new file mode 100644 index 00000000..c7650182 --- /dev/null +++ b/src/components/card/__docs__/index.stories.tsx @@ -0,0 +1,264 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import React, {useState} from 'react'; + +import {greys, palette} from '../../../helpers/colorHelpers'; +import {fontSizes, fontWeights, VisualSizesEnum} from '../../../helpers/fontHelpers'; +import {DefaultStyleProvider} from '../../../utils/defaultStyleProvider'; +import {Button} from '../../button/button'; +import {Checkbox} from '../../checkbox/checkbox'; +import {Card} from '../card'; + +const meta: Meta = { + title: 'Components/Card', + component: Card, + parameters: { + layout: 'padded' + }, + argTypes: { + size: { + control: {type: 'select'}, + options: ['SMALL', 'MEDIUM', 'LARGE'] + } + } +}; + +export default meta; +type Story = StoryObj; + +export const WithButtons: Story = { + render: (args) => ( + + + Card with Buttons + This card contains buttons in the footer. + +
+ + +
+
+
+
+ ), + decorators: [ + (Story) => ( +
+ +
+ ) + ] +}; + +const WithCheckboxAndBodyComponent = () => { + const [isChecked, setIsChecked] = useState(false); + + return ( + + + + + + Customer Feedback + + + + +
+ Problem - 30 Sep, 2025 +
+
+
+
+ ); +}; + +export const WithCheckboxAndBody: Story = { + render: () => , + decorators: [ + (Story) => ( +
+ +
+ ) + ] +}; + +const WithCheckboxBodyAndFooterComponent = () => { + const [isChecked, setIsChecked] = useState(false); + + return ( + + console.log('Edit clicked!') + }, + { + label: 'Log content', + icon: 'AttachmentGeneric', + tooltip: 'Log content', + onClick: () => console.log('Log content clicked!') + } + ]}> + + + + Customer Feedback + + + + +
+ Speak with Lance about pricing proposal +
+
+ +
+ Call - 30 Sep, 2025 +
+
+
+
+ ); +}; + +export const WithCheckboxBodyAndFooter: Story = { + args: { + size: VisualSizesEnum.MEDIUM + }, + render: () => , + + decorators: [ + (Story) => ( +
+ +
+ ) + ] +}; + +export const CardWithActions: Story = { + render: (args) => ( + +
+ console.log('Edit clicked!') + } + ]}> + Card with Single Action + Hover over this card to see the edit action in the top right corner. + Card footer content + + console.log('Edit clicked!') + }, + { + label: 'Duplicate', + icon: 'Copy', + tooltip: 'Duplicate this card', + onClick: () => console.log('Duplicate clicked!') + }, + { + label: 'Delete', + icon: 'Trash', + tooltip: 'Delete this card', + onClick: () => console.log('Delete clicked!') + } + ]}> + Actions Show on Hover + + Hover over this card to see the action menu (three dots) in the top right corner. + + Card footer content + + console.log('Edit clicked!') + }, + { + label: 'Copy', + icon: 'Copy', + tooltip: 'Copy this card', + onClick: () => console.log('Copy clicked!') + }, + { + label: 'Share', + icon: 'ExternalLink', + tooltip: 'Share this card', + onClick: () => console.log('Share clicked!') + } + ]}> + Grouped Actions (groupActions=true) + + When groupActions=true, all actions are grouped into a single dropdown menu. This keeps the + interface clean and compact, especially useful when you have many actions or limited space. + + Card footer content + +
+
+ ), + decorators: [ + (Story) => ( +
+ +
+ ) + ] +}; diff --git a/src/components/card/card.tsx b/src/components/card/card.tsx new file mode 100644 index 00000000..d86f8cec --- /dev/null +++ b/src/components/card/card.tsx @@ -0,0 +1,183 @@ +import {FC, ReactNode} from 'react'; +import styled from 'styled-components'; + +import {Icon, IconName} from '../../elements/icon/icon'; +import {greys} from '../../helpers/colorHelpers'; +import {VisualSizesEnum} from '../../helpers/fontHelpers'; +import {makeSizeConstants} from '../../helpers/styleHelpers'; +import {ActionMenu} from '../_pre-built/actionMenu/actionMenu'; +import {ActionMenuItem} from '../_pre-built/actionMenu/actionMenuItem'; +import {Button} from '../button/button'; +import {Tooltip} from '../tooltip/tooltip'; +import {TooltipCoordinator} from '../tooltip/tooltipCoordinator'; +import {CardBody} from './cardBody'; +import {CardFooter} from './cardFooter'; +import {CardHeader} from './cardHeader'; + +/* + * Props. + */ + +export interface CardAction { + /** The label for the action. */ + label: string; + /** The icon name for the action. */ + icon?: IconName; + /** The tooltip text for the action (optional, defaults to label). */ + tooltip?: string; + /** Called when the action is clicked. */ + onClick: () => void; +} + +export interface CardProps { + /** Content to render inside the card. */ + children?: ReactNode; + /** The size of the card. */ + size?: VisualSizesEnum; + /** Class name to allow custom styling of the card. */ + className?: string; + /** Optional actions to display in the top right corner. */ + actions?: CardAction[]; + /** Whether actions should only be visible on hover (default: true - actions always visible). */ + showActionsOnHover?: boolean; + /** Whether to group actions into a dropdown menu (default: false - show as individual icon buttons). */ + groupActions?: boolean; +} + +/* + * Style. + */ + +interface StyledCardProps { + $size: VisualSizesEnum; +} + +const cardPadding = makeSizeConstants(12, 16, 20); +const cardBorderRadius = makeSizeConstants(6, 8, 10); + +const StyledCard = styled.div` + display: flex; + flex-direction: column; + background: ${greys.white}; + border-radius: ${(p) => cardBorderRadius[p.$size]}px; + box-sizing: border-box; + overflow: hidden; + width: 100%; + + /* Padding based on size */ + padding: ${(p) => cardPadding[p.$size]}px; +`; + +const RelativeContainer = styled.div` + position: relative; + width: 100%; + height: 100%; +`; + +const ActionButtonContainer = styled.div<{zIndex?: number; $showOnHover?: boolean}>` + position: absolute; + top: 8px; + right: 8px; + z-index: ${(p) => p.zIndex || 10}; + display: flex; + align-items: center; + gap: 4px; + opacity: ${(p) => (p.$showOnHover ? 0 : 1)}; + transition: opacity 0.2s ease; + + /* Show on parent hover (only if showOnHover is true) */ + ${(p) => + p.$showOnHover && + `${RelativeContainer}:hover & { + opacity: 1; + }`} +`; + +/* + * Component. + */ + +const CardComponent: FC = ({ + children, + size = VisualSizesEnum.MEDIUM, + className, + actions = [], + showActionsOnHover = false, + groupActions = false +}) => { + const handleActionClick = (action: CardAction) => { + action.onClick(); + }; + + // Render actions based on groupActions setting + const renderActions = () => { + // Return null if no actions + if (actions.length === 0) return null; + + if (groupActions) + // Group all actions into a dropdown menu + return ( + + + {actions.map((action) => ( + { + handleActionClick(action); + }}> + {action.label} + + ))} + + + ); + + // Show actions as individual icon buttons + return ( + + {actions.map((action) => ( + {action.tooltip ?? action.label}}> + + + ))} + + ); + }; + + return ( + + {children} + {renderActions()} + + ); +}; + +/* + * Sub-components. + */ + +// Create a compound component by assigning sub-components to the main component +const Card = Object.assign(CardComponent, { + Header: CardHeader, + Body: CardBody, + Footer: CardFooter +}) as typeof CardComponent & { + Header: typeof CardHeader; + Body: typeof CardBody; + Footer: typeof CardFooter; +}; + +Card.Header = CardHeader; +Card.Body = CardBody; +Card.Footer = CardFooter; + +export {Card}; diff --git a/src/components/card/cardBody.tsx b/src/components/card/cardBody.tsx new file mode 100644 index 00000000..8e6c1450 --- /dev/null +++ b/src/components/card/cardBody.tsx @@ -0,0 +1,58 @@ +import {FC, ReactNode} from 'react'; +import styled from 'styled-components'; + +import {greys} from '../../helpers/colorHelpers'; +import {VisualSizesEnum} from '../../helpers/fontHelpers'; +import {makeSizeConstants} from '../../helpers/styleHelpers'; + +/* + * Props. + */ + +export interface CardBodyProps { + /** Content to render inside the card body. */ + children?: ReactNode; + /** The size of the card body. */ + size?: VisualSizesEnum; + /** Class name to allow custom styling of the card body. */ + className?: string; + /** Whether the body has padding. */ + hasPadding?: boolean; +} + +/* + * Style. + */ + +interface StyledCardBodyProps { + $size: VisualSizesEnum; + $hasPadding: boolean; +} + +const bodyPadding = makeSizeConstants(0, 0, 0); +const bodyMarginVertical = makeSizeConstants(4, 8, 12); + +const StyledCardBody = styled.div` + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + margin: ${(p) => (p.$hasPadding ? `${bodyMarginVertical[p.$size]}px 0` : '0')}; + padding: ${(p) => (p.$hasPadding ? `${bodyPadding[p.$size]}px` : '0')}; + color: ${greys.shade80}; +`; + +/* + * Component. + */ + +export const CardBody: FC = ({ + children, + size = VisualSizesEnum.MEDIUM, + className, + hasPadding = false +}) => ( + + {children} + +); diff --git a/src/components/card/cardFooter.tsx b/src/components/card/cardFooter.tsx new file mode 100644 index 00000000..f5af00a4 --- /dev/null +++ b/src/components/card/cardFooter.tsx @@ -0,0 +1,62 @@ +import {FC, ReactNode} from 'react'; +import styled from 'styled-components'; + +import {greys} from '../../helpers/colorHelpers'; +import {VisualSizesEnum} from '../../helpers/fontHelpers'; +import {makeSizeConstants} from '../../helpers/styleHelpers'; + +/* + * Props. + */ + +export interface CardFooterProps { + /** Content to render inside the card footer. */ + children?: ReactNode; + /** The size of the card footer. */ + size?: VisualSizesEnum; + /** Class name to allow custom styling of the card footer. */ + className?: string; + /** Whether the footer has a top border. */ + hasBorder?: boolean; +} + +/* + * Style. + */ + +interface StyledCardFooterProps { + $size: VisualSizesEnum; + $hasBorder: boolean; +} + +const footerPadding = makeSizeConstants(0, 0, 0); +const footerMarginTop = makeSizeConstants(8, 12, 16); +const footerBorderRadius = makeSizeConstants(6, 8, 10); + +const StyledCardFooter = styled.div` + display: flex; + align-items: center; + width: 100%; + margin-top: ${(p) => footerMarginTop[p.$size]}px; + padding: ${(p) => footerPadding[p.$size]}px; + border-radius: 0 0 ${(p) => footerBorderRadius[p.$size]}px ${(p) => footerBorderRadius[p.$size]}px; + + /* Border styling */ + border-top: ${(p) => (p.$hasBorder ? `1px solid ${greys.shade20}` : 'none')}; + padding-top: ${(p) => (p.$hasBorder ? footerMarginTop[p.$size] : 0)}px; +`; + +/* + * Component. + */ + +export const CardFooter: FC = ({ + children, + size = VisualSizesEnum.MEDIUM, + className, + hasBorder = false +}) => ( + + {children} + +); diff --git a/src/components/card/cardHeader.tsx b/src/components/card/cardHeader.tsx new file mode 100644 index 00000000..f43759cd --- /dev/null +++ b/src/components/card/cardHeader.tsx @@ -0,0 +1,62 @@ +import {FC, ReactNode} from 'react'; +import styled from 'styled-components'; + +import {greys} from '../../helpers/colorHelpers'; +import {VisualSizesEnum} from '../../helpers/fontHelpers'; +import {makeSizeConstants} from '../../helpers/styleHelpers'; + +/* + * Props. + */ + +export interface CardHeaderProps { + /** Content to render inside the card header. */ + children?: ReactNode; + /** The size of the card header. */ + size?: VisualSizesEnum; + /** Class name to allow custom styling of the card header. */ + className?: string; + /** Whether the header has a bottom border. */ + hasBorder?: boolean; +} + +/* + * Style. + */ + +interface StyledCardHeaderProps { + $size: VisualSizesEnum; + $hasBorder: boolean; +} + +const headerPadding = makeSizeConstants(0, 0, 0); +const headerMarginBottom = makeSizeConstants(4, 8, 12); +const headerBorderRadius = makeSizeConstants(6, 8, 10); + +const StyledCardHeader = styled.div` + display: flex; + align-items: center; + width: 100%; + margin-bottom: ${(p) => headerMarginBottom[p.$size]}px; + padding: ${(p) => headerPadding[p.$size]}px; + border-radius: ${(p) => headerBorderRadius[p.$size]}px ${(p) => headerBorderRadius[p.$size]}px 0 0; + + /* Border styling */ + border-bottom: ${(p) => (p.$hasBorder ? `1px solid ${greys.shade20}` : 'none')}; + padding-bottom: ${(p) => (p.$hasBorder ? headerMarginBottom[p.$size] : 0)}px; +`; + +/* + * Component. + */ + +export const CardHeader: FC = ({ + children, + size = VisualSizesEnum.MEDIUM, + className, + hasBorder = false +}) => ( + + {children} + +); diff --git a/src/index.ts b/src/index.ts index ddb3468e..02581326 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,11 @@ export {ButtonContent} from './components/button/buttonContent'; export {ButtonContentIcon} from './components/button/buttonContentIcon'; export {ButtonGroup} from './components/button/buttonGroup'; +export {Card} from './components/card/card'; +export {CardHeader} from './components/card/cardHeader'; +export {CardBody} from './components/card/cardBody'; +export {CardFooter} from './components/card/cardFooter'; + export {Checkbox} from './components/checkbox/checkbox'; export {DatePickerDropdown as DatePicker} from './components/datepicker/datepickerDropdown';