Additional Content

Card-content additions: divider, label, caption, button, double button, search input, tags, segmented (8 sub-shapes, 320 wide).

Installation

$npx @309-thingspire/ui@latest add additional-content

Usage

import { AdditionalContent } from "@/components/additional-content/additional-content"
<AdditionalContent />

Examples

Live preview rendered from AdditionalContent.preview.tsx. Switch to the Code tab to view the underlying component source.

Loading preview…
'use client';

import React, { useState, type ReactNode } from 'react';

import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import { IconMoreLine, IconSearchLine } from '../icons';

import type {
  AdditionalContentProps,
  AdditionalContentSegmentedTab,
} from './AdditionalContent.types';

const palette = colors.primitive.palette;

const captionMRegular = typography.scale.captionM.regular;
const captionMMedium = typography.scale.captionM.medium;
const captionLRegular = typography.scale.captionL.regular;
const captionLMedium = typography.scale.captionL.medium;

const SIDEBAR_WIDTH = spacing.scale['320'];

const labelTextStyle: React.CSSProperties = {
  fontFamily: captionMMedium.fontFamily,
  fontSize: captionMMedium.fontSize,
  fontWeight: captionMMedium.fontWeight,
  lineHeight: `${captionMMedium.lineHeight}px`,
  letterSpacing: `${captionMMedium.letterSpacing}px`,
  color: palette.gray['9a'],
  margin: 0,
  flex: '1 0 0',
  minWidth: 0,
};

const captionTextStyle: React.CSSProperties = {
  fontFamily: captionMRegular.fontFamily,
  fontSize: captionMRegular.fontSize,
  fontWeight: captionMRegular.fontWeight,
  lineHeight: `${captionMRegular.lineHeight}px`,
  letterSpacing: `${captionMRegular.letterSpacing}px`,
  color: palette.gray['7a'],
  margin: 0,
  flex: '1 0 0',
  minWidth: 0,
};

const placeholderTextStyle: React.CSSProperties = {
  fontFamily: captionLRegular.fontFamily,
  fontSize: captionLRegular.fontSize,
  fontWeight: captionLRegular.fontWeight,
  lineHeight: `${captionLRegular.lineHeight}px`,
  letterSpacing: `${captionLRegular.letterSpacing}px`,
  color: palette.gray['7a'],
  margin: 0,
  flex: '1 0 0',
  minWidth: 0,
  border: 'none',
  outline: 'none',
  background: 'transparent',
  paddingInline: spacing.scale['4'],
};

const buttonTextStyle: React.CSSProperties = {
  fontFamily: captionMMedium.fontFamily,
  fontSize: captionMMedium.fontSize,
  fontWeight: captionMMedium.fontWeight,
  lineHeight: `${captionMMedium.lineHeight}px`,
  letterSpacing: `${captionMMedium.letterSpacing}px`,
  margin: 0,
  whiteSpace: 'nowrap',
  textAlign: 'center',
};

const tagTextStyle: React.CSSProperties = {
  fontFamily: captionMMedium.fontFamily,
  fontSize: captionMMedium.fontSize,
  fontWeight: captionMMedium.fontWeight,
  lineHeight: `${captionMMedium.lineHeight}px`,
  letterSpacing: `${captionMMedium.letterSpacing}px`,
  color: palette.gray['9a'],
  margin: 0,
  whiteSpace: 'nowrap',
};

const segmentedTextStyle: React.CSSProperties = {
  fontFamily: captionLMedium.fontFamily,
  fontSize: captionLMedium.fontSize,
  fontWeight: captionLMedium.fontWeight,
  lineHeight: `${captionLMedium.lineHeight}px`,
  letterSpacing: `${captionLMedium.letterSpacing}px`,
  margin: 0,
  whiteSpace: 'nowrap',
  textAlign: 'center',
};

const DEFAULT_TAGS = ['Best sellers', 'Pro access', 'UI Kits', 'Framer'];
const DEFAULT_SEGMENTED_TABS: AdditionalContentSegmentedTab[] = [
  { id: 'tab-1', label: 'Label' },
  { id: 'tab-2', label: 'Label', badge: '12' },
];

function getContainerPadding(type: AdditionalContentProps['type']): React.CSSProperties {
  if (type === 'divider') {
    return { paddingInline: spacing.scale['0'], paddingBlock: spacing.scale['4'] };
  }
  if (type === 'button' || type === 'doubleButton' || type === 'caption') {
    return { paddingInline: spacing.scale['12'], paddingBlock: spacing.scale['8'] };
  }
  // label / searchInput / tags / segmented
  return { paddingInline: spacing.scale['12'], paddingBlock: spacing.scale['6'] };
}

