@olundot/ui · Surfaces
Popover
가벼운 컨트롤, 필터, 미리보기, 맥락 액션을 위한 닫을 수 있는 플로팅 서피스입니다.
- Modal default
- modal={false} (non-blocking dismiss). 배경 클릭·Esc 자동 닫힘. 진짜 모달 focus trap은 Dialog 사용.
- Focus management
- Radix 자동 — Tab navigation, Esc dismiss는 trigger로 focus 복귀. onOpenAutoFocus/onCloseAutoFocus/onInteractOutside로 override 가능.
- Tooltip 차이
- Tooltip은 hover-only 단순 label(non-interactive). Popover는 click/keyboard interactive content(form, menu, links) — trigger anchor 기반.
- Anchor optional
- Trigger와 별개 anchor 위치 지정이 필요하면 <PopoverAnchor> 활용.
- Arrow 옵션
- <PopoverContent showArrow> 또는 standalone <PopoverArrow>. trigger anchor를 시각적으로 명시할 때 사용.
- Anchor positioning (MUI 정합)
- Default `side='bottom'` = MUI anchorOrigin 'bottom-left' 정합 — anchor 아래 정렬 기준. `side`는 **preferred side**이며 Radix가 viewport collision 시 자동 flip/shift할 수 있음 (`collisionPadding=8`, `avoidCollisions=true`). 정확한 위치 보장 필요 시 `avoidCollisions={false}` 사용. `align='start|center|end'`로 정렬 (default center).
- PopoverArrow 표시
- `<PopoverContent showArrow>` opt-in (default false). Anchor 시각 cue 필요 시 활성화. Tooltip은 arrow 기본 포함, Popover는 명시 — interactive content 강조.
- Draggable 미지원 (산업 표준)
- Radix/shadcn/MUI Popover 모두 draggable 기능 미포함 — Popover는 anchor 기반 floating UI로 drag 시 anchor와 끊겨 의미 흐려짐. drag 패턴 필요 시 `@dnd-kit`/`react-draggable` 별도 통합 또는 별도 `MovablePanel` 컴포넌트로 분리 권장.
메뉴
라이브 검증 (compact action list — side="bottom" default)코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// Popover는 modal={false} 기본 — 비차단 dismiss.
// 배경 클릭 또는 Esc 로 닫힘. Tab으로 내부 버튼 순회 가능.
// side="bottom" = MUI anchorOrigin 'bottom-left' 정합 (anchor 아래 표시).
export function ActionMenu() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">메뉴</Button>
</PopoverTrigger>
<PopoverContent side="bottom">
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<button
type="button"
style={{
textAlign: "left",
padding: "6px 8px",
borderRadius: "var(--radius-sm)",
border: "none",
background: "transparent",
color: "var(--text-primary)",
cursor: "pointer",
fontSize: "0.875rem",
}}
>
편집
</button>
<button
type="button"
style={{
textAlign: "left",
padding: "6px 8px",
borderRadius: "var(--radius-sm)",
border: "none",
background: "transparent",
color: "var(--text-primary)",
cursor: "pointer",
fontSize: "0.875rem",
}}
>
복제
</button>
<button
type="button"
style={{
textAlign: "left",
padding: "6px 8px",
borderRadius: "var(--radius-sm)",
border: "none",
background: "transparent",
color: "var(--color-danger, var(--text-primary))",
cursor: "pointer",
fontSize: "0.875rem",
}}
>
삭제
</button>
</div>
</PopoverContent>
</Popover>
);
}필터
라이브 검증 (체크박스 폼 + Apply — side="bottom")코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// 필터 그룹을 popover에 담아 inline에서 검색 조건 조정.
// Esc / outside click으로 자동 dismiss, 적용은 명시적 버튼.
// Tooltip과 달리 checkbox, button 등 interactive content 가능.
// side="bottom" — anchor 아래 표시 (default 동일, 명시적 선언).
export function StatusFilter() {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [applied, setApplied] = useState<string[]>([]);
const toggle = (s: string) =>
setSelected((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
);
const handleApply = () => {
setApplied(selected);
setOpen(false);
};
return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
상태 필터{applied.length > 0 ? ` (${applied.length})` : ""}
</Button>
</PopoverTrigger>
<PopoverContent side="bottom">
<fieldset style={{ border: "none", padding: 0, margin: 0, display: "grid", gap: "8px" }}>
<legend style={{ fontSize: "0.875rem", color: "var(--text-secondary)", marginBottom: "4px", fontWeight: 600 }}>
표시할 상태
</legend>
{["대기", "진행 중", "완료", "오류"].map((s) => (
<label key={s} style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", fontSize: "0.875rem" }}>
<input
type="checkbox"
checked={selected.includes(s)}
onChange={() => toggle(s)}
/>
{s}
</label>
))}
</fieldset>
<div style={{ display: "flex", gap: "8px", marginTop: "12px", justifyContent: "flex-end" }}>
<Button variant="outline" size="sm" onClick={() => { setSelected([]); }}>
초기화
</Button>
<Button variant="primary" size="sm" onClick={handleApply}>
적용
</Button>
</div>
</PopoverContent>
</Popover>
{applied.length > 0 && (
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>
선택: {applied.join(", ")}
</span>
)}
</div>
);
}미리보기
라이브 검증 (클릭 시 메타 정보 노출 + showArrow — side="right")코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
// 학생 이름 클릭 시 학번·과정·출결 요약 미리보기.
// Tooltip과 달리 링크·버튼 같은 interactive content 포함 가능.
// side="right" — anchor 오른쪽에 표시 (테이블/목록 행 inline preview 패턴).
// showArrow={true}로 trigger anchor를 시각적으로 명시.
// viewport 좁으면 collisionPadding=8 자동 fallback.
const students = [
{ name: "김지수", id: "20220001", dept: "의학과 4학년", attendance: "출석 28 / 결석 2" },
{ name: "이민준", id: "20220042", dept: "의학과 4학년", attendance: "출석 25 / 결석 5" },
];
export function StudentPreview() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{students.map((student) => (
<Popover key={student.id}>
<PopoverTrigger asChild>
<button
type="button"
style={{
background: "none",
border: "none",
color: "var(--accent-fg)",
textDecoration: "underline",
cursor: "pointer",
fontSize: "0.875rem",
textAlign: "left",
padding: 0,
}}
>
{student.name}
</button>
</PopoverTrigger>
<PopoverContent side="right" showArrow>
<div style={{ display: "grid", gap: "6px" }}>
<p style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--text-primary)", margin: 0 }}>
{student.name}
</p>
<p style={{ fontSize: "0.75rem", color: "var(--text-secondary)", margin: 0 }}>
학번: {student.id}
</p>
<p style={{ fontSize: "0.75rem", color: "var(--text-secondary)", margin: 0 }}>
{student.dept}
</p>
<p style={{ fontSize: "0.75rem", color: "var(--text-secondary)", margin: 0 }}>
{student.attendance}
</p>
</div>
</PopoverContent>
</Popover>
))}
</div>
);
}확인
라이브 검증 (low-risk 인라인 confirm — side="top")코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// 위험도 낮은 액션 (예: '저장 후 닫기')의 inline 확인.
// destructive / 비가역 액션은 Dialog 사용 — Popover는 non-blocking.
// side="top" — anchor 위에 표시 (confirm overlay가 트리거 버튼을 가리지 않도록).
// onOpenChange로 confirm 후 닫기.
export function SaveConfirmation() {
const [open, setOpen] = useState(false);
const [saved, setSaved] = useState(false);
const handleConfirm = () => {
setSaved(true);
setOpen(false);
};
return (
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="primary" size="sm">
저장 후 닫기
</Button>
</PopoverTrigger>
<PopoverContent side="top" showArrow>
<p style={{ margin: "0 0 12px", fontSize: "0.875rem", color: "var(--text-primary)", fontWeight: 600 }}>
변경사항을 저장할까요?
</p>
<p style={{ margin: "0 0 12px", fontSize: "0.8125rem", color: "var(--text-secondary)" }}>
저장 후 편집 화면이 닫힙니다.
</p>
<div style={{ display: "flex", gap: "8px", justifyContent: "flex-end" }}>
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
취소
</Button>
<Button variant="primary" size="sm" onClick={handleConfirm}>
저장
</Button>
</div>
</PopoverContent>
</Popover>
{saved && (
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>
저장 완료
</span>
)}
</div>
);
}