Task Card

Card surface representing a task.

Installation

$npx @309-thingspire/ui@latest add task-card

Usage

import { TaskCard } from "@/components/task-card/task-card"
<TaskCard />

Examples

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

Loading preview…
import React, { useState } from 'react';

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

import type { TaskCardProps, TaskCardState } from './TaskCard.types';

type TypographyToken = {
  fontFamily: string;
  fontSize: number;
  fontWeight: number;
  lineHeight: number;
  letterSpacing: number;
};

const palette = colors.primitive.palette;
const textTokens = colors.semantic.theme.text.base;

function getTypographyStyle(token: TypographyToken) {
  return {
    fontFamily: token.fontFamily,
    fontSize: token.fontSize,
    fontWeight: token.fontWeight,
    lineHeight: `${token.lineHeight}px`,
    letterSpacing: `${token.letterSpacing}px`,
  };
}

function resolveState(forcedState: TaskCardState | undefined, hovered: boolean, interactive: boolean): TaskCardState {
  if (forcedState) {
    return forcedState;
  }

  if (interactive && hovered) {
    return 'hover';
  }

  return 'default';
}

function Badge({ label }: { label: string }) {
  return (
    <div
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        paddingInline: spacing.scale['4'],
        paddingBlock: spacing.scale['2'],
        borderRadius: radius.scale.md,
        backgroundColor: palette.gray['2'],
      }}
    >
      <span
        style={{
          display: 'inline-flex',
          alignItems: 'center',
          justifyContent: 'center',
          paddingInline: spacing.scale['4'],
          paddingBlock: spacing.scale['0'],
          color: textTokens.staticDarkSecondary,
          ...getTypographyStyle(typography.scale.captionL.medium),
          whiteSpace: 'nowrap',
        }}
      >
        {label}
      </span>
    </div>
  );
}

export function TaskCard({
  state,
  interactive = true,
  headline = 'Create a new job post',
  description = 'Design better and spend less time without restricting creative freedom.',
  caption = 'CFW-481',
  tags = ['Design', 'Hiring'],
  onClick,
  className,
  style,
  onMouseEnter,
  onMouseLeave,
  onKeyDown,
  ...props
}: TaskCardProps) {
  const [hovered, setHovered] = useState(false);

  const resolvedState = resolveState(state, hovered, interactive);
  const isInteractive = Boolean(onClick) || interactive;

  const handleKeyDown: React.KeyboardEventHandler<HTMLElement> = (event) => {
    if (!onClick) {
      onKeyDown?.(event);
      return;
    }

    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      onClick();
    }

    onKeyDown?.(event);
  };

  return (
    <article
      className={className}
      role={onClick ? 'button' : undefined}
      tabIndex={onClick ? 0 : undefined}
      onClick={onClick}
      onKeyDown={handleKeyDown}
      onMouseEnter={(event) => {
        if (!state && interactive) {
          setHovered(true);
        }
        onMouseEnter?.(event);
      }}
      onMouseLeave={(event) => {
        if (!state && interactive) {
          setHovered(false);
        }
        onMouseLeave?.(event);
      }}
      style={{
        width: spacing.scale['400'],
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'flex-start',
        gap: spacing.scale['12'],
        padding: spacing.scale['16'],
        borderStyle: 'solid',
        borderWidth: border.width['1'],
        borderColor: palette.gray['3'],
        borderRadius: radius.scale.lg,
        backgroundColor: resolvedState === 'hover' ? palette.gray['1'] : palette.base.white,
        boxShadow: shadows.elevation.xs.css,
        boxSizing: 'border-box',
        cursor: isInteractive ? 'pointer' : 'default',
        ...style,
      }}
      {...props}
    >
      <div
        style={{
          width: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'flex-start',
          gap: spacing.scale['4'],
        }}
      >
        <h3
          style={{
            margin: spacing.scale['0'],
            width: '100%',
            color: textTokens.staticDark,
            ...getTypographyStyle(typography.scale.bodyS.medium),
          }}
        >
          {headline}
        </h3>

        <p
          style={{
            margin: spacing.scale['0'],
            width: '100%',
            color: textTokens.staticDarkSecondary,
            ...getTypographyStyle(typography.scale.captionL.regular),
          }}
        >
          {description}
        </p>

        <p
          style={{
            margin: spacing.scale['0'],
            width: '100%',
            color: textTokens.staticDarkTertiary,
            ...getTypographyStyle(typography.scale.captionM.medium),
          }}
        >
          {caption}
        </p>
      </div>

      <div
        style={{
          width: '100%',
          display: 'flex',
          flexWrap: 'wrap',
          alignItems: 'flex-start',
          gap: spacing.scale['8'],
        }}
      >
        {tags.map((tag) => (
          <Badge key={tag} label={tag} />
        ))}
      </div>
    </article>
  );
}

API Reference

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