@olundot/ui · Surfaces
Popover
가벼운 컨트롤, 필터, 미리보기, 맥락 액션을 위한 닫을 수 있는 플로팅 서피스입니다.
- 모달 여부
- modal={false}가 기본입니다. 배경 클릭·Esc 자동 닫힘을 제공하며, 전체 포커스 트랩이 필요하면 Dialog를 사용합니다.
- 포커스 관리
- Radix가 Tab 이동과 Esc 닫기 후 trigger 포커스 복귀를 처리합니다. onOpenAutoFocus/onCloseAutoFocus/onInteractOutside로 조정할 수 있습니다.
- Tooltip 차이
- Tooltip은 hover 중심의 단순 label입니다. Popover는 click/keyboard로 여는 form, menu, links 같은 조작 콘텐츠를 담습니다.
- Anchor 선택
- Trigger와 별개 anchor 위치 지정이 필요하면 <PopoverAnchor>를 활용합니다.
- 위치
- `side='bottom'`이 기본이며 트리거 아래에 표시합니다. 충돌이 생기면 Radix가 자동으로 flip/shift하고, `align='start|center|end'`로 정렬을 조정합니다.
- Arrow 옵션
- `<PopoverContent showArrow>`로 트리거와 popover의 관계를 시각적으로 보강할 수 있습니다.
- 드래그 이동
- Popover는 트리거 기준 floating UI이며 드래그 이동을 제공하지 않습니다. 이동 가능한 패널이 필요하면 별도 컴포넌트로 분리합니다.
메뉴
트리거 아래에 여는 조밀한 액션 목록코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// Popover는 modal={false} 기본 — 비차단 dismiss.
// 배경 클릭 또는 Esc 로 닫힘. Tab으로 버튼 순회 가능.
// side="bottom"으로 트리거 아래에 표시합니다.
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>
);
}필터
체크박스 폼과 적용 액션코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// 필터 그룹을 Popover에 담아 인접한 자리에서 검색 조건을 조정합니다.
// Esc와 바깥 클릭으로 자동 닫힘을 지원하고, 적용은 명시적 버튼으로 처리합니다.
// Tooltip과 달리 checkbox, button 등 조작 가능한 콘텐츠를 담을 수 있습니다.
// side="bottom"으로 트리거 아래에 표시합니다.
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>
);
}적용 중
필터 적용 대기 상태코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// 적용 버튼의 loading 상태로 요청 진행 중임을 표시합니다.
// Popover 자체 API를 늘리지 않고 적용 버튼의 상태로 표현합니다.
export function PendingFilter() {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const handleApply = () => {
setPending(true);
window.setTimeout(() => {
setPending(false);
setOpen(false);
}, 1000);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">필터 조정</Button>
</PopoverTrigger>
<PopoverContent side="bottom">
<div style={{ display: "grid", gap: "12px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.875rem" }}>
<input type="checkbox" defaultChecked />
제출 완료만 보기
</label>
<Button variant="primary" size="sm" loading={pending} onClick={handleApply}>
적용
</Button>
</div>
</PopoverContent>
</Popover>
);
}미리보기
화살표가 있는 메타 정보 미리보기코드 보기tsx
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
// 학생 이름 클릭 시 학번·과정·출결 요약 미리보기.
// Tooltip과 달리 링크·버튼 같은 조작 가능한 콘텐츠를 담을 수 있습니다.
// side="right" — anchor 오른쪽에 표시 (테이블/목록 행 미리보기 패턴).
// showArrow={true}로 트리거와 popover의 관계를 시각적으로 보강합니다.
// 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>
);
}확인
낮은 위험의 인라인 confirm코드 보기tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@olundot/ui";
import { Button } from "@olundot/ui";
// 되돌릴 수 있는 가벼운 액션은 Popover 안에서 확인할 수 있습니다.
// destructive / 비가역 액션은 Dialog 사용 — Popover는 배경 작업을 막지 않습니다.
// 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>
);
}