feat(primitives): Button white + Card glow + StatCard + KbdHint + PageHeader + AppShellLayout — 7C.4
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export const Card = styled.div<{ $hoverable?: boolean }>`
|
||||
export const Card = styled.div<{
|
||||
$hoverable?: boolean;
|
||||
$variant?: 'default' | 'hoverable' | 'glow';
|
||||
}>`
|
||||
background: ${theme.color.surface};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.lg};
|
||||
padding: ${theme.space.lg};
|
||||
position: relative;
|
||||
|
||||
${({ $hoverable }) =>
|
||||
$hoverable &&
|
||||
${({ $hoverable, $variant }) =>
|
||||
($hoverable || $variant === 'hoverable') &&
|
||||
css`
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
cursor: pointer;
|
||||
@@ -19,10 +24,37 @@ export const Card = styled.div<{ $hoverable?: boolean }>`
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`}
|
||||
|
||||
${({ $variant }) =>
|
||||
$variant === 'glow' &&
|
||||
css`
|
||||
overflow: hidden;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(99, 102, 241, 0.18) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
& > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Button = styled.button<{
|
||||
$variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
$variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'white';
|
||||
$block?: boolean;
|
||||
$size?: 'sm' | 'md' | 'lg';
|
||||
}>`
|
||||
@@ -102,6 +134,16 @@ export const Button = styled.button<{
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
`;
|
||||
case 'white':
|
||||
return css`
|
||||
background: #ffffff;
|
||||
color: ${theme.color.bg};
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
&:hover:not(:disabled) {
|
||||
background: ${theme.color.textBright};
|
||||
box-shadow: ${theme.shadow.md};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -273,3 +315,291 @@ export const Selectable = styled.span.attrs<{ 'data-selectable'?: string }>({
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
`;
|
||||
|
||||
// ─── Phase 7 추가 컴포넌트 ──────────────────────────────────────────────────
|
||||
|
||||
// StatCard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StatCardDelta {
|
||||
value: string;
|
||||
direction: 'up' | 'down';
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
delta?: StatCardDelta;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const StatCardRoot = styled.div`
|
||||
background: ${theme.color.surface};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.lg};
|
||||
padding: ${theme.space.lg};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const StatCardHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StatCardLabel = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
`;
|
||||
|
||||
const StatCardIconSlot = styled.span`
|
||||
color: ${theme.color.textMute};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StatCardValueRow = styled.div`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: ${theme.space.xs};
|
||||
`;
|
||||
|
||||
const StatCardValue = styled.span`
|
||||
font-family: ${theme.font.display};
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: ${theme.color.textMain};
|
||||
font-variant-numeric: tabular-nums;
|
||||
`;
|
||||
|
||||
const StatCardUnit = styled.span`
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const StatCardDeltaBadge = styled.span<{ $direction: 'up' | 'down' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
${({ $direction }) =>
|
||||
$direction === 'up'
|
||||
? css`
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: ${theme.color.success};
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
`
|
||||
: css`
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: ${theme.color.danger};
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
`}
|
||||
`;
|
||||
|
||||
const StatCardChildren = styled.div`
|
||||
margin-top: ${theme.space.xs};
|
||||
`;
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
delta,
|
||||
icon,
|
||||
children,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<StatCardRoot>
|
||||
<StatCardHeader>
|
||||
<StatCardLabel>{label}</StatCardLabel>
|
||||
{icon && <StatCardIconSlot>{icon}</StatCardIconSlot>}
|
||||
</StatCardHeader>
|
||||
<StatCardValueRow>
|
||||
<StatCardValue>{value}</StatCardValue>
|
||||
{unit && <StatCardUnit>{unit}</StatCardUnit>}
|
||||
{delta && (
|
||||
<StatCardDeltaBadge $direction={delta.direction} title={delta.hint}>
|
||||
{delta.direction === 'up' ? '▲' : '▼'} {delta.value}
|
||||
</StatCardDeltaBadge>
|
||||
)}
|
||||
</StatCardValueRow>
|
||||
{children && <StatCardChildren>{children}</StatCardChildren>}
|
||||
</StatCardRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// KbdHint ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KbdHintProps {
|
||||
keys: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
const KbdHintRoot = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.xs};
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const KbdKey = styled.kbd`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
padding: 2px 6px;
|
||||
background: ${theme.color.surface2};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.sm};
|
||||
font-family: ${theme.font.mono};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textMain};
|
||||
line-height: 1.6;
|
||||
box-shadow: 0 1px 0 ${theme.color.border};
|
||||
`;
|
||||
|
||||
const KbdPlusSep = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
|
||||
export function KbdHint({ keys, label }: KbdHintProps) {
|
||||
return (
|
||||
<KbdHintRoot>
|
||||
{keys.map((k, i) => (
|
||||
<React.Fragment key={k}>
|
||||
{i > 0 && <KbdPlusSep>+</KbdPlusSep>}
|
||||
<KbdKey>{k}</KbdKey>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span>{label}</span>
|
||||
</KbdHintRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// PageHeader ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
right?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PageHeaderRoot = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.space.md};
|
||||
padding-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const PageHeaderLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.xs};
|
||||
`;
|
||||
|
||||
const PageHeaderEyebrow = styled.span`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: ${theme.color.accent};
|
||||
`;
|
||||
|
||||
const PageHeaderTitle = styled.h1`
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textMain};
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const PageHeaderSubtitle = styled.p`
|
||||
font-size: 14px;
|
||||
color: ${theme.color.textSub};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const PageHeaderRight = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
eyebrow,
|
||||
right,
|
||||
children,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<PageHeaderRoot>
|
||||
<PageHeaderLeft>
|
||||
{eyebrow && <PageHeaderEyebrow>{eyebrow}</PageHeaderEyebrow>}
|
||||
<PageHeaderTitle>{title}</PageHeaderTitle>
|
||||
{subtitle && <PageHeaderSubtitle>{subtitle}</PageHeaderSubtitle>}
|
||||
{children}
|
||||
</PageHeaderLeft>
|
||||
{right && <PageHeaderRight>{right}</PageHeaderRight>}
|
||||
</PageHeaderRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// AppShellLayout ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AppShellLayoutProps {
|
||||
sidebar: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AppShellRoot = styled.div`
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: ${theme.color.bg};
|
||||
`;
|
||||
|
||||
const AppShellSidebar = styled.aside`
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid ${theme.color.border};
|
||||
background: ${theme.color.surface};
|
||||
`;
|
||||
|
||||
const AppShellMain = styled.main`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export function AppShellLayout({ sidebar, children }: AppShellLayoutProps) {
|
||||
return (
|
||||
<AppShellRoot>
|
||||
<AppShellSidebar>{sidebar}</AppShellSidebar>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</AppShellRoot>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user