Card compact and expanded template
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { CardView } from "./Card.view";
|
||||
import type { CardProps } from "./Card.types";
|
||||
|
||||
const CardContainer = memo<CardProps>(
|
||||
({
|
||||
label,
|
||||
supportText = "",
|
||||
recommended = false,
|
||||
selected = false,
|
||||
orientation = "horizontal",
|
||||
showInfoIcon = false,
|
||||
id,
|
||||
className = "",
|
||||
onClick,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardView
|
||||
label={label}
|
||||
supportText={supportText}
|
||||
recommended={recommended}
|
||||
selected={selected}
|
||||
orientation={orientation}
|
||||
showInfoIcon={showInfoIcon}
|
||||
id={id}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardContainer.displayName = "Card";
|
||||
|
||||
export default CardContainer;
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CardProps {
|
||||
label: string;
|
||||
supportText?: string;
|
||||
recommended?: boolean;
|
||||
selected?: boolean;
|
||||
orientation: "horizontal" | "vertical";
|
||||
showInfoIcon?: boolean;
|
||||
/** Optional id for the card root (e.g. data-card-id for focus after modal close). */
|
||||
id?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface CardViewProps {
|
||||
label: string;
|
||||
supportText: string;
|
||||
recommended: boolean;
|
||||
selected: boolean;
|
||||
orientation: "horizontal" | "vertical";
|
||||
showInfoIcon: boolean;
|
||||
id: string | undefined;
|
||||
className: string;
|
||||
onClick: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Tag from "../../utility/Tag";
|
||||
import type { CardViewProps } from "./Card.types";
|
||||
|
||||
function InfoIcon() {
|
||||
return (
|
||||
<span
|
||||
className="flex h-[var(--spacing-scale-016)] w-[var(--spacing-scale-016)] shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent font-inter text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
|
||||
aria-hidden
|
||||
>
|
||||
?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTag({
|
||||
recommended,
|
||||
selected,
|
||||
}: {
|
||||
recommended: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (selected) return <Tag variant="selected" />;
|
||||
if (recommended) return <Tag variant="recommended" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CardView({
|
||||
label,
|
||||
supportText,
|
||||
recommended,
|
||||
selected,
|
||||
orientation,
|
||||
showInfoIcon,
|
||||
id: cardId,
|
||||
className,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}: CardViewProps) {
|
||||
const borderClass = "border border-[var(--color-border-default-primary)]";
|
||||
const selectedBorder = selected
|
||||
? "outline outline-2 outline-dashed outline-black outline-offset-[-2px]"
|
||||
: "";
|
||||
const baseClasses = `rounded-[var(--radius-measures-radius-small)] bg-[#FFFFFF] p-4 transition-all duration-200 cursor-pointer ${borderClass} ${selectedBorder} ${className}`;
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<div
|
||||
{...(cardId ? { "data-card-id": cardId } : {})}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={supportText ? `${label}: ${supportText}` : label}
|
||||
className={baseClasses}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<CardTag recommended={recommended} selected={selected} />
|
||||
<span className="font-inter text-base font-semibold leading-6 text-black w-full">
|
||||
{label}
|
||||
</span>
|
||||
{supportText ? (
|
||||
<p className="font-inter text-sm font-normal leading-5 text-black w-full">
|
||||
{supportText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(cardId ? { "data-card-id": cardId } : {})}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={supportText ? `${label}: ${supportText}` : label}
|
||||
className={`${baseClasses} flex flex-row items-center justify-between gap-4`}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-inter text-base font-semibold leading-6 text-black">
|
||||
{label}
|
||||
</span>
|
||||
{showInfoIcon ? <InfoIcon /> : null}
|
||||
</div>
|
||||
{supportText ? (
|
||||
<p className="font-inter text-sm font-normal leading-5 text-black">
|
||||
{supportText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<CardTag recommended={recommended} selected={selected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Card.container";
|
||||
export type { CardProps } from "./Card.types";
|
||||
@@ -4,7 +4,7 @@ import { memo, forwardRef } from "react";
|
||||
import { useComponentId, useFormField } from "../../../hooks";
|
||||
import { TextAreaView } from "./TextArea.view";
|
||||
import type { TextAreaProps } from "./TextArea.types";
|
||||
import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
|
||||
import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant, normalizeTextAreaAppearance } from "../../../../lib/propNormalization";
|
||||
|
||||
const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
(
|
||||
@@ -27,6 +27,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
textHint = false,
|
||||
formHeader = true,
|
||||
showHelpIcon = false,
|
||||
appearance: appearanceProp = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -35,6 +36,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
const size = normalizeSmallMediumLargeSize(sizeProp);
|
||||
const labelVariant = normalizeLabelVariant(labelVariantProp);
|
||||
const state = normalizeInputState(stateProp);
|
||||
const appearance = normalizeTextAreaAppearance(appearanceProp);
|
||||
// Generate unique ID for accessibility if not provided
|
||||
const { id: textareaId, labelId } = useComponentId("textarea", id);
|
||||
|
||||
@@ -74,11 +76,26 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
},
|
||||
};
|
||||
|
||||
// State styles
|
||||
// State styles (embedded: Figma 20736-12668 – borderless, darker grey block, white text)
|
||||
const getStateStyles = (): {
|
||||
textarea: string;
|
||||
label: string;
|
||||
} => {
|
||||
if (appearance === "embedded") {
|
||||
if (disabled) {
|
||||
return {
|
||||
textarea:
|
||||
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60",
|
||||
label: "text-[var(--color-content-default-secondary)]",
|
||||
};
|
||||
}
|
||||
return {
|
||||
textarea:
|
||||
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset",
|
||||
label: "text-[var(--color-content-default-secondary)]",
|
||||
};
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return {
|
||||
textarea:
|
||||
@@ -138,8 +155,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
: `${currentSize.label} font-inter`;
|
||||
|
||||
const textareaClasses = `
|
||||
w-full border transition-all duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-0 resize-none
|
||||
scrollbar-design w-full transition-all duration-200 ease-in-out resize-none
|
||||
${appearance === "embedded" ? "rounded-[var(--radius-300,12px)]" : "border"}
|
||||
${currentSize.textarea}
|
||||
${stateStyles.textarea}
|
||||
${className}
|
||||
@@ -180,6 +197,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
textHint={textHint}
|
||||
formHeader={formHeader}
|
||||
showHelpIcon={showHelpIcon}
|
||||
appearance={appearance}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { InputStateValue } from "../../../../lib/propNormalization";
|
||||
export type TextAreaSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
|
||||
export type TextAreaLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
|
||||
|
||||
export type TextAreaAppearanceValue = "default" | "embedded" | "Default" | "Embedded";
|
||||
|
||||
export interface TextAreaProps extends Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
"size" | "onChange" | "onFocus" | "onBlur"
|
||||
@@ -47,6 +49,12 @@ export interface TextAreaProps extends Omit<
|
||||
* @default false
|
||||
*/
|
||||
showHelpIcon?: boolean;
|
||||
/**
|
||||
* Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668):
|
||||
* borderless, darker grey background, white text. "default" is standard bordered input.
|
||||
* @default "default"
|
||||
*/
|
||||
appearance?: TextAreaAppearanceValue;
|
||||
}
|
||||
|
||||
export interface TextAreaViewProps {
|
||||
@@ -73,4 +81,5 @@ export interface TextAreaViewProps {
|
||||
textHint?: boolean;
|
||||
formHeader?: boolean;
|
||||
showHelpIcon?: boolean;
|
||||
appearance?: "default" | "embedded";
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
textHint = false,
|
||||
formHeader = true,
|
||||
showHelpIcon = false,
|
||||
appearance: _appearance,
|
||||
// Component-only props: do not pass to DOM
|
||||
size: _size,
|
||||
labelVariant: _labelVariant,
|
||||
state: _state,
|
||||
error: _error,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
|
||||
@@ -10,6 +10,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
headerContent,
|
||||
children,
|
||||
footerContent,
|
||||
showBackButton = true,
|
||||
@@ -113,6 +114,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
headerContent={headerContent}
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
children={children}
|
||||
footerContent={footerContent}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface CreateProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Default header: title + description. Omit to use title/description. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Custom header slot. When set, replaces title/description for full control. */
|
||||
headerContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footerContent?: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
@@ -17,35 +20,12 @@ export interface CreateProps {
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
/**
|
||||
* Whether to enable Create block array content type (Figma prop).
|
||||
* @default false
|
||||
*/
|
||||
/** Figma / design alignment (unused in implementation). */
|
||||
createBlockArray?: boolean;
|
||||
/**
|
||||
* Whether to enable Text input content type (Figma prop).
|
||||
* @default false
|
||||
*/
|
||||
textInput?: boolean;
|
||||
/**
|
||||
* Whether to enable Text area content type (Figma prop).
|
||||
* @default false
|
||||
*/
|
||||
textArea?: boolean;
|
||||
/**
|
||||
* Whether to enable Multi-select content type (Figma prop).
|
||||
* @default false
|
||||
*/
|
||||
multiSelect?: boolean;
|
||||
/**
|
||||
* Whether to enable Upload content type (Figma prop).
|
||||
* @default false
|
||||
*/
|
||||
upload?: boolean;
|
||||
/**
|
||||
* Whether to enable Proportion content type (Figma prop).
|
||||
* @default false
|
||||
*/
|
||||
proportion?: boolean;
|
||||
}
|
||||
|
||||
@@ -54,6 +34,7 @@ export interface CreateViewProps {
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
headerContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footerContent?: React.ReactNode;
|
||||
showBackButton: boolean;
|
||||
|
||||
@@ -11,6 +11,7 @@ export function CreateView({
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
headerContent,
|
||||
children,
|
||||
footerContent,
|
||||
showBackButton,
|
||||
@@ -40,21 +41,23 @@ export function CreateView({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Create Dialog */}
|
||||
{/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
|
||||
<div
|
||||
ref={createRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[728px] flex flex-col overflow-hidden z-[9999] ${className}`}
|
||||
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
|
||||
>
|
||||
{/* Header with close buttons */}
|
||||
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
|
||||
|
||||
{/* Header Lockup Section (Sticky) */}
|
||||
{(title || description) && (
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0 sticky top-[48px] z-[2]">
|
||||
{/* Header: custom headerContent (when provided) or default title/description */}
|
||||
{headerContent !== undefined ? (
|
||||
<div className="shrink-0">{headerContent}</div>
|
||||
) : (title || description) ? (
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={title}
|
||||
description={description}
|
||||
@@ -62,14 +65,14 @@ export function CreateView({
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Content Area (Scrollable) */}
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-024)] px-[24px] pb-[96px] overflow-x-clip overflow-y-auto relative shrink-0 flex-1">
|
||||
{/* Content Area (scrollable when content overflows) */}
|
||||
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer (always visible at bottom of modal) */}
|
||||
<ModalFooter
|
||||
showBackButton={showBackButton}
|
||||
showNextButton={showNextButton}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { CardStackView } from "./CardStack.view";
|
||||
import type { CardStackProps } from "./CardStack.types";
|
||||
|
||||
const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
|
||||
const DEFAULT_SHOW_LESS_LABEL = "Show less";
|
||||
|
||||
const CardStackContainer = memo<CardStackProps>(
|
||||
({
|
||||
cards,
|
||||
selectedId: controlledSelectedId,
|
||||
selectedIds: controlledSelectedIds,
|
||||
onCardSelect: controlledOnCardSelect,
|
||||
expanded: controlledExpanded,
|
||||
onToggleExpand: controlledOnToggleExpand,
|
||||
hasMore = true,
|
||||
toggleLabel = DEFAULT_TOGGLE_LABEL,
|
||||
showLessLabel = DEFAULT_SHOW_LESS_LABEL,
|
||||
title = "",
|
||||
description = "",
|
||||
className = "",
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const expanded =
|
||||
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (controlledOnToggleExpand) {
|
||||
controlledOnToggleExpand();
|
||||
} else {
|
||||
setInternalExpanded((prev) => !prev);
|
||||
}
|
||||
}, [controlledOnToggleExpand]);
|
||||
|
||||
const selectedIds =
|
||||
controlledSelectedIds !== undefined
|
||||
? controlledSelectedIds
|
||||
: controlledSelectedId !== undefined
|
||||
? (controlledSelectedId ? [controlledSelectedId] : [])
|
||||
: internalSelectedIds;
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (controlledOnCardSelect) {
|
||||
controlledOnCardSelect(id);
|
||||
} else {
|
||||
setInternalSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
}
|
||||
},
|
||||
[controlledOnCardSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardStackView
|
||||
cards={cards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={hasMore}
|
||||
toggleLabel={toggleLabel}
|
||||
showLessLabel={showLessLabel}
|
||||
title={title}
|
||||
description={description}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardStackContainer.displayName = "CardStack";
|
||||
|
||||
export default CardStackContainer;
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface CardStackItem {
|
||||
id: string;
|
||||
label: string;
|
||||
supportText?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface CardStackProps {
|
||||
cards: CardStackItem[];
|
||||
selectedId?: string | null;
|
||||
selectedIds?: string[];
|
||||
onCardSelect?: (id: string) => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
hasMore?: boolean;
|
||||
toggleLabel?: string;
|
||||
showLessLabel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CardStackViewProps {
|
||||
cards: CardStackItem[];
|
||||
selectedIds: string[];
|
||||
onCardSelect: (id: string) => void;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
hasMore: boolean;
|
||||
toggleLabel: string;
|
||||
showLessLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
className: string;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../type/HeaderLockup";
|
||||
import Card from "../../cards/Card";
|
||||
import type { CardStackViewProps } from "./CardStack.types";
|
||||
|
||||
export function CardStackView({
|
||||
cards,
|
||||
selectedIds,
|
||||
onCardSelect,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
hasMore,
|
||||
toggleLabel,
|
||||
showLessLabel,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: CardStackViewProps) {
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: recommended only (up to 5). Expanded: all cards.
|
||||
const compactCards = cards
|
||||
.filter((c) => c.recommended ?? false)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{(title || description) ? (
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{expanded ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6 w-full">
|
||||
{cards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="vertical"
|
||||
showInfoIcon={true}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Compact under 640: single column, up to 5 recommended cards */}
|
||||
<div className="flex flex-col gap-6 w-full md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="vertical"
|
||||
showInfoIcon={true}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 2–3 and 4–5) */}
|
||||
<div className="hidden md:grid grid-cols-6 gap-x-4 gap-y-6 w-full">
|
||||
{compactCards.map((item, index) => {
|
||||
const colClass =
|
||||
index <= 2
|
||||
? "md:col-span-2"
|
||||
: index === 3 && compactCards.length === 4
|
||||
? "md:col-start-3 md:col-span-2"
|
||||
: index === 3
|
||||
? "md:col-start-2 md:col-span-2"
|
||||
: "md:col-start-4 md:col-span-2";
|
||||
return (
|
||||
<div key={item.id} className={colClass}>
|
||||
<Card
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none self-center cursor-pointer"
|
||||
>
|
||||
{expanded ? showLessLabel : toggleLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./CardStack.container";
|
||||
export type { CardStackProps, CardStackItem } from "./CardStack.types";
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ScrollbarProps {
|
||||
/** Content to scroll. */
|
||||
children: ReactNode;
|
||||
/** Optional class name merged with scrollbar-design. */
|
||||
className?: string;
|
||||
/** Vertical scroll only, horizontal only, or both. @default "vertical" */
|
||||
orientation?: "vertical" | "horizontal" | "both";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ScrollbarProps } from "./Scrollbar.types";
|
||||
|
||||
const overflowClass = {
|
||||
vertical: "overflow-x-clip overflow-y-auto",
|
||||
horizontal: "overflow-y-clip overflow-x-auto",
|
||||
both: "overflow-auto",
|
||||
} as const;
|
||||
|
||||
export function ScrollbarView({
|
||||
children,
|
||||
className = "",
|
||||
orientation = "vertical",
|
||||
}: ScrollbarProps) {
|
||||
return (
|
||||
<div
|
||||
className={`scrollbar-design ${overflowClass[orientation]} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ScrollbarView as default } from "./Scrollbar.view";
|
||||
export type { ScrollbarProps } from "./Scrollbar.types";
|
||||
|
||||
/** Class name to apply the design system scrollbar to any scrollable element (e.g. textarea, div). */
|
||||
export const SCROLLBAR_DESIGN_CLASS = "scrollbar-design";
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { TagView } from "./Tag.view";
|
||||
import type { TagProps } from "./Tag.types";
|
||||
|
||||
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
|
||||
recommended: "RECOMMENDED",
|
||||
selected: "SELECTED",
|
||||
};
|
||||
|
||||
const TagContainer = memo<TagProps>(
|
||||
({ variant, children, className = "" }) => {
|
||||
const content = children ?? DEFAULT_LABELS[variant];
|
||||
return (
|
||||
<TagView variant={variant} className={className}>
|
||||
{content}
|
||||
</TagView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TagContainer.displayName = "Tag";
|
||||
|
||||
export default TagContainer;
|
||||
@@ -0,0 +1,15 @@
|
||||
export type TagVariant = "recommended" | "selected";
|
||||
|
||||
export interface TagProps {
|
||||
/** Visual variant: recommended (yellow) or selected (dark) */
|
||||
variant: TagVariant;
|
||||
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TagViewProps {
|
||||
variant: TagVariant;
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import type { TagViewProps } from "./Tag.types";
|
||||
|
||||
/**
|
||||
* Tag view – Figma 17861-22238.
|
||||
* Recommended: light yellow bg (#F6EEA7), dark text (#3F3F3F).
|
||||
* Selected: dark bg (#3F3F3F), white text (#FFFFFF).
|
||||
* Typography: Inter Medium 10px, line-height 12, uppercase.
|
||||
*/
|
||||
export function TagView({ variant, children, className }: TagViewProps) {
|
||||
const isRecommended = variant === "recommended";
|
||||
const bgClass = isRecommended
|
||||
? "bg-[#F6EEA7]"
|
||||
: "bg-[#3F3F3F]";
|
||||
const textClass = isRecommended
|
||||
? "text-[#3F3F3F]"
|
||||
: "text-[#FFFFFF]";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded px-2 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 ${bgClass} ${textClass} ${className}`}
|
||||
role="status"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Tag.container";
|
||||
export type { TagProps, TagVariant } from "./Tag.types";
|
||||
Reference in New Issue
Block a user