02修正した値を戻せる変更履歴 diff
修正前の値は del で打ち消し線、修正後の値は ins で下線を引き、各行の取り消しボタンを押すと steps(1, end) のカット一つで表示が前の値に戻ります。
使いどころ設定画面の項目別変更記録、見積書の値の変更履歴
delinssteps(1, end)

<main class="stage">
<section class="dh is-demo" id="dh" aria-label="見積変更履歴">
<header class="dh__h">
<span class="dh__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
<h2 class="dh__t">変更履歴</h2>
<span class="dh__n" id="dh-n" data-t="{n}箇所を変更" aria-live="polite">3箇所を変更</span>
</header>
<ul class="dh__list" id="dh-list">
<li class="dh__row" data-row="1">
<span class="dh__f">納期</span>
<p class="dh__diff">
<span class="dh__a"><del>10月15日</del> → <ins>10月8日</ins></span>
<span class="dh__b"><b>10月15日</b><i class="dh__tag">戻しました</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="納期">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
元に戻す
</button>
</li>
<li class="dh__row" data-row="2">
<span class="dh__f">担当者</span>
<p class="dh__diff">
<span class="dh__a"><del>イ・ジュン</del> → <ins>パク・ソア</ins></span>
<span class="dh__b"><b>イ・ジュン</b><i class="dh__tag">戻しました</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="担当者">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
元に戻す
</button>
</li>
<li class="dh__row" data-row="3">
<span class="dh__f">数量</span>
<p class="dh__diff">
<span class="dh__a"><del>120</del> → <ins>150</ins></span>
<span class="dh__b"><b>120</b><i class="dh__tag">戻しました</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="数量">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
元に戻す
</button>
</li>
</ul>
<p class="dh__live" aria-live="polite" id="dh-live">項目を元に戻すとここに表示されます</p>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 14%);
$soft: color.mix($subject-blue, $stage-paper, 7%);
.dh {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.dh__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.dh__ic { display: inline-flex; width: 14px; height: 14px; color: $color; }
.dh__t { margin: 0; font-size: 12px; font-weight: 700; }
.dh__n {
margin-left: auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $soft;
color: $subject-blue-deep;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
}
.dh__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 이력 한 줄 ────────────────────────────────────
.dh__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
}
.dh__f { color: rgba(23, 20, 26, .72); font-weight: 700; }
// 두 상태를 겹쳐 두고 steps 로 자른다 — 반투명 유령 프레임 금지
.dh__diff { position: relative; margin: 0; min-height: 18px; }
.dh__a, .dh__b { position: absolute; inset: 0; display: inline-flex; align-items: center; gap: $sp-1; }
.dh__b { visibility: hidden; }
.dh__row.is-back .dh__a { visibility: hidden; }
.dh__row.is-back .dh__b { visibility: visible; }
.dh__diff del {
color: $error;
font-size: 11px;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.dh__diff ins {
color: $color;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.dh__b b { color: $subject-ink; font-weight: 700; }
.dh__tag {
padding: 0 4px;
border-radius: $r-xs;
background: $subject-mint;
color: $subject-ink;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.dh__undo {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.dh__undo:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dh__u { display: inline-flex; width: 11px; height: 11px; }
.dh__row.is-back .dh__undo { background: $soft; color: $color; }
.dh__row.is-back .dh__u { transform: rotate(-360deg); transition: transform $dur-base $easing; }
.dh__live {
margin: $sp-2 0 0;
color: rgba(23, 20, 26, .72);
font-size: 10px;
}
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 되돌림은 steps 컷으로 한 프레임에 자르고, 프레임 수는 아이콘 회전과 칩 팝이 채운다
@keyframes dh-a-1 { 0%, 24% { visibility: visible; } 25%, 87% { visibility: hidden; } 88%, 100% { visibility: visible; } }
@keyframes dh-b-1 { 0%, 24% { visibility: hidden; } 25%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes dh-a-2 { 0%, 54% { visibility: visible; } 55%, 87% { visibility: hidden; } 88%, 100% { visibility: visible; } }
@keyframes dh-b-2 { 0%, 54% { visibility: hidden; } 55%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes dh-spin-1 {
0%, 24% { transform: rotate(0deg); }
30%, 87% { transform: rotate(-360deg); }
88%, 100% { transform: rotate(0deg); }
}
@keyframes dh-spin-2 {
0%, 54% { transform: rotate(0deg); }
60%, 87% { transform: rotate(-360deg); }
88%, 100% { transform: rotate(0deg); }
}
@keyframes dh-pop-1 {
0%, 24% { transform: scale(1); }
27% { transform: scale(1.14); }
31%, 87% { transform: scale(1); }
88%, 100% { transform: scale(1); }
}
@keyframes dh-pop-2 {
0%, 54% { transform: scale(1); }
57% { transform: scale(1.14); }
61%, 100% { transform: scale(1); }
}
@keyframes dh-n-tick {
0%, 24% { transform: translateX(0); }
25%, 54% { transform: translateX(-2px); }
55%, 87% { transform: translateX(-4px); }
88%, 100% { transform: translateX(0); }
}
.dh.is-demo {
.dh__row[data-row="1"] .dh__a { animation-name: dh-a-1; }
.dh__row[data-row="1"] .dh__b { animation-name: dh-b-1; }
.dh__row[data-row="2"] .dh__a { animation-name: dh-a-2; }
.dh__row[data-row="2"] .dh__b { animation-name: dh-b-2; }
.dh__a, .dh__b {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dh__row[data-row="1"] .dh__u { animation-name: dh-spin-1; }
.dh__row[data-row="2"] .dh__u { animation-name: dh-spin-2; }
.dh__u {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dh__row[data-row="1"] .dh__undo { animation-name: dh-pop-1; }
.dh__row[data-row="2"] .dh__undo { animation-name: dh-pop-2; }
.dh__undo {
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.dh__n {
animation-name: dh-n-tick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.dh { padding: $sp-2; font-size: 11px; }
.dh__h { margin-bottom: $sp-1; }
.dh__row { padding: 0 $sp-2; }
.dh__f { font-size: 10px; }
.dh__undo { font-size: 10px; }
.dh__live { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dh__row.is-back .dh__u { transform: none; transition: none; }
.dh.is-demo .dh__a,
.dh.is-demo .dh__b,
.dh.is-demo .dh__u,
.dh.is-demo .dh__undo,
.dh.is-demo .dh__n { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 1·2번 줄은 되돌린 상태
.dh.is-demo .dh__row[data-row="1"] .dh__a,
.dh.is-demo .dh__row[data-row="2"] .dh__a { visibility: hidden !important; }
.dh.is-demo .dh__row[data-row="1"] .dh__b,
.dh.is-demo .dh__row[data-row="2"] .dh__b { visibility: visible !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dh {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.dh__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.dh__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #2f6df6;
}
.dh__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.dh__n {
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
background: rgb(94.2901960784%, 95.9921568627%, 99.7529411765%);
color: #0a4fe8;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
}
.dh__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.dh__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 8px;
background: #ffffff;
}
.dh__f {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
}
.dh__diff {
position: relative;
margin: 0;
min-height: 18px;
}
.dh__a, .dh__b {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.dh__b {
visibility: hidden;
}
.dh__row.is-back .dh__a {
visibility: hidden;
}
.dh__row.is-back .dh__b {
visibility: visible;
}
.dh__diff del {
color: #c2361a;
font-size: 11px;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.dh__diff ins {
color: #2f6df6;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.dh__b b {
color: #17141a;
font-weight: 700;
}
.dh__tag {
padding: 0 4px;
border-radius: 4px;
background: #4cd4a6;
color: #17141a;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.dh__undo {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.dh__undo:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dh__u {
display: inline-flex;
width: 11px;
height: 11px;
}
.dh__row.is-back .dh__undo {
background: rgb(94.2901960784%, 95.9921568627%, 99.7529411765%);
color: #2f6df6;
}
.dh__row.is-back .dh__u {
transform: rotate(-360deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dh__live {
margin: 8px 0 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
@keyframes dh-a-1 {
0%, 24% {
visibility: visible;
}
25%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes dh-b-1 {
0%, 24% {
visibility: hidden;
}
25%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes dh-a-2 {
0%, 54% {
visibility: visible;
}
55%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes dh-b-2 {
0%, 54% {
visibility: hidden;
}
55%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes dh-spin-1 {
0%, 24% {
transform: rotate(0deg);
}
30%, 87% {
transform: rotate(-360deg);
}
88%, 100% {
transform: rotate(0deg);
}
}
@keyframes dh-spin-2 {
0%, 54% {
transform: rotate(0deg);
}
60%, 87% {
transform: rotate(-360deg);
}
88%, 100% {
transform: rotate(0deg);
}
}
@keyframes dh-pop-1 {
0%, 24% {
transform: scale(1);
}
27% {
transform: scale(1.14);
}
31%, 87% {
transform: scale(1);
}
88%, 100% {
transform: scale(1);
}
}
@keyframes dh-pop-2 {
0%, 54% {
transform: scale(1);
}
57% {
transform: scale(1.14);
}
61%, 100% {
transform: scale(1);
}
}
@keyframes dh-n-tick {
0%, 24% {
transform: translateX(0);
}
25%, 54% {
transform: translateX(-2px);
}
55%, 87% {
transform: translateX(-4px);
}
88%, 100% {
transform: translateX(0);
}
}
.dh.is-demo .dh__row[data-row="1"] .dh__a {
animation-name: dh-a-1;
}
.dh.is-demo .dh__row[data-row="1"] .dh__b {
animation-name: dh-b-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__a {
animation-name: dh-a-2;
}
.dh.is-demo .dh__row[data-row="2"] .dh__b {
animation-name: dh-b-2;
}
.dh.is-demo .dh__a, .dh.is-demo .dh__b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__row[data-row="1"] .dh__u {
animation-name: dh-spin-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__u {
animation-name: dh-spin-2;
}
.dh.is-demo .dh__u {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__row[data-row="1"] .dh__undo {
animation-name: dh-pop-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__undo {
animation-name: dh-pop-2;
}
.dh.is-demo .dh__undo {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__n {
animation-name: dh-n-tick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.dh {
padding: 8px;
font-size: 11px;
}
.dh__h {
margin-bottom: 4px;
}
.dh__row {
padding: 0 8px;
}
.dh__f {
font-size: 10px;
}
.dh__undo {
font-size: 10px;
}
.dh__live {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dh__row.is-back .dh__u {
transform: none;
transition: none;
}
.dh.is-demo .dh__a,
.dh.is-demo .dh__b,
.dh.is-demo .dh__u,
.dh.is-demo .dh__undo,
.dh.is-demo .dh__n {
animation: none !important;
transform: none !important;
}
.dh.is-demo .dh__row[data-row="1"] .dh__a,
.dh.is-demo .dh__row[data-row="2"] .dh__a {
visibility: hidden !important;
}
.dh.is-demo .dh__row[data-row="1"] .dh__b,
.dh.is-demo .dh__row[data-row="2"] .dh__b {
visibility: visible !important;
}
}// 실제 동작: 되돌리기를 누른 항목만 이전 값으로 돌아간다 (화면 표시와 aria 상태 둘 다).
var dh = document.getElementById('dh');
var dhN = document.getElementById('dh-n');
var dhLive = document.getElementById('dh-live');
var dhLeft = 3;
function dhCount() {
dhN.textContent = dhN.getAttribute('data-t').replace('{n}', dhLeft);
}
document.querySelectorAll('.dh__undo').forEach(function (b) {
b.addEventListener('click', function () {
dh.classList.remove('is-demo');
var row = b.closest('.dh__row');
var back = row.classList.toggle('is-back');
b.setAttribute('aria-pressed', back ? 'true' : 'false');
dhLeft += back ? -1 : 1;
dhCount();
dhLive.textContent = back
? b.getAttribute('data-f') + 'を前の値に戻しました'
: b.getAttribute('data-f') + 'を再び修正しました';
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChangeHistoryDiff.module.scss";
export type ChangeHistoryDiffProps = {
heading?: string;
groupLabel?: string;
undoLabel?: string;
revertedTag?: string;
liveHint?: string;
restoredMsg?: string;
editedAgainMsg?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Field = { key: string; f: string; oldV: string; newV: string };
const FIELDS: Field[] = [
{ key: "due", f: "Due date", oldV: "Oct 15", newV: "Oct 8" },
{ key: "owner", f: "Owner", oldV: "Lee Jun", newV: "Park Seo-a" },
{ key: "qty", f: "Qty", oldV: "120", newV: "150" },
];
function UndoIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128" />
</g>
</svg>
);
}
export default function ChangeHistoryDiff({
heading = "Change history",
groupLabel = "Quote change history",
undoLabel = "Undo",
revertedTag = "Reverted",
liveHint = "Undoing an item reports here",
restoredMsg = " was restored to the previous value",
editedAgainMsg = " was edited again",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChangeHistoryDiffProps) {
const [live, setLive] = useState(demo);
const [back, setBack] = useState<Record<string, boolean>>({});
const [msg, setMsg] = useState(liveHint);
const left = FIELDS.filter((x) => !back[x.key]).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function undo(k: string, f: string) {
setLive(false);
const nextVal = !back[k];
setBack({ ...back, [k]: nextVal });
setMsg(nextVal ? f + restoredMsg : f + editedAgainMsg);
}
const cls = [styles.dh, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><UndoIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.n} aria-live="polite">
{left} edited
</span>
</header>
<ul className={styles.list}>
{FIELDS.map((x, i) => (
<li key={x.key} className={[styles.row, i + 1 <= 2 ? styles[`r${i + 1}`] : "", back[x.key] ? styles.isBack : ""].filter(Boolean).join(" ")}>
<span className={styles.f}>{x.f}</span>
<p className={styles.diff}>
<span className={styles.a}>
<del>{x.oldV}</del> → <ins>{x.newV}</ins>
</span>
<span className={styles.b}>
<b>{x.oldV}</b>
<i className={styles.tag}>{revertedTag}</i>
</span>
</p>
<button className={styles.undoBtn} type="button" aria-pressed={!!back[x.key]} onClick={() => undo(x.key, x.f)}>
<span className={styles.u} aria-hidden="true"><UndoIcon /></span>
{undoLabel}
</button>
</li>
))}
</ul>
<p className={styles.live} aria-live="polite">{msg}</p>
</section>
);
}02 修正した値を戻せる変更履歴 diff — 計測 累積面積 2.813% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · 通知 ui 9選 — サイドバーに置ける活動フィードウィジェット
このデモが属する記事
通知 ui 9選 — サイドバーに置ける活動フィードウィジェット通知 ui とは、一瞬だけ現れて消えるトーストではなく、画面の片隅に居座り、積み上がり続けるリストのことです。サイドバーに置けるこの9個のウィジェットは、既読、修正前の値、ログレベル、今日の件数まで、すべて実際の状態を書き換えて動きます。 この記事のデモをすべて見る