function Tag({ children }: { children: ReactNode }) {
  return (
    <div
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        paddingInline: spacing.scale['6'],
        paddingBlock: spacing.scale['2'],
        backgroundColor: palette.base.white,
        borderStyle: 'solid',
        borderWidth: border.width['1'],
        borderColor: palette.gray['2a'],
        borderRadius: radius.scale.sm,
      }}
    >
      <span style={tagTextStyle}>{children}</span>
    </div>
  );
}

function Segmented({
  tabs = DEFAULT_SEGMENTED_TABS,
  activeId,
  defaultActiveId,
  onChange,
}: {
  tabs?: AdditionalContentSegmentedTab[];
  activeId?: string;
  defaultActiveId?: string;
  onChange?: (id: string) => void;
}) {
  const isControlled = typeof activeId === 'string';
  const [internalActive, setInternalActive] = useState(
    () => defaultActiveId ?? tabs[1]?.id ?? tabs[0]?.id ?? '',
  );
  const active = isControlled ? activeId! : internalActive;

  const handleClick = (id: string) => {
    if (!isControlled) setInternalActive(id);
    onChange?.(id);
  };

  return (
    <div
      role="tablist"
      style={{
        flex: '1 0 0',
        minWidth: 0,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        gap: spacing.scale['2'],
        padding: spacing.scale['2'],
        backgroundColor: palette.gray['2a'],
        borderRadius: radius.scale.lg,
      }}
    >
      {tabs.map((tab, index) => {
        const id = tab.id ?? `seg-${index}`;
        const isActive = active === id;
        return (
          <button
            key={id}
            type="button"
            role="tab"
            aria-selected={isActive}
            onClick={() => handleClick(id)}
            style={{
              flex: '1 0 0',
              minWidth: 0,
              display: 'inline-flex',
              alignItems: 'center',
              justifyContent: 'center',
              gap: spacing.scale['4'],
              paddingInline: spacing.scale['8'],
              paddingBlock: spacing.scale['4'],
              borderRadius: radius.scale.md,
              borderStyle: 'solid',
              borderWidth: isActive ? border.width['1'] : border.width['0'],
              borderColor: isActive ? palette.gray['3'] : 'transparent',
              backgroundColor: isActive ? palette.base.white : 'transparent',
              boxShadow: isActive ? '0px 1px 2px 0px rgba(20,21,26,0.05)' : 'none',
              cursor: 'pointer',
              appearance: 'none',
              outline: 'none',
            }}
          >
            <span
              style={{
                ...segmentedTextStyle,
                color: isActive ? palette.gray['13'] : palette.gray['9a'],
              }}
            >
              {tab.label}
            </span>
            {tab.badge != null ? (
              <span
                style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  paddingInline: spacing.scale['6'],
                  paddingBlock: spacing.scale['2'],
                  backgroundColor: palette.gray['1a'],
                  borderRadius: radius.scale.sm,
                  fontFamily: captionMMedium.fontFamily,
                  fontSize: captionMMedium.fontSize,
                  fontWeight: captionMMedium.fontWeight,
                  lineHeight: `${captionMMedium.lineHeight}px`,
                  letterSpacing: `${captionMMedium.letterSpacing}px`,
                  color: palette.gray['13'],
                  whiteSpace: 'nowrap',
                }}
              >
                {tab.badge}
              </span>
            ) : null}
          </button>
        );
      })}
    </div>
  );
}

export function AdditionalContent({
  type = 'divider',
  text,
  placeholder = 'Search',
  searchValue,
  onSearchChange,
  tags,
  segmentedTabs,
  activeSegmentId,
  defaultActiveSegmentId,
  onSegmentChange,
  buttonLabel = 'Button',
  doubleButtonIcon,
  onButtonClick,
  onDoubleButtonIconClick,
  width = SIDEBAR_WIDTH,
  className,
  style,
}: AdditionalContentProps) {
  const containerPadding = getContainerPadding(type);

  const baseStyle: React.CSSProperties = {
    width,
    boxSizing: 'border-box',
    display: 'flex',
    alignItems: 'center',
    ...containerPadding,
    ...style,
  };

  if (type === 'divider') {
    return (
      <div className={className} style={baseStyle}>
        <div
          aria-hidden="true"
          style={{
            flex: '1 0 0',
            minWidth: 0,
            height: 1,
            backgroundColor: palette.gray['2'],
          }}
        />
      </div>
    );
  }

  if (type === 'label') {
    return (
      <div className={className} style={baseStyle}>
        <p style={labelTextStyle}>{text ?? 'Label'}</p>
      </div>
    );
  }

  if (type === 'caption') {
    return (
      <div className={className} style={{ ...baseStyle, alignItems: 'flex-start', gap: spacing.scale['4'] }}>
        <p style={captionTextStyle}>
          {text ?? 'A caption is a brief description accompanying an illustration'}
        </p>
      </div>
    );
  }

  if (type === 'button') {
    return (
      <div className={className} style={baseStyle}>
        <button
          type="button"
          onClick={onButtonClick}
          style={{
            flex: '1 0 0',
            minWidth: 0,
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            gap: spacing.scale['4'],
            paddingInline: spacing.scale['8'],
            paddingBlock: spacing.scale['4'],
            backgroundColor: palette.gray['13'],
            borderRadius: radius.scale.md,
            borderStyle: 'none',
            color: palette.base.white,
            boxShadow: '0px 1px 2px 0px rgba(20,21,26,0.05)',
            cursor: 'pointer',
            appearance: 'none',
            outline: 'none',
            ...buttonTextStyle,
          }}
        >
          {buttonLabel}
        </button>
      </div>
    );
  }

  if (type === 'doubleButton') {
    return (
      <div className={className} style={{ ...baseStyle, justifyContent: 'space-between', gap: spacing.scale['8'] }}>
        <button
          type="button"
          onClick={onButtonClick}
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            gap: spacing.scale['4'],
            paddingInline: spacing.scale['8'],
            paddingBlock: spacing.scale['4'],
            backgroundColor: palette.gray['1a'],
            borderRadius: radius.scale.md,
            borderStyle: 'none',
            color: palette.gray['13'],
            cursor: 'pointer',
            appearance: 'none',
            outline: 'none',
            ...buttonTextStyle,
          }}
        >
          {buttonLabel}
        </button>
        <button
          type="button"
          aria-label="More"
          onClick={onDoubleButtonIconClick}
          style={{
            width: spacing.scale['24'],
            height: spacing.scale['24'],
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: palette.gray['1a'],
            borderRadius: radius.scale.md,
            borderStyle: 'none',
            cursor: 'pointer',
            appearance: 'none',
            outline: 'none',
            color: palette.gray['9a'],
          }}
        >
          {doubleButtonIcon ?? (
            <IconMoreLine
              aria-hidden
              style={{ width: spacing.scale['14'], height: spacing.scale['14'], display: 'block' }}
            />
          )}
        </button>
      </div>
    );
  }

  if (type === 'searchInput') {
    return (
      <div className={className} style={baseStyle}>
        <div
          style={{
            flex: '1 0 0',
            minWidth: 0,
            display: 'flex',
            alignItems: 'center',
            gap: spacing.scale['4'],
          }}
        >
          <span
            aria-hidden="true"
            style={{
              display: 'inline-flex',
              alignItems: 'center',
              flexShrink: 0,
              color: palette.gray['7a'],
            }}
          >
            <IconSearchLine
              aria-hidden
              style={{ width: spacing.scale['16'], height: spacing.scale['16'], display: 'block' }}
            />
          </span>
          <input
            type="text"
            value={searchValue}
            placeholder={placeholder}
            onChange={onSearchChange ? (event) => onSearchChange(event.target.value) : undefined}
            style={placeholderTextStyle}
          />
        </div>
      </div>
    );
  }

  if (type === 'tags') {
    const items = tags ?? DEFAULT_TAGS;
    return (
      <div className={className} style={{ ...baseStyle, alignItems: 'flex-start' }}>
        <div
          style={{
            flex: '1 0 0',
            minWidth: 0,
            display: 'flex',
            flexWrap: 'wrap',
            gap: spacing.scale['8'],
          }}
        >
          {items.map((item, index) => (
            <Tag key={`tag-${index}`}>{item}</Tag>
          ))}
        </div>
      </div>
    );
  }

  // segmented
  return (
    <div className={className} style={baseStyle}>
      <Segmented
        tabs={segmentedTabs}
        activeId={activeSegmentId}
        defaultActiveId={defaultActiveSegmentId}
        onChange={onSegmentChange}
      />
    </div>
  );
}

export default AdditionalContent;

API Reference

Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)