Skip to content

Commit e48db11

Browse files
devmgnclaude
andauthored
refactor(hooks): extract composition store from useIsComposing (#2716)
useIsComposing 内にあった capture/bubble それぞれの Map ベースのストアを独立モジュール (utils/compositionStore) に切り出し、フックは `useSyncExternalStore` でストアを選ぶだけの薄いレイヤーに整理した。ストアは React 非依存になりユニットテストが書きやすく、購読切れ目で composing が漏れない挙動 (Bug A) も契約として明示的に保証される。 あわせてテストを describe ブロックで階層化し、store と重複する観点を削除して整理。Storybook はキャプチャ/バブルの発火順を目視確認できるデモに刷新。 Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent ea7d60e commit e48db11

6 files changed

Lines changed: 799 additions & 242 deletions

File tree

src/hooks/useIsComposing/useIsComposing.stories.tsx

Lines changed: 167 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,183 @@
11
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2-
import { useId } from "react";
2+
import { useEffect, useId, useRef, useState } from "react";
33
import { useIsComposing } from "./useIsComposing";
44
import { Input } from "../../components/Input";
55

6+
interface LogEntry {
7+
id: number;
8+
label: string;
9+
event: "compositionstart" | "compositionend";
10+
accent: "capture" | "target" | "bubble";
11+
}
12+
13+
const accentClass: Record<LogEntry["accent"], string> = {
14+
capture: "text-primary",
15+
target: "text-foreground",
16+
bubble: "text-muted-foreground",
17+
};
18+
19+
function StateCard({
20+
label,
21+
phase,
22+
value,
23+
}: {
24+
label: string;
25+
phase: string;
26+
value: boolean;
27+
}) {
28+
return (
29+
<div
30+
className={`flex flex-col gap-1 rounded-md border p-3 transition-colors ${
31+
value ? "border-primary bg-primary/10" : "border-border bg-background"
32+
}`}
33+
>
34+
<span className="text-label-sm text-muted-foreground">{phase}</span>
35+
<code className="text-label-md text-foreground">{label}</code>
36+
<span
37+
className={`font-mono text-body-md ${
38+
value ? "text-primary" : "text-muted-foreground"
39+
}`}
40+
>
41+
{String(value)}
42+
</span>
43+
</div>
44+
);
45+
}
46+
647
const meta = {
748
component: undefined,
849
tags: ["!manifest"],
9-
parameters: {
10-
layout: "centered",
11-
},
50+
parameters: { layout: "centered" },
1251
render: () => {
13-
const isComposing = useIsComposing();
14-
const id = useId();
52+
const isComposingCapture = useIsComposing(true);
53+
const isComposingBubble = useIsComposing(false);
54+
const [log, setLog] = useState<LogEntry[]>([]);
55+
const counterRef = useRef(0);
56+
const inputId = useId();
57+
58+
useEffect(() => {
59+
const append = (
60+
label: string,
61+
event: LogEntry["event"],
62+
accent: LogEntry["accent"],
63+
) => {
64+
counterRef.current += 1;
65+
const id = counterRef.current;
66+
setLog((prev) => [...prev, { id, label, event, accent }]);
67+
};
68+
69+
const phases = [
70+
{ capture: true, accent: "capture", label: "📥 document (capture)" },
71+
{ capture: false, accent: "bubble", label: "📤 document (bubble)" },
72+
] as const;
73+
74+
const handlers = phases.flatMap(({ capture, accent, label }) =>
75+
(["compositionstart", "compositionend"] as const).map((event) => ({
76+
event,
77+
capture,
78+
listener: () => {
79+
append(label, event, accent);
80+
},
81+
})),
82+
);
83+
84+
for (const { event, listener, capture } of handlers) {
85+
document.addEventListener(event, listener, capture);
86+
}
87+
return () => {
88+
for (const { event, listener, capture } of handlers) {
89+
document.removeEventListener(event, listener, capture);
90+
}
91+
};
92+
}, []);
93+
94+
const appendTarget = (event: LogEntry["event"]) => {
95+
counterRef.current += 1;
96+
const id = counterRef.current;
97+
setLog((prev) => [
98+
...prev,
99+
{ id, label: "🎯 input (target / React)", event, accent: "target" },
100+
]);
101+
};
15102

16103
return (
17-
<div className="flex flex-col gap-4">
18-
<div className="flex items-center gap-2">
19-
<p>isComposing</p>
20-
<p className="rounded border border-gray-800 px-1 py-0.5 text-sm">
21-
{isComposing.toString()}
104+
<div className="flex w-[520px] flex-col gap-5">
105+
<div className="flex flex-col gap-1">
106+
<p className="text-label-lg font-semibold text-foreground">
107+
capture と bubble の発火順デモ
108+
</p>
109+
<p className="text-body-sm text-muted-foreground">
110+
下の入力欄に日本語 IME で「かんじ」などと入力して変換すると、 同じ{" "}
111+
<code>compositionstart</code> が capture → target → bubble の順で
112+
記録されます。<code>useIsComposing(true)</code>{" "}
113+
はこのうち最も早いタイミング、
114+
<code>useIsComposing(false)</code>{" "}
115+
は最も遅いタイミングで状態を切り替えます。
22116
</p>
23117
</div>
118+
119+
<div className="grid grid-cols-2 gap-3">
120+
<StateCard
121+
label="useIsComposing(true)"
122+
phase="capture phase"
123+
value={isComposingCapture}
124+
/>
125+
<StateCard
126+
label="useIsComposing(false)"
127+
phase="bubble phase"
128+
value={isComposingBubble}
129+
/>
130+
</div>
131+
24132
<div className="flex flex-col gap-2">
25-
<label htmlFor={id}>your text</label>
26-
<Input id={id} />
133+
<label htmlFor={inputId} className="text-label-md text-foreground">
134+
your text
135+
</label>
136+
<Input
137+
id={inputId}
138+
onCompositionStart={() => {
139+
appendTarget("compositionstart");
140+
}}
141+
onCompositionEnd={() => {
142+
appendTarget("compositionend");
143+
}}
144+
/>
145+
</div>
146+
147+
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/40 p-3">
148+
<div className="flex items-center justify-between">
149+
<span className="text-label-md text-foreground">Event log</span>
150+
<button
151+
type="button"
152+
className="text-label-sm text-muted-foreground underline"
153+
onClick={() => {
154+
counterRef.current = 0;
155+
setLog([]);
156+
}}
157+
>
158+
clear
159+
</button>
160+
</div>
161+
{log.length === 0 ? (
162+
<p className="text-body-sm text-muted-foreground">
163+
まだイベントが記録されていません。
164+
</p>
165+
) : (
166+
<ol className="flex flex-col gap-1 font-mono text-body-sm">
167+
{log.map((entry) => (
168+
<li
169+
key={entry.id}
170+
className={`flex items-center gap-2 ${accentClass[entry.accent]}`}
171+
>
172+
<span className="w-6 text-right text-muted-foreground tabular-nums">
173+
{entry.id}
174+
</span>
175+
<span className="min-w-[170px]">{entry.label}</span>
176+
<span>{entry.event}</span>
177+
</li>
178+
))}
179+
</ol>
180+
)}
27181
</div>
28182
</div>
29183
);

0 commit comments

Comments
 (0)