@aiquants/spreadsheet 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +1103 -0
  2. package/LICENSE.md +155 -0
  3. package/README.md +817 -0
  4. package/dist/index.cjs +21 -0
  5. package/dist/index.d.cts +1960 -0
  6. package/dist/index.d.ts +1960 -0
  7. package/dist/index.js +7711 -0
  8. package/dist/styles/spreadsheet.css +2 -0
  9. package/dist/styles/spreadsheet.standalone.css +3 -0
  10. package/package.json +94 -0
  11. package/src/Spreadsheet.tsx +2313 -0
  12. package/src/clipboard/clipPayload.ts +1242 -0
  13. package/src/clipboard/fixtures/excel.fixture.ts +301 -0
  14. package/src/clipboard/fixtures/html.fixture.ts +90 -0
  15. package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
  16. package/src/clipboard/htmlInterop.ts +1694 -0
  17. package/src/clipboard/numberFormatCodec.ts +870 -0
  18. package/src/clipboard/pasteSpecial.ts +608 -0
  19. package/src/clipboard/tiling.ts +370 -0
  20. package/src/clipboard/tsv.ts +186 -0
  21. package/src/clipboard/useClipboard.ts +1154 -0
  22. package/src/commands/selfCalc.ts +279 -0
  23. package/src/commands/useCommands.ts +946 -0
  24. package/src/editor/useCellEditor.ts +708 -0
  25. package/src/grid/CellView.tsx +51 -0
  26. package/src/grid/GridBody.tsx +489 -0
  27. package/src/header/ColumnHeader.tsx +167 -0
  28. package/src/header/RowHeader.tsx +144 -0
  29. package/src/header/columnName.ts +31 -0
  30. package/src/header/resizeGates.ts +146 -0
  31. package/src/header/resizeStrips.ts +79 -0
  32. package/src/header/useHeaderResize.ts +598 -0
  33. package/src/header/useHeaderSelection.ts +454 -0
  34. package/src/index.ts +122 -0
  35. package/src/keyboard/compositionGuard.ts +53 -0
  36. package/src/keyboard/ensureCellVisible.ts +77 -0
  37. package/src/keyboard/shortcuts.ts +718 -0
  38. package/src/keyboard/useGridKeyboard.ts +940 -0
  39. package/src/model/axisModel.ts +186 -0
  40. package/src/model/cellAnnotations.ts +92 -0
  41. package/src/model/cellCoord.ts +114 -0
  42. package/src/model/cellFormat.ts +562 -0
  43. package/src/model/cellPaint.ts +537 -0
  44. package/src/model/conditionalFormat.ts +805 -0
  45. package/src/model/dataAdapter.ts +478 -0
  46. package/src/model/dataValidation.ts +542 -0
  47. package/src/model/formulaRefs.ts +984 -0
  48. package/src/model/frozenPartition.ts +100 -0
  49. package/src/model/labels.ts +228 -0
  50. package/src/model/viewStore.ts +124 -0
  51. package/src/overlay/SelectionOverlay.tsx +1425 -0
  52. package/src/overlay/useFillHandle.ts +432 -0
  53. package/src/popover/CommentPopover.tsx +109 -0
  54. package/src/popover/LinkPopover.tsx +108 -0
  55. package/src/popover/ListPopover.tsx +161 -0
  56. package/src/popover/MessagePopover.tsx +36 -0
  57. package/src/popover/NotePopover.tsx +106 -0
  58. package/src/popover/NoteTip.tsx +26 -0
  59. package/src/popover/PopoverLayer.tsx +210 -0
  60. package/src/popover/placement.ts +138 -0
  61. package/src/popover/usePopovers.ts +642 -0
  62. package/src/selection/selectionState.ts +239 -0
  63. package/src/selection/useAutoScroller.ts +226 -0
  64. package/src/selection/usePointDrag.ts +273 -0
  65. package/src/selection/useSelectionEngine.ts +687 -0
  66. package/src/styles/components.entry.css +10 -0
  67. package/src/styles/spreadsheet.css +1029 -0
  68. package/src/styles/standalone.entry.css +9 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @fileoverview Default validation list picker (v0.8 §4.1.16): a combobox input narrowing the
3
+ * list rule's values by case-insensitive PREFIX, over a `role="listbox"` of `role="option"`
4
+ * items tracked through `aria-activedescendant`. Keys: ArrowDown / ArrowUp move the active
5
+ * option (clamped to the RENDERED options), Enter commits it, Escape closes; a pointer click
6
+ * commits the clicked option (its pointerdown is prevented so the input keeps focus). An empty
7
+ * match shows `labels.listEmpty`. At most `LIST_POPOVER_MAX_OPTIONS` matches are mounted — a
8
+ * larger match set appends one non-option `labels.listMore` row naming the hidden count (a
9
+ * pasted foreign rule may carry an arbitrarily large values array; mounting it whole would
10
+ * freeze the tab, so the picker renders a bounded prefix and the prefix filter narrows the
11
+ * rest). Leaving by focus closes. DOM hooks: `data-aqsp-popover="list"`, `data-aqsp-list-input`,
12
+ * `data-aqsp-list-option="<value>"`, `data-aqsp-list-empty`, `data-aqsp-list-more`.
13
+ *
14
+ * 既定の検証リスト (v0.8 §4.1.16): list 規則の値を大文字小文字を無視した**前方一致**で絞る
15
+ * combobox 入力 + `role="listbox"` / `role="option"` (aria-activedescendant で追跡)。キー:
16
+ * ArrowDown / ArrowUp で活性項目を移動 (**描画済み**候補へクランプ)、Enter で確定、Escape で
17
+ * 閉じる。ポインタのクリックはその項目を確定 (pointerdown を止めて入力のフォーカスを保つ)。
18
+ * 一致なしは `labels.listEmpty`。マウントする候補は `LIST_POPOVER_MAX_OPTIONS` 件まで — 超過分は
19
+ * 非 option の `labels.listMore` 行 1 つが非表示件数を示す (貼り付けた外来規則の values は任意長で、
20
+ * 全量マウントはタブを固まらせるため、描画は上限つきの先頭区間に絞り残りは前方一致で狭める)。
21
+ * フォーカス離脱で閉じる。
22
+ */
23
+
24
+ import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useMemo, useRef, useState } from "react"
25
+ import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
26
+ import type { ListPopoverContext } from "./usePopovers.ts"
27
+
28
+ export type ListPopoverProps = { ctx: ListPopoverContext; labels: SpreadsheetLabels }
29
+
30
+ /**
31
+ * The most options one render mounts (v0.8 §4.1.16): a foreign list rule pasted from the wire
32
+ * may carry hundreds of thousands of values, and mounting one DOM node per value freezes the
33
+ * tab on open and on every keystroke re-filter. Matches beyond the cap stay reachable through
34
+ * the prefix filter and are announced by the `labels.listMore` row.
35
+ * 1 レンダーでマウントする候補数の上限 (v0.8 §4.1.16)。wire から貼り付けた外来 list 規則の values は
36
+ * 数十万件になり得て、1 値 1 DOM ノードのマウントは開いた瞬間と絞込のたびにタブを固まらせる。上限超の
37
+ * 一致は前方一致の絞込で到達でき、`labels.listMore` 行が存在を告知する。
38
+ */
39
+ export const LIST_POPOVER_MAX_OPTIONS = 500
40
+
41
+ /**
42
+ * Renders the list picker for one cell.
43
+ * 1 セルのリスト選択を描画する。
44
+ */
45
+ export const ListPopover = ({ ctx, labels }: ListPopoverProps): ReactNode => {
46
+ const [typed, setTyped] = useState(ctx.typed)
47
+ const [index, setIndex] = useState(0)
48
+ const listId = useId()
49
+ const settlingRef = useRef(false)
50
+ // 前方一致 (大文字小文字無視) の絞込 — 元の順序を保つ
51
+ const filtered = useMemo(() => {
52
+ const needle = typed.toLowerCase()
53
+ return ctx.current.filter((value) => value.toLowerCase().startsWith(needle))
54
+ }, [ctx.current, typed])
55
+ // マウントは上限まで — 外来規則の巨大 values で 1 コミットに数十万ノードを積まない
56
+ const visible = useMemo(() => (filtered.length > LIST_POPOVER_MAX_OPTIONS ? filtered.slice(0, LIST_POPOVER_MAX_OPTIONS) : filtered), [filtered])
57
+ // 上限で切られた一致の数 (> 0 のとき listMore 行を描く)
58
+ const hidden = filtered.length - visible.length
59
+ // 絞込後の活性 index は描画済み候補の末尾へクランプ (項目が減っても・上限超でも範囲外を指さない)
60
+ const active = visible.length === 0 ? -1 : Math.min(index, visible.length - 1)
61
+
62
+ const settle = (action: () => void): void => {
63
+ settlingRef.current = true
64
+ try {
65
+ action()
66
+ } finally {
67
+ settlingRef.current = false
68
+ }
69
+ }
70
+ const commitActive = (): void => {
71
+ if (active < 0) {
72
+ return
73
+ }
74
+ settle(() => ctx.commit(visible[active]))
75
+ }
76
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
77
+ if (event.key === "Escape") {
78
+ event.preventDefault()
79
+ settle(ctx.close)
80
+ return
81
+ }
82
+ if (event.key === "Enter") {
83
+ event.preventDefault()
84
+ commitActive()
85
+ return
86
+ }
87
+ if (event.key === "ArrowDown") {
88
+ event.preventDefault()
89
+ // 上端のクランプはレンダー時 (active の導出) が担う — ここで重ねて丸めない
90
+ setIndex(active + 1)
91
+ return
92
+ }
93
+ if (event.key === "ArrowUp") {
94
+ event.preventDefault()
95
+ setIndex(Math.max(active - 1, 0))
96
+ }
97
+ }
98
+ const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
99
+ if (settlingRef.current || (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) {
100
+ return
101
+ }
102
+ settle(ctx.close)
103
+ }
104
+ const optionId = (i: number): string => `${listId}-${i}`
105
+
106
+ return (
107
+ // biome-ignore lint/a11y/noStaticElementInteractions: 枠は combobox + listbox の focusout 合流点 (relatedTarget の内外判定) — 自身は操作面でない
108
+ <div className="aqsp-popover-list" data-aqsp-popover="list" onBlur={onBlur}>
109
+ <input
110
+ className="aqsp-popover-input"
111
+ data-aqsp-list-input=""
112
+ data-aqsp-popover-autofocus=""
113
+ type="text"
114
+ role="combobox"
115
+ aria-label={resolveLabel(labels, "listTitle")}
116
+ aria-expanded="true"
117
+ aria-autocomplete="list"
118
+ aria-controls={listId}
119
+ aria-activedescendant={active < 0 ? undefined : optionId(active)}
120
+ value={typed}
121
+ onChange={(event) => {
122
+ setTyped(event.currentTarget.value)
123
+ setIndex(0)
124
+ }}
125
+ onKeyDown={onKeyDown}
126
+ />
127
+ {/* role="listbox" は combobox の候補一覧 (select 要素では aria-activedescendant 追跡と前方一致絞込を表現できない) */}
128
+ <div className="aqsp-popover-options" id={listId} role="listbox" aria-label={resolveLabel(labels, "listTitle")}>
129
+ {visible.length === 0 ? (
130
+ <div className="aqsp-popover-empty" data-aqsp-list-empty="">
131
+ {resolveLabel(labels, "listEmpty")}
132
+ </div>
133
+ ) : (
134
+ visible.map((value, i) => (
135
+ // key は位置由来 (optionId) — wire 契約は重複値を許し、値 key は兄弟間で衝突する
136
+ // (項目は毎レンダー絞込から再導出される状態なし葉なので位置 key で安全)
137
+ // biome-ignore lint/a11y/useKeyWithClickEvents: キーボード操作は combobox 入力の onKeyDown が担う (aria-activedescendant パターン — 項目はポインタ確定の面、tabIndex -1 でプログラム的にだけ焦点可)
138
+ <div
139
+ key={optionId(i)}
140
+ className="aqsp-popover-option"
141
+ id={optionId(i)}
142
+ role="option"
143
+ tabIndex={-1}
144
+ aria-selected={i === active}
145
+ data-aqsp-list-option={value}
146
+ onPointerDown={(event) => event.preventDefault()}
147
+ onClick={() => settle(() => ctx.commit(value))}>
148
+ {value}
149
+ </div>
150
+ ))
151
+ )}
152
+ {hidden > 0 ? (
153
+ // 描画特性は listEmpty 行と同じ情報行 (クラス共有)、role は与えない — 選択不能な告知面
154
+ <div className="aqsp-popover-empty" data-aqsp-list-more="">
155
+ {resolveLabel(labels, "listMore", hidden)}
156
+ </div>
157
+ ) : null}
158
+ </div>
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @fileoverview Default validation message popover (v0.8 §4.1.16): a `role="status"` box with the
3
+ * title and the message (the rule's input message, or a strict rejection verdict when
4
+ * `current.rejected`) and a Close button. It never takes focus — the layer skips the autofocus for
5
+ * this kind so typing continues in the sink. DOM hooks: `data-aqsp-popover="message"`,
6
+ * `data-aqsp-popover-title`, `data-aqsp-popover-message-text`, `data-aqsp-popover-rejected`,
7
+ * `data-aqsp-popover-close`.
8
+ *
9
+ * 既定の検証メッセージ (v0.8 §4.1.16): 題名と本文 (規則の入力メッセージ、または `current.rejected`
10
+ * のとき strict 拒否の判定文) + 閉じるボタンの `role="status"`。フォーカスは奪わない — 層はこの
11
+ * kind の初期フォーカスを省き、入力はシンクで続く。
12
+ */
13
+
14
+ import type { ReactNode } from "react"
15
+ import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
16
+ import type { MessagePopoverContext } from "./usePopovers.ts"
17
+
18
+ export type MessagePopoverProps = { ctx: MessagePopoverContext; labels: SpreadsheetLabels }
19
+
20
+ /**
21
+ * Renders the validation message.
22
+ * 検証メッセージを描画する。
23
+ */
24
+ export const MessagePopover = ({ ctx, labels }: MessagePopoverProps): ReactNode => (
25
+ <div className="aqsp-popover-message" data-aqsp-popover="message" data-aqsp-popover-rejected={ctx.current.rejected ? "" : undefined} role="status">
26
+ <div className="aqsp-popover-title" data-aqsp-popover-title="">
27
+ {ctx.current.title}
28
+ </div>
29
+ <p className="aqsp-popover-message-text" data-aqsp-popover-message-text="">
30
+ {ctx.current.message}
31
+ </p>
32
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={ctx.close}>
33
+ ×
34
+ </button>
35
+ </div>
36
+ )
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @fileoverview Default note editor popover (v0.8 §4.1.16): a textarea draft with Save / Close
3
+ * buttons. Keys: Escape saves-if-changed and closes in the normal state, closes WITHOUT saving in
4
+ * the rejected state; Ctrl+Enter saves (or retries after a rejection). Leaving the popover by
5
+ * focus saves-if-changed — an anchor scrolled under a frozen band is clipped, not un-rendered
6
+ * (SelectionOverlay `placeAnchor`), so the textarea keeps focus and that is NOT a leave: the
7
+ * editor hides and comes back with its draft. The textarea's `aria-describedby` points at the hint paragraph, which
8
+ * swaps between `labels.noteEscapeHint` and `labels.noteRejectedHint` with the rejected state.
9
+ * DOM hooks: `data-aqsp-popover="note"`, `data-aqsp-popover-title`, `data-aqsp-note-input`,
10
+ * `data-aqsp-popover-save`, `data-aqsp-popover-close`, `data-aqsp-popover-hint`,
11
+ * `data-aqsp-popover-rejected`.
12
+ *
13
+ * 既定の note エディタ (v0.8 §4.1.16): textarea の下書き + 保存 / 閉じるボタン。キー: Escape は
14
+ * 通常状態で「変更があれば保存して閉じる」、拒否状態では保存せず閉じる。Ctrl+Enter は保存 (拒否後は
15
+ * 再試行)。フォーカスがポップオーバーを離れたら変更があれば保存 — 凍結帯の下へ潜ったアンカーは
16
+ * clip されるだけで描画から外れない (SelectionOverlay の placeAnchor) ので textarea はフォーカスを保ち、
17
+ * 離脱には当たらない: エディタは隠れて下書きごと戻る。textarea の aria-describedby は
18
+ * ヒント段落を指し、拒否状態で `labels.noteEscapeHint` ↔ `labels.noteRejectedHint` が差し替わる。
19
+ */
20
+
21
+ import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
22
+ import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
23
+ import type { NotePopoverContext } from "./usePopovers.ts"
24
+
25
+ export type NotePopoverProps = { ctx: NotePopoverContext; labels: SpreadsheetLabels }
26
+
27
+ /**
28
+ * Renders the note editor for one cell.
29
+ * 1 セルの note エディタを描画する。
30
+ */
31
+ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
32
+ const initial = ctx.current === undefined ? "" : ctx.current
33
+ const [draft, setDraft] = useState(initial)
34
+ const titleId = useId()
35
+ const hintId = useId()
36
+ /** True while THIS component drives a commit / close (its synchronous focusout must not re-fire the save). / 自分が commit / close を駆動している間 true (同期 focusout の二重保存を防ぐ)。 */
37
+ const settlingRef = useRef(false)
38
+
39
+ /** Runs a terminal action with the focusout re-entry guard engaged. / focusout 再入ガードを立てて終端動作を実行。 */
40
+ const settle = (action: () => void): void => {
41
+ settlingRef.current = true
42
+ try {
43
+ action()
44
+ } finally {
45
+ settlingRef.current = false
46
+ }
47
+ }
48
+ const save = (): void => {
49
+ // 空の下書きはノート消去
50
+ settle(() => ctx.commit(draft === "" ? null : draft))
51
+ }
52
+ const saveIfChanged = (): void => {
53
+ if (draft !== initial) {
54
+ save()
55
+ } else {
56
+ settle(ctx.close)
57
+ }
58
+ }
59
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
60
+ if (event.key === "Escape") {
61
+ event.preventDefault()
62
+ // 拒否状態の Escape は保存せず閉じる、通常状態は変更があれば保存して閉じる
63
+ if (ctx.rejected) {
64
+ settle(ctx.close)
65
+ } else {
66
+ saveIfChanged()
67
+ }
68
+ return
69
+ }
70
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey) {
71
+ event.preventDefault()
72
+ save()
73
+ }
74
+ }
75
+ const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
76
+ // ポップオーバー内の移動 (ボタンへ) と自分が起こした遷移は離脱ではない
77
+ if (settlingRef.current || (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) {
78
+ return
79
+ }
80
+ // 拒否状態はフォーカス離脱で黙って再試行しない (利用者の明示操作を待つ)
81
+ if (ctx.rejected) {
82
+ return
83
+ }
84
+ saveIfChanged()
85
+ }
86
+
87
+ return (
88
+ <div className="aqsp-popover-note" data-aqsp-popover="note" data-aqsp-popover-rejected={ctx.rejected ? "" : undefined} role="dialog" aria-labelledby={titleId} onBlur={onBlur}>
89
+ <div className="aqsp-popover-title" data-aqsp-popover-title="" id={titleId}>
90
+ {resolveLabel(labels, "noteTitle")}
91
+ </div>
92
+ <textarea className="aqsp-popover-textarea" data-aqsp-note-input="" data-aqsp-popover-autofocus="" value={draft} aria-describedby={hintId} aria-invalid={ctx.rejected ? true : undefined} onChange={(event) => setDraft(event.currentTarget.value)} onKeyDown={onKeyDown} />
93
+ <p className="aqsp-popover-hint" id={hintId} data-aqsp-popover-hint="">
94
+ {resolveLabel(labels, ctx.rejected ? "noteRejectedHint" : "noteEscapeHint")}
95
+ </p>
96
+ <div className="aqsp-popover-actions">
97
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" onClick={save}>
98
+ {resolveLabel(labels, "noteSave")}
99
+ </button>
100
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
101
+ ×
102
+ </button>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @fileoverview Default hover note tip (v0.8 §4.1.16): a read-only `role="tooltip"` box showing
3
+ * the hovered cell's note. It has no controls — the hook hides it (leave grace / Escape step 0)
4
+ * and the sink references it through `aria-describedby` while the hovered cell is active.
5
+ * DOM hook: `data-aqsp-popover="note-tip"`.
6
+ *
7
+ * 既定のホバー note tip (v0.8 §4.1.16): ホバーセルのノートを示す読み取り専用の `role="tooltip"`。
8
+ * 操作子は持たない — 消灯はフック (離脱猶予 / Escape step 0) が担い、ホバーセルが active の間は
9
+ * シンクが aria-describedby で参照する。
10
+ */
11
+
12
+ import type { ReactNode } from "react"
13
+ import type { SpreadsheetLabels } from "../model/labels.ts"
14
+ import type { NoteTipContext } from "./usePopovers.ts"
15
+
16
+ export type NoteTipProps = { ctx: NoteTipContext; labels: SpreadsheetLabels }
17
+
18
+ /**
19
+ * Renders the note tip text.
20
+ * note tip の本文を描画する。
21
+ */
22
+ export const NoteTip = ({ ctx }: NoteTipProps): ReactNode => (
23
+ <div className="aqsp-popover-note-tip" data-aqsp-popover="note-tip" role="tooltip">
24
+ {ctx.current}
25
+ </div>
26
+ )
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @fileoverview The popover layer DOM (v0.8 §4.1.15 — ADR-15): a sibling of the selection
3
+ * overlay inside `.aqsp-body-stack` holding the TWO resident anchors (`interactive`, `tip`) the
4
+ * overlay's repaintOverlay positions through the refs handed in here. A popover frame is mounted
5
+ * INSIDE its anchor only while the controller has a context (lazy mount — a closed layer renders
6
+ * the two empty anchors and nothing else); the frame is placed by `placePopover` relative to the
7
+ * anchor cell, so it follows the anchor through scrolling for free. A closed anchor is `inert`
8
+ * (it neither intercepts pointer events nor takes focus). The overlay's `placeAnchor` hides an
9
+ * anchor without a cell, outside the viewport or under a frozen band by CLIPPING it
10
+ * (`clip-path`), never by un-rendering it: an open frame — its focused textarea, its draft —
11
+ * stays mounted and focused while hidden and simply reappears when the cell scrolls back
12
+ * (hide-and-restore, plan §5.7-13; `display: none` would fire the browser's focus fixup and the
13
+ * editor's focus-leave rule). A frame is keyed by kind + cell, so re-opening the same kind on another
14
+ * cell remounts its component (fresh draft) while a rejected re-present on the same cell keeps
15
+ * it (the draft survives for the retry); every presented context is re-PLACED though — a same-cell
16
+ * re-present (a rejected retry, an inputMessage replaced by a strict rejection) re-measures at the
17
+ * new content so the previous `maxHeight` never clips it — while the initial focus fires once per
18
+ * placement (a re-present does not steal the caret). The layer also owns the cross-kind wiring: the IME guard
19
+ * (`isCompositionKeydown` swallowed in the capture phase before any popover handler), the
20
+ * compositionend registration, the Escape fallback (an un-prevented Escape closes — the default
21
+ * components prevent their own), and the initial focus (`[data-aqsp-popover-autofocus]`, skipped
22
+ * for the passive `message` kind). `renderPopover` replaces the default components per context.
23
+ * DOM hooks: `[data-aqsp-popover-layer]`, `[data-aqsp-popover-anchor="interactive" | "tip"]`,
24
+ * `[data-aqsp-popover-frame="<kind>"]`, `[data-aqsp-popover-open]` on an anchor holding a frame
25
+ * (anchor open-state only — the link editor's Open button is `data-aqsp-link-open`).
26
+ *
27
+ * ポップオーバー層の DOM (v0.8 §4.1.15 — ADR-15)。`.aqsp-body-stack` 内で選択 overlay の兄弟と
28
+ * して、overlay の repaintOverlay がここで渡す ref 経由で配置する 2 つの常設アンカー
29
+ * (`interactive` / `tip`) を持つ。フレームはコントローラに文脈があるときだけアンカーの**内側**へ
30
+ * マウントする (遅延マウント — 閉じた層は空のアンカー 2 つ以外を描かない)。フレームは
31
+ * placePopover でアンカーセル相対に置くので、スクロールにはアンカーごと追随する。閉じたアンカーは
32
+ * `inert` (ポインタも取らずフォーカスも受けない)。セルの無い / viewport 外 / 凍結帯の下のアンカーは
33
+ * overlay の placeAnchor が **clip** (`clip-path`) で隠し、描画から外すことはしない: 開いたフレーム
34
+ * (フォーカス中の textarea も下書きも) は隠れている間もマウント + フォーカスのまま残り、セルが戻れば
35
+ * そのまま再び見える (隠して戻す — 計画 §5.7-13。`display: none` はブラウザの focus fixup とエディタの
36
+ * focus-leave 規則を起こす)。フレームは kind + セルをキーにするため、同じ kind を別セルで開き直すとコンポーネントは
37
+ * 再マウント (新しい下書き)、同セルの拒否再提示は保持 (再試行のため下書きが残る)。ただし提示された文脈は
38
+ * 毎回**再配置**する — 同セルの再提示 (拒否の再試行、inputMessage の strict 拒否への差替) も新しい内容で
39
+ * 再計測し、前回の maxHeight が切り詰めない。初期フォーカスは配置 (kind + セル) につき 1 回
40
+ * (再提示はキャレットを奪わない)。
41
+ * 層は kind 横断の結線も所有: IME ガード (capture 段で isCompositionKeydown を握り潰す)、
42
+ * compositionend 登録、Escape フォールバック (preventDefault されていない Escape は閉じる —
43
+ * 既定コンポーネントは自前で prevent する)、初期フォーカス (`[data-aqsp-popover-autofocus]`、受動的な
44
+ * `message` は省く)。`renderPopover` は文脈ごとに既定コンポーネントを置き換える。
45
+ */
46
+
47
+ import { type CompositionEvent, type KeyboardEvent, type ReactNode, type RefObject, useLayoutEffect, useRef } from "react"
48
+ import type { SpreadsheetLabels } from "../model/labels.ts"
49
+ import { CommentPopover } from "./CommentPopover.tsx"
50
+ import { LinkPopover } from "./LinkPopover.tsx"
51
+ import { ListPopover } from "./ListPopover.tsx"
52
+ import { MessagePopover } from "./MessagePopover.tsx"
53
+ import { NotePopover } from "./NotePopover.tsx"
54
+ import { NoteTip } from "./NoteTip.tsx"
55
+ import { placePopover, popoverWidth } from "./placement.ts"
56
+ import type { PopoverContext, PopoverController } from "./usePopovers.ts"
57
+
58
+ export type PopoverLayerProps = {
59
+ controller: PopoverController
60
+ /** Consumer replacement of the default components (receives the discriminated context). / 既定コンポーネントの差替 (判別文脈を受け取る)。 */
61
+ renderPopover?: (ctx: PopoverContext) => ReactNode
62
+ labels: SpreadsheetLabels
63
+ /** The interactive anchor node (positioned by the overlay). / 対話アンカーノード (overlay が配置)。 */
64
+ interactiveAnchorRef: RefObject<HTMLDivElement | null>
65
+ /** The tip anchor node (positioned by the overlay). / tip アンカーノード (overlay が配置)。 */
66
+ tipAnchorRef: RefObject<HTMLDivElement | null>
67
+ }
68
+
69
+ /** The default component per kind. / kind 毎の既定コンポーネント。 */
70
+ const renderDefault = (ctx: PopoverContext, labels: SpreadsheetLabels): ReactNode => {
71
+ switch (ctx.kind) {
72
+ case "note":
73
+ return <NotePopover ctx={ctx} labels={labels} />
74
+ case "note-tip":
75
+ return <NoteTip ctx={ctx} labels={labels} />
76
+ case "comment":
77
+ return <CommentPopover ctx={ctx} labels={labels} />
78
+ case "link":
79
+ return <LinkPopover ctx={ctx} labels={labels} />
80
+ case "list":
81
+ return <ListPopover ctx={ctx} labels={labels} />
82
+ default:
83
+ return <MessagePopover ctx={ctx} labels={labels} />
84
+ }
85
+ }
86
+
87
+ /** Identity of a placement (kind + cell) — the frame key and the initial-focus once-guard; a rejected re-present keeps both (no remount / re-focus) but IS re-placed. / 配置の同一性 (kind + セル) — フレームキー兼初期フォーカスの既済ガード。拒否再提示はどちらも保つ (再マウント / 再フォーカスなし) が再配置はする。 */
88
+ const placementKeyOf = (ctx: PopoverContext | null): string | null => (ctx === null ? null : `${ctx.kind}:${ctx.cell.row}:${ctx.cell.col}`)
89
+
90
+ /**
91
+ * Renders the layer, its two anchors and the lazily mounted frames.
92
+ * 層・2 アンカー・遅延マウントされるフレームを描画する。
93
+ */
94
+ export const PopoverLayer = ({ controller, renderPopover, labels, interactiveAnchorRef, tipAnchorRef }: PopoverLayerProps): ReactNode => {
95
+ const { context, tipContext } = controller
96
+ const frameRef = useRef<HTMLDivElement | null>(null)
97
+ const tipFrameRef = useRef<HTMLDivElement | null>(null)
98
+ // tip effect は ref 経由で最新値を読む (deps は配置の同一性だけ — tip の内容は同セルでは変わらない)
99
+ const tipContextRef = useRef(tipContext)
100
+ tipContextRef.current = tipContext
101
+ const controllerRef = useRef(controller)
102
+ controllerRef.current = controller
103
+ const placementKey = placementKeyOf(context)
104
+ const tipPlacementKey = placementKeyOf(tipContext)
105
+ /** The placement that already received its initial focus — a re-present re-places but must not move the caret. / 初期フォーカス既済の配置 — 再提示は再配置するがキャレットは動かさない。 */
106
+ const focusedPlacementRef = useRef<string | null>(null)
107
+
108
+ /**
109
+ * Places a frame relative to its anchor cell: the width (`popoverWidth` of the layer) is
110
+ * written and any previous clamp is cleared BEFORE the height is measured, so the measured
111
+ * height is the wrapped height at the final width (a narrower layer wraps more lines; the
112
+ * un-keyed tip frame carries the previous cell's `maxHeight`) — one forced layout per placement.
113
+ * フレームをアンカーセル相対に配置する: 幅 (層の popoverWidth) を書き前回のクランプを外して**から**
114
+ * 高さを測る — 計測値は最終幅で折り返した実高になる (狭い層は行数が増え、キー無しの tip フレームは
115
+ * 前セルの maxHeight を持ち越す)。配置 1 回につき forced layout は 1 回。
116
+ */
117
+ const place = (frame: HTMLDivElement, anchorRect: { top: number; left: number; width: number; height: number }): void => {
118
+ const layer = controllerRef.current.getLayerSize()
119
+ // 幅を確定しクランプを外してから測る (折り返し後の実高で候補判定する)
120
+ frame.style.width = `${popoverWidth(layer)}px`
121
+ frame.style.maxHeight = ""
122
+ const placed = placePopover(anchorRect, frame.offsetHeight, layer)
123
+ // アンカーはセル左上に置かれる — 層座標からアンカー相対へ
124
+ frame.style.left = `${placed.left - anchorRect.left}px`
125
+ frame.style.top = `${placed.top - anchorRect.top}px`
126
+ frame.style.maxHeight = `${placed.height}px`
127
+ }
128
+
129
+ useLayoutEffect(() => {
130
+ const frame = frameRef.current
131
+ if (frame === null || context === null) {
132
+ // 閉じたら既済を戻す (同セルの開き直しは新フレームなので初期フォーカスをやり直す)
133
+ focusedPlacementRef.current = null
134
+ return
135
+ }
136
+ // 同セル再提示 (拒否の再試行 / inputMessage → strict 拒否の差替) も毎回再計測する — 前回の maxHeight で新しい文面を切らない
137
+ place(frame, context.anchorRect)
138
+ // 受動的な message はフォーカスを奪わない (入力はシンクで続く)
139
+ if (context.kind === "message") {
140
+ return
141
+ }
142
+ // 初期フォーカスは配置につき 1 回 — 再提示で下書きのキャレットを奪い直さない
143
+ const key = placementKeyOf(context)
144
+ if (focusedPlacementRef.current === key) {
145
+ return
146
+ }
147
+ focusedPlacementRef.current = key
148
+ const target = frame.querySelector<HTMLElement>("[data-aqsp-popover-autofocus]")
149
+ if (target !== null) {
150
+ target.focus({ preventScroll: true })
151
+ } else {
152
+ frame.focus({ preventScroll: true })
153
+ }
154
+ }, [context])
155
+
156
+ useLayoutEffect(() => {
157
+ const frame = tipFrameRef.current
158
+ const ctx = tipContextRef.current
159
+ if (frame === null || ctx === null) {
160
+ return
161
+ }
162
+ place(frame, ctx.anchorRect)
163
+ }, [tipPlacementKey])
164
+
165
+ const render = (ctx: PopoverContext): ReactNode => (renderPopover === undefined ? renderDefault(ctx, labels) : renderPopover(ctx))
166
+ const onKeyDownCapture = (event: KeyboardEvent<HTMLDivElement>): void => {
167
+ // IME 由来の keydown は全 popover ハンドラより先に握り潰す (Escape / Enter が変換確定を壊さない)
168
+ if (controller.isCompositionKey(event.nativeEvent)) {
169
+ event.stopPropagation()
170
+ }
171
+ }
172
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
173
+ // フォールバック: コンポーネントが処理しなかった Escape は閉じる
174
+ if (event.key === "Escape" && !event.defaultPrevented) {
175
+ event.preventDefault()
176
+ controller.close()
177
+ }
178
+ }
179
+ const onCompositionEnd = (event: CompositionEvent<HTMLDivElement>): void => {
180
+ controller.registerCompositionEnd(event.timeStamp)
181
+ }
182
+
183
+ return (
184
+ // biome-ignore lint/a11y/noStaticElementInteractions: 層は kind 横断の keydown 結線点 (IME ガード / Escape フォールバック) — 対話子孫のイベントを受けるだけで自身は操作面でない
185
+ <div className="aqsp-popover-layer" data-aqsp-popover-layer="" onKeyDownCapture={onKeyDownCapture} onKeyDown={onKeyDown} onCompositionEnd={onCompositionEnd}>
186
+ <div ref={interactiveAnchorRef} className="aqsp-popover-anchor" data-aqsp-popover-anchor="interactive" data-aqsp-popover-open={context === null ? undefined : ""} inert={context === null}>
187
+ {context === null ? null : (
188
+ // フレームは autofocus 対象のない消費者レンダラのフォーカス着地点 (tabIndex -1 = プログラム的にだけ受ける)
189
+ <div key={placementKey} ref={frameRef} className="aqsp-popover" data-aqsp-popover-frame={context.kind} tabIndex={-1}>
190
+ {render(context)}
191
+ </div>
192
+ )}
193
+ </div>
194
+ <div
195
+ ref={tipAnchorRef}
196
+ className="aqsp-popover-anchor"
197
+ data-aqsp-popover-anchor="tip"
198
+ data-aqsp-popover-open={tipContext === null ? undefined : ""}
199
+ inert={tipContext === null}
200
+ onPointerEnter={controller.onTipPointerEnter}
201
+ onPointerLeave={controller.onTipPointerLeave}>
202
+ {tipContext === null ? null : (
203
+ <div ref={tipFrameRef} id={controller.tipId} className="aqsp-popover" data-aqsp-popover-frame="note-tip">
204
+ {render(tipContext)}
205
+ </div>
206
+ )}
207
+ </div>
208
+ </div>
209
+ )
210
+ }