Board card
Playground
TSX
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import {
BoardCard,
BoardCardHeading,
BoardCardContent,
Badge,
Icon,
type BoardCardState,
} from '@mbsw/ui';
import {
CalendarDays,
ChevronRight,
FolderKanban,
MessageSquare,
Paperclip,
Target,
Users,
type LucideIcon,
} from 'lucide-react';
function Property({
icon,
label,
children,
}: {
icon: LucideIcon;
label: string;
children: React.ReactNode;
}) {
return (
<span
role="group"
aria-label={label}
title={label}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 6px',
border: '1px solid var(--riido-border-strong)',
borderRadius: 'var(--riido-primitive-radius-radius-xs)',
fontSize: 12,
lineHeight: '16px',
}}
>
<Icon icon={icon} size="xs" decorative />
{children}
</span>
);
}
export default function Demo() {
const settings = {
"title": "디자인시스템 가이드",
"description": "신규 사용자 온보딩",
"state": "default"
};
const area = useRef<HTMLDivElement>(null);
const card = useRef<HTMLDivElement>(null);
const pointer = useRef<{ id: number; x: number; y: number } | null>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [delta, setDelta] = useState({ x: 0, y: 0 });
const [active, setActive] = useState<'pointer' | 'keyboard' | null>(null);
const [pointerFocus, setPointerFocus] = useState(false);
const [announcement, setAnnouncement] = useState('');
const instructions = useId();
const dragging = active !== null || settings.state === 'dragging';
function constrain(x: number, y: number) {
// Leave space for the package's rotated overlay at the preview edges.
const limitX = Math.max(
0,
((area.current?.clientWidth ?? 0) - (card.current?.offsetWidth ?? 0)) /
2 -
12,
);
const limitY = Math.max(
0,
((area.current?.clientHeight ?? 0) - (card.current?.offsetHeight ?? 0)) /
2 -
12,
);
return {
x: Math.max(-limitX, Math.min(limitX, x)),
y: Math.max(-limitY, Math.min(limitY, y)),
};
}
function move(x: number, y: number) {
const next = constrain(position.x + x, position.y + y);
setDelta({ x: next.x - position.x, y: next.y - position.y });
if (active === 'keyboard')
setAnnouncement(
"중앙 기준 가로 {x}px, 세로 {y}px 위치입니다."
.replace('{x}', String(Math.round(next.x)))
.replace('{y}', String(Math.round(next.y))),
);
}
function finish(cancel = false) {
if (!cancel)
setPosition(constrain(position.x + delta.x, position.y + delta.y));
pointer.current = null;
setDelta({ x: 0, y: 0 });
setActive(null);
setAnnouncement(cancel ? "이동을 취소했습니다." : "카드를 놓았습니다.");
}
useEffect(() => {
const observer = new ResizeObserver(() => {
setPosition((current) => constrain(current.x, current.y));
pointer.current = null;
setDelta({ x: 0, y: 0 });
setActive(null);
});
if (area.current) observer.observe(area.current);
if (card.current) observer.observe(card.current);
return () => observer.disconnect();
}, []);
return (
<div
ref={area}
style={{
width: '100%',
height: '100vh',
display: 'grid',
placeItems: 'center',
}}
>
<span
id={instructions}
style={{
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clipPath: 'inset(50%)',
}}
>
{"카드를 끌어서 이동합니다. 키보드는 Space 또는 Enter로 잡고, 방향키로 이동하고, 다시 눌러 놓습니다. Escape로 취소합니다."}
</span>
<span
role="status"
aria-live="polite"
style={{
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clipPath: 'inset(50%)',
}}
>
{announcement}
</span>
<div
style={{
position: 'relative',
width: 320,
maxWidth: 'calc(100% - 24px)',
transform: `translate(${position.x}px, ${position.y}px)`,
}}
>
{dragging && (
<BoardCard
state="dragging"
aria-hidden="true"
inert
style={{ position: 'absolute', inset: 0, height: '100%' }}
/>
)}
<BoardCard
ref={card}
role="button"
tabIndex={0}
aria-roledescription={"드래그 가능한 카드"}
aria-describedby={instructions}
state={dragging ? 'overlay' : (settings.state as BoardCardState)}
aria-label={`WORK-128 ${settings.title}`}
style={{
width: '100%',
position: 'relative',
transform: `translate(${delta.x}px, ${delta.y}px)`,
cursor: active ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
// Keep keyboard focus for Escape, but hide its ring for pointer input.
outline: pointerFocus ? 'none' : undefined,
}}
onPointerDown={(event) => {
if (event.button !== 0 || active === 'keyboard' || pointer.current)
return;
setPointerFocus(true);
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
pointer.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
};
}}
onPointerMove={(event) => {
const start = pointer.current;
if (!start || event.pointerId !== start.id) return;
const x = event.clientX - start.x;
const y = event.clientY - start.y;
if (!active && Math.hypot(x, y) < 4) return;
if (!active) setAnnouncement("카드를 잡았습니다. 놓아서 이동을 완료합니다.");
setActive('pointer');
move(x, y);
}}
onPointerUp={(event) => {
if (pointer.current?.id === event.pointerId) finish();
}}
onPointerCancel={() => {
if (pointer.current) finish(true);
}}
onLostPointerCapture={() => {
if (pointer.current) finish(true);
}}
onBlur={() => {
setPointerFocus(false);
if (active === 'keyboard') finish(true);
}}
onKeyDown={(event) => {
setPointerFocus(false);
if (event.key === 'Escape' && (active || pointer.current)) {
event.preventDefault();
event.stopPropagation();
finish(true);
} else if (
(event.key === ' ' || event.key === 'Enter') &&
active !== 'pointer'
) {
event.preventDefault();
if (event.repeat) return;
if (active === 'keyboard') finish();
else {
setActive('keyboard');
setAnnouncement("카드를 잡았습니다. 방향키로 이동하고 Space로 놓습니다.");
}
} else if (
active === 'keyboard' &&
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(
event.key,
)
) {
event.preventDefault();
const step = event.shiftKey ? 24 : 8;
move(
delta.x +
(event.key === 'ArrowLeft'
? -step
: event.key === 'ArrowRight'
? step
: 0),
delta.y +
(event.key === 'ArrowUp'
? -step
: event.key === 'ArrowDown'
? step
: 0),
);
}
}}
>
<BoardCardHeading>
<div style={{ display: 'grid', gap: 4, width: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 8px',
fontSize: 12,
lineHeight: '16px',
}}
>
<span style={{ color: 'var(--riido-foreground-secondary)' }}>
WORK-128
</span>
<span
role="group"
aria-label={"상위 작업 WORK-100"}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
color: 'var(--riido-foreground-secondary)',
background: 'var(--riido-primary-subtle)',
borderRadius: 'var(--riido-primitive-radius-radius-full)',
padding: '2px 6px',
}}
>
<Icon icon={ChevronRight} size="2xs" decorative />
WORK-100
</span>
</div>
<div
style={{
padding: '2px 8px',
fontSize: 14,
lineHeight: '20px',
fontWeight: 500,
}}
>
{settings.title}
</div>
</div>
</BoardCardHeading>
<BoardCardContent>
<div style={{ display: 'grid', gap: 8, padding: '4px 8px' }}>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: 'var(--riido-foreground-secondary)',
fontSize: 12,
lineHeight: '20px',
}}
>
<Icon icon={FolderKanban} size="xs" decorative />
{settings.description}
</span>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 6,
}}
>
<Property icon={CalendarDays} label={"작업 기간 10월 1일부터 10월 8일까지"}>
10/01 → 10/08
</Property>
<Property icon={Users} label={"참여자 2명"}>
{"2명"}
</Property>
<Badge
size="md"
shape="square"
style={{
backgroundColor: 'var(--riido-primitive-colors-iris-400)',
height: 24,
minWidth: 48,
paddingInline: 6,
color: 'var(--riido-foreground-on-brand)',
}}
>
{"디자인"}
</Badge>
<Property icon={Target} label={"예상 규모 3포인트"}>
3
</Property>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
color: 'var(--riido-foreground-tertiary)',
fontSize: 12,
lineHeight: '16px',
}}
>
<span
role="img"
aria-label={"댓글 3개"}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
<Icon icon={MessageSquare} size="xs" decorative />3
</span>
<span
role="img"
aria-label={"첨부파일 2개"}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
<Icon icon={Paperclip} size="xs" decorative />2
</span>
</div>
</div>
</BoardCardContent>
</BoardCard>
</div>
</div>
);
}
Properties
State
Content
Title
Project
Usage
state는 시각 상태입니다. dragging은 원래 카드 위치의 빈 자리, overlay는 내용이 보이는 기울어진 카드, ghost는 반투명 잔상입니다. Playground에서는 카드를 직접 끌어 이동할 수 있습니다. 이동 중에는 원래 위치의 빈 자리와 움직이는 overlay를 함께 보여줍니다. Space 또는 Enter로 잡고 방향키로 이동한 뒤 다시 눌러 놓으며, Escape로 취소합니다. 설정 초기화 버튼으로 처음 위치로 돌아갑니다. 이 이동 동작은 예제에서 별도로 구현합니다.
TSX
'use client';
import { BoardCard, BoardCardHeading, BoardCardContent } from '@mbsw/ui';
export default function Example() {
return (
<BoardCard>
<BoardCardHeading>{"디자인시스템 가이드"}</BoardCardHeading>
<BoardCardContent>{"신규 사용자 온보딩"}</BoardCardContent>
</BoardCard>
);
}
Playground의 Code 탭에서 현재 설정과 동작이 포함된 예제 전체를 복사할 수 있습니다. 공통 CSS 설치는 설치하기를 확인하세요.