07보내기 전 전부 펼쳐 보는 검토 화면
마지막 화면은 앞 단계에서 받은 값을 묶음별로 다시 적어 두고, 묶음마다 붙은 연필 단추가 그 단계 번호를 들고 있어 눌린 묶음만 잠깐 밝아졌다가 가라앉습니다.
쓰임결제 직전 주문 확인 화면, 제출 전 마지막으로 훑는 신청 요약
aria-labelscalesteps(1, end)

<main class="stage">
<section class="rv is-demo" id="rv" aria-label="보내기 전 검토">
<p class="rv__h">마지막 확인</p>
<div class="rv__g" data-g="1">
<div class="rv__top">
<p class="rv__name">배송지</p>
<button class="rv__pen" type="button" data-step="1" aria-label="배송지 고치기" aria-pressed="false">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
</button>
</div>
<dl class="rv__kv">
<div class="rv__row">
<dt class="rv__k">받는 사람</dt>
<dd class="rv__dd" data-label="받는 사람"><span class="rv__val">홍길동</span></dd>
</div>
<div class="rv__row">
<dt class="rv__k">주소</dt>
<dd class="rv__dd" data-label="주소"><span class="rv__val">서울시 강남구 역삼동 12-34</span></dd>
</div>
</dl>
</div>
<div class="rv__g" data-g="2">
<div class="rv__top">
<p class="rv__name">연락처</p>
<button class="rv__pen" type="button" data-step="2" aria-label="연락처 고치기" aria-pressed="false">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
</button>
</div>
<dl class="rv__kv">
<div class="rv__row">
<dt class="rv__k">전화</dt>
<dd class="rv__dd" data-label="전화"><span class="rv__val">010-2345-6789</span></dd>
</div>
<div class="rv__row">
<dt class="rv__k">이메일</dt>
<dd class="rv__dd" data-label="이메일"><span class="rv__val">[email protected]</span></dd>
</div>
</dl>
</div>
<div class="rv__g" data-g="3">
<div class="rv__top">
<p class="rv__name">결제 수단</p>
<button class="rv__pen" type="button" data-step="3" aria-label="결제 수단 고치기" aria-pressed="false">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
</button>
</div>
<dl class="rv__kv">
<div class="rv__row">
<dt class="rv__k">결제 카드</dt>
<dd class="rv__dd" data-label="결제 카드"><span class="rv__val">개인 카드 · 4444</span></dd>
</div>
<div class="rv__row">
<dt class="rv__k">할부</dt>
<dd class="rv__dd" data-label="할부"><span class="rv__val">일시불</span></dd>
</div>
</dl>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$line: color.mix($subject-cream, $stage-orange, 24%);
$soft: color.mix($subject-cream, $stage-orange, 10%);
$dim: rgba(255, 247, 230, .72);
$muted: rgba(255, 247, 230, .5);
.rv {
box-sizing: border-box;
display: grid;
gap: $sp-1;
width: min(320px, 92vw);
padding: $sp-2;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-orange;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.rv__h { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: $subject-ink; }
// ── 값 묶음 하나 ────────────────────────────────
.rv__g {
box-sizing: border-box;
padding: $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: transparent;
transition: border-color $duration $easing, background-color $duration $easing;
}
// 편집 상태 — 눌린 묶음만 살짝 들렀다 가라앉는다(scale 한 번 재생)
.rv__g.is-edit {
border-color: $color;
background: $soft;
animation-name: rv-lift;
animation-duration: 600ms;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes rv-lift {
0% { transform: scale(1); }
40% { transform: scale(1.04); }
100% { transform: scale(1); }
}
.rv__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: 0 0 $sp-1;
}
.rv__name { margin: 0; font-size: 11px; font-weight: 700; color: $subject-ink; }
.rv__pen {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
margin: 0;
padding: 0;
border: 1px solid $line;
border-radius: 50%;
background: transparent;
color: $dim;
cursor: pointer;
transition: background-color $duration $easing, border-color $duration $easing, color $duration $easing;
}
.rv__pen svg { width: 12px; height: 12px; }
.rv__pen:hover { border-color: $color; color: $subject-cream; }
.rv__pen:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.rv__g.is-edit .rv__pen { background: $color; border-color: $color; color: $stage-orange; }
.rv__kv { display: grid; margin: 0; }
.rv__row {
display: grid;
grid-template-columns: 64px 1fr;
gap: $sp-2;
align-items: baseline;
}
.rv__k { font-size: 11px; color: $subject-ink; }
.rv__dd {
margin: 0;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
color: $subject-cream;
}
// 정적 상태(사람이 조작하는 길) — 검토 목록은 값이 적혀 있는 채로 산다
.rv__val {
color: $subject-ink;
border-radius: $r-xs;
transition: background-color $duration $easing, box-shadow $duration $easing;
}
// 편집으로 진짜 생기는 칸 — 값이 input 으로 바뀐다
.rv__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 0 $sp-1;
border: 0;
border-bottom: 1px solid $color;
border-radius: $r-xs $r-xs 0 0;
background: rgba(255, 247, 230, .14);
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.4;
}
.rv__inp:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// ── 격자 미리보기 전용 2초 루프 ──────────────────
// 묶음이 하나씩 scale 로 들렀다 놓이고, 편집 상태 전환은 steps(1, end) 로 한 프레임에 컷.
// 검토 목록(주인공)은 루프 안에서 지우지 않는다.
@keyframes rv-lift-1 {
0%, 4% { transform: scale(1); }
10% { transform: scale(1.04); }
16%, 100% { transform: scale(1); }
}
@keyframes rv-lift-2 {
0%, 32% { transform: scale(1); }
38% { transform: scale(1.04); }
44%, 100% { transform: scale(1); }
}
@keyframes rv-lift-3 {
0%, 60% { transform: scale(1); }
66% { transform: scale(1.04); }
72%, 100% { transform: scale(1); }
}
// 편집 상태는 한 번 켜지면 루프 끝까지 유지되고, 처음으로 돌아가는 컷은
// 애니메이션 경계(100% → 0%) 자체다 — 세 묶음이 한꺼번에 꺼지는 프레임이 poster 로 뽑히는 일을 막는다
@keyframes rv-edit-1 {
0%, 18% { border-color: $line; background: transparent; }
19%, 100% { border-color: $color; background: $soft; }
}
@keyframes rv-edit-2 {
0%, 48% { border-color: $line; background: transparent; }
49%, 100% { border-color: $color; background: $soft; }
}
@keyframes rv-edit-3 {
0%, 78% { border-color: $line; background: transparent; }
79%, 100% { border-color: $color; background: $soft; }
}
@keyframes rv-pen-1 {
0%, 18% { background: transparent; color: $dim; }
19%, 100% { background: $color; color: $stage-orange; }
}
@keyframes rv-pen-2 {
0%, 48% { background: transparent; color: $dim; }
49%, 100% { background: $color; color: $stage-orange; }
}
@keyframes rv-pen-3 {
0%, 78% { background: transparent; color: $dim; }
79%, 100% { background: $color; color: $stage-orange; }
}
@keyframes rv-field-1 {
0%, 18% { background: transparent; box-shadow: none; }
19%, 100% { background: rgba(255, 247, 230, .14); box-shadow: 0 1px 0 $color; }
}
@keyframes rv-field-2 {
0%, 48% { background: transparent; box-shadow: none; }
49%, 100% { background: rgba(255, 247, 230, .14); box-shadow: 0 1px 0 $color; }
}
@keyframes rv-field-3 {
0%, 78% { background: transparent; box-shadow: none; }
79%, 100% { background: rgba(255, 247, 230, .14); box-shadow: 0 1px 0 $color; }
}
.rv.is-demo {
.rv__g[data-g="1"] { animation-name: rv-lift-1, rv-edit-1; }
.rv__g[data-g="2"] { animation-name: rv-lift-2, rv-edit-2; }
.rv__g[data-g="3"] { animation-name: rv-lift-3, rv-edit-3; }
.rv__g {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.rv__g[data-g="1"] .rv__pen { animation-name: rv-pen-1; }
.rv__g[data-g="2"] .rv__pen { animation-name: rv-pen-2; }
.rv__g[data-g="3"] .rv__pen { animation-name: rv-pen-3; }
.rv__pen {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rv__g[data-g="1"] .rv__val { animation-name: rv-field-1; }
.rv__g[data-g="2"] .rv__val { animation-name: rv-field-2; }
.rv__g[data-g="3"] .rv__val { animation-name: rv-field-3; }
.rv__val {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
// 좁은 폭에서는 각 묶음의 첫 값만 보인다 — 320px 세로 예산(174px) 안에 들어가게
@media (max-width: 340px) {
.rv { padding: $sp-2; gap: $sp-1; }
.rv__g { padding: $sp-1; }
.rv__top { margin-bottom: $sp-1; }
.rv__pen { width: 16px; height: 16px; }
.rv__pen svg { width: 10px; height: 10px; }
.rv__row { grid-template-columns: 52px 1fr; gap: $sp-1; }
.rv__name, .rv__k, .rv__dd, .rv__inp { font-size: 10px; }
.rv__kv .rv__row:nth-child(2) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.rv.is-demo .rv__g,
.rv.is-demo .rv__pen,
.rv.is-demo .rv__val { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 첫 묶음이 편집 상태인 장을 보여 준다
.rv.is-demo .rv__g[data-g="1"] { border-color: $color !important; background: $soft !important; }
.rv.is-demo .rv__g[data-g="1"] .rv__pen { background: $color !important; border-color: $color !important; color: $stage-orange !important; }
.rv.is-demo .rv__g[data-g="1"] .rv__val { background: rgba(255, 247, 230, .14) !important; box-shadow: 0 1px 0 $color !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
box-sizing: border-box;
display: grid;
gap: 4px;
width: min(320px, 92vw);
padding: 8px;
border: 1px solid rgb(100%, 46.1960784314%, 30.8862745098%);
border-radius: 16px;
background: #ff4d1f;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.rv__h {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: #17141a;
}
.rv__g {
box-sizing: border-box;
padding: 8px;
border: 1px solid rgb(100%, 46.1960784314%, 30.8862745098%);
border-radius: 8px;
background: transparent;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__g.is-edit {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
animation-name: rv-lift;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
@keyframes rv-lift {
0% {
transform: scale(1);
}
40% {
transform: scale(1.04);
}
100% {
transform: scale(1);
}
}
.rv__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 4px;
}
.rv__name {
margin: 0;
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.rv__pen {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
margin: 0;
padding: 0;
border: 1px solid rgb(100%, 46.1960784314%, 30.8862745098%);
border-radius: 50%;
background: transparent;
color: rgba(255, 247, 230, 0.72);
cursor: pointer;
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__pen svg {
width: 12px;
height: 12px;
}
.rv__pen:hover {
border-color: #fff7e6;
color: #fff7e6;
}
.rv__pen:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 1px;
}
.rv__g.is-edit .rv__pen {
background: #fff7e6;
border-color: #fff7e6;
color: #ff4d1f;
}
.rv__kv {
display: grid;
margin: 0;
}
.rv__row {
display: grid;
grid-template-columns: 64px 1fr;
gap: 8px;
align-items: baseline;
}
.rv__k {
font-size: 11px;
color: #17141a;
}
.rv__dd {
margin: 0;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
color: #fff7e6;
}
.rv__val {
color: #17141a;
border-radius: 4px;
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 0 4px;
border: 0;
border-bottom: 1px solid #fff7e6;
border-radius: 4px 4px 0 0;
background: rgba(255, 247, 230, 0.14);
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.4;
}
.rv__inp:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 1px;
}
@keyframes rv-lift-1 {
0%, 4% {
transform: scale(1);
}
10% {
transform: scale(1.04);
}
16%, 100% {
transform: scale(1);
}
}
@keyframes rv-lift-2 {
0%, 32% {
transform: scale(1);
}
38% {
transform: scale(1.04);
}
44%, 100% {
transform: scale(1);
}
}
@keyframes rv-lift-3 {
0%, 60% {
transform: scale(1);
}
66% {
transform: scale(1.04);
}
72%, 100% {
transform: scale(1);
}
}
@keyframes rv-edit-1 {
0%, 18% {
border-color: rgb(100%, 46.1960784314%, 30.8862745098%);
background: transparent;
}
19%, 100% {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
}
}
@keyframes rv-edit-2 {
0%, 48% {
border-color: rgb(100%, 46.1960784314%, 30.8862745098%);
background: transparent;
}
49%, 100% {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
}
}
@keyframes rv-edit-3 {
0%, 78% {
border-color: rgb(100%, 46.1960784314%, 30.8862745098%);
background: transparent;
}
79%, 100% {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
}
}
@keyframes rv-pen-1 {
0%, 18% {
background: transparent;
color: rgba(255, 247, 230, 0.72);
}
19%, 100% {
background: #fff7e6;
color: #ff4d1f;
}
}
@keyframes rv-pen-2 {
0%, 48% {
background: transparent;
color: rgba(255, 247, 230, 0.72);
}
49%, 100% {
background: #fff7e6;
color: #ff4d1f;
}
}
@keyframes rv-pen-3 {
0%, 78% {
background: transparent;
color: rgba(255, 247, 230, 0.72);
}
79%, 100% {
background: #fff7e6;
color: #ff4d1f;
}
}
@keyframes rv-field-1 {
0%, 18% {
background: transparent;
box-shadow: none;
}
19%, 100% {
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 #fff7e6;
}
}
@keyframes rv-field-2 {
0%, 48% {
background: transparent;
box-shadow: none;
}
49%, 100% {
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 #fff7e6;
}
}
@keyframes rv-field-3 {
0%, 78% {
background: transparent;
box-shadow: none;
}
79%, 100% {
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 #fff7e6;
}
}
.rv.is-demo .rv__g[data-g="1"] {
animation-name: rv-lift-1, rv-edit-1;
}
.rv.is-demo .rv__g[data-g="2"] {
animation-name: rv-lift-2, rv-edit-2;
}
.rv.is-demo .rv__g[data-g="3"] {
animation-name: rv-lift-3, rv-edit-3;
}
.rv.is-demo .rv__g {
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.rv.is-demo .rv__g[data-g="1"] .rv__pen {
animation-name: rv-pen-1;
}
.rv.is-demo .rv__g[data-g="2"] .rv__pen {
animation-name: rv-pen-2;
}
.rv.is-demo .rv__g[data-g="3"] .rv__pen {
animation-name: rv-pen-3;
}
.rv.is-demo .rv__pen {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rv.is-demo .rv__g[data-g="1"] .rv__val {
animation-name: rv-field-1;
}
.rv.is-demo .rv__g[data-g="2"] .rv__val {
animation-name: rv-field-2;
}
.rv.is-demo .rv__g[data-g="3"] .rv__val {
animation-name: rv-field-3;
}
.rv.is-demo .rv__val {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rv {
padding: 8px;
gap: 4px;
}
.rv__g {
padding: 4px;
}
.rv__top {
margin-bottom: 4px;
}
.rv__pen {
width: 16px;
height: 16px;
}
.rv__pen svg {
width: 10px;
height: 10px;
}
.rv__row {
grid-template-columns: 52px 1fr;
gap: 4px;
}
.rv__name, .rv__k, .rv__dd, .rv__inp {
font-size: 10px;
}
.rv__kv .rv__row:nth-child(2) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rv.is-demo .rv__g,
.rv.is-demo .rv__pen,
.rv.is-demo .rv__val {
animation: none !important;
transform: none !important;
}
.rv.is-demo .rv__g[data-g="1"] {
border-color: #fff7e6 !important;
background: rgb(100%, 36.862745098%, 19.9607843137%) !important;
}
.rv.is-demo .rv__g[data-g="1"] .rv__pen {
background: #fff7e6 !important;
border-color: #fff7e6 !important;
color: #ff4d1f !important;
}
.rv.is-demo .rv__g[data-g="1"] .rv__val {
background: rgba(255, 247, 230, 0.14) !important;
box-shadow: 0 1px 0 #fff7e6 !important;
}
}// 실제 동작: 연필 단추를 누른 묶음의 값이 진짜 input 으로 바뀌고,
// 다시 누르면 고친 값을 텍스트로 되돌려 적는다(화면만 바꾸는 흉내가 아니다).
var rv = document.getElementById('rv');
var groups = [].slice.call(rv.querySelectorAll('.rv__g'));
groups.forEach(function (g) {
var pen = g.querySelector('.rv__pen');
pen.addEventListener('click', function () {
rv.classList.remove('is-demo');
var editing = g.classList.toggle('is-edit');
pen.setAttribute('aria-pressed', editing ? 'true' : 'false');
g.querySelectorAll('.rv__dd').forEach(function (dd) {
var old = dd.querySelector('input');
if (editing && !old) {
var inp = document.createElement('input');
inp.className = 'rv__inp';
inp.value = dd.textContent.trim();
inp.setAttribute('aria-label', dd.getAttribute('data-label'));
dd.textContent = '';
dd.appendChild(inp);
} else if (!editing && old) {
dd.textContent = old.value.trim() || '—';
}
});
var first = g.querySelector('input');
if (editing && first) first.focus({ preventScroll: true });
});
g.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && g.classList.contains('is-edit')) { e.preventDefault(); pen.click(); }
});
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./FinalReviewEdit.module.scss";
export type ReviewField = { label: string; value: string };
export type ReviewGroup = {
name: string;
editLabel: string;
fields: ReviewField[];
};
export type FinalReviewEditProps = {
heading?: string;
groups?: ReviewGroup[];
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FocusEl = { focus(opt: { preventScroll: boolean }): void };
type KeyEvt = { key: string; preventDefault(): void };
type InpEvt = { target: { value: string } };
const DEFAULT_GROUPS: ReviewGroup[] = [
{
name: "Delivery",
editLabel: "Edit the delivery address",
fields: [
{ label: "Recipient", value: "Hong Gildong" },
{ label: "Address", value: "12-34 Yeoksam-dong, Gangnam-gu, Seoul" },
],
},
{
name: "Contact",
editLabel: "Edit the contact details",
fields: [
{ label: "Phone", value: "010-2345-6789" },
{ label: "Email", value: "[email protected]" },
],
},
{
name: "Payment",
editLabel: "Edit the payment method",
fields: [
{ label: "Card", value: "Personal card · 4444" },
{ label: "Plan", value: "Pay in full" },
],
},
];
function Pencil() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2" />
<path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z" />
</g>
</svg>
);
}
export default function FinalReviewEdit({
heading = "Last check",
groups = DEFAULT_GROUPS,
groupLabel = "Final review before sending",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: FinalReviewEditProps) {
const [editing, setEditing] = useState<number | null>(null);
const [values, setValues] = useState<string[][]>(groups.map((g) => g.fields.map((f) => f.value)));
const [live, setLive] = useState(demo);
const firstBox = useRef<FocusEl | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 편집에 들어가면 첫 칸에 초점 — iframe 안이라 preventScroll 이 필수
useEffect(() => {
if (editing !== null) {
const el = firstBox.current;
if (el) el.focus({ preventScroll: true });
}
}, [editing]);
// 연필: 눌린 묶음의 값이 진짜 input 으로 바뀌고, 다시 누르면 고친 값이 텍스트로 남는다
function press(gi: number) {
setLive(false);
setEditing((prev) => (prev === gi ? null : gi));
}
const cls = [styles.rv, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.h}>{heading}</p>
{groups.map((g, gi) => {
const isEdit = editing === gi;
return (
<div key={g.name} className={[styles.g, isEdit ? styles.isEdit : ""].filter(Boolean).join(" ")} data-g={gi + 1}>
<div className={styles.top}>
<p className={styles.name}>{g.name}</p>
<button
className={styles.pen}
type="button"
data-step={gi + 1}
aria-label={g.editLabel}
aria-pressed={isEdit}
onClick={() => press(gi)}
>
<Pencil />
</button>
</div>
<dl className={styles.kv}>
{g.fields.map((f, fi) => (
<div key={f.label} className={styles.row}>
<dt className={styles.k}>{f.label}</dt>
<dd className={styles.dd}>
{isEdit ? (
<input
className={styles.inp}
value={values[gi][fi]}
aria-label={f.label}
ref={fi === 0 ? (el: FocusEl | null) => { firstBox.current = el; } : undefined}
onChange={(e: InpEvt) => {
setValues((prev) => prev.map((row, n) => (n === gi ? row.map((v, m) => (m === fi ? e.target.value : v)) : row)));
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter") { e.preventDefault(); setEditing(null); }
}}
/>
) : (
<span className={styles.val}>{values[gi][fi] || "—"}</span>
)}
</dd>
</div>
))}
</dl>
</div>
);
})}
</section>
);
}07 보내기 전 전부 펼쳐 보는 검토 화면 — 측정 누적면적 45.66% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼
이 데모가 속한 글
회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼회원가입 단계 ui는 한 번에 다 보여 주지 않고 몇 장의 화면으로 쪼개어 순서대로 받아 내는 입력 방식입니다. 아홉 개 전부 값을 실제로 읽고 검사하고 저장하며, 칸을 채우지 않으면 다음 장으로 안 넘어가고 탭을 이 글의 데모 전부 보기


