Scratch design doc for the reviewer. Not shipped. Branch:
nynexman4464/refactor/ime-guard-shared-base.
- Keep
isImeKeyEventas the single low-level predicate. It is the right primitive. Do not add auseImeSafeKeyDownwrapper hook — see §3 for why it would be over-engineering and, at 2 of 7 sites, outright wrong. - Relocate it from
hooks/useFocusTrap.ts→utils/ime.ts(a discoverable home matching the pure-predicate convention inutils/), with the canonical "why" doc comment living there and nowhere else. - Re-export from
useFocusTrap.ts(deprecated) for back-compat, and migrate the 3 existing importers + 3 inline duplicators to the shared predicate. - Give the lint rule (separate stream) one blessed symbol to point at:
@astryxdesign/core/src/utils/ime#isImeKeyEvent.
| # | Site | Guards | Handler | Detection | Event source | Notes |
|---|---|---|---|---|---|---|
| 1 | hooks/useFocusTrap.ts |
Escape (dismiss trap) | native keydown |
isImeKeyEvent (canonical def lived here) |
native DOM KeyboardEvent |
Also home of the exported helper — odd place for a general util. |
| 2 | Typeahead/BaseTypeahead.tsx |
Enter/Esc/Arrows/Home/End (candidate nav + commit) | React onKeyDown |
isImeKeyEvent(e.nativeEvent) |
e.nativeEvent |
Early return before all key logic. Added via PR #4860. |
| 3 | PowerSearch/PowerSearchEditPopover.tsx |
Enter (save), Escape (cancel) | React onKeyDown |
isImeKeyEvent(e.nativeEvent) |
e.nativeEvent |
Early return before Enter/Escape branch. |
| 4 | Dialog/Dialog.tsx |
Escape (close) | native keydown |
isImeKeyEvent(event) + hasActiveFocusTrapEscape() |
native DOM KeyboardEvent |
Composite guard; also defers to layered popover. |
| 5 | ContextMenu/ContextMenu.tsx |
Escape (dismiss) | native keydown (document listener) |
inline e.isComposing || e.keyCode === 229 |
native DOM KeyboardEvent |
Duplicated predicate. |
| 6 | Tooltip/useTooltip.tsx |
Escape (dismiss, WCAG 1.4.13) | native keydown (document listener) |
inline e.isComposing || e.keyCode === 229 |
native DOM KeyboardEvent |
Duplicated predicate. |
| 7 | Chat/ChatComposerInput.tsx |
Enter (submit) | React onKeyDown |
inline e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 |
e.nativeEvent |
Duplicated predicate; only guards Enter. |
| — | lab/CodeEditor/CodeEditor.tsx |
onInput (not keydown) | React onInput + onCompositionStart/End |
ref tracking (isComposingRef) |
n/a | DIFFERENT pattern & different concern — see §4. Left untouched. |
- Shared predicate (sites 1–4): call
isImeKeyEvent(...). - Inline duplication (sites 5–7): open-code
isComposing || keyCode === 229. - Ref tracking (CodeEditor): track
compositionstart/compositionendto gate the input/change path, not a keydown command.
- React's
KeyboardEventSyntheticEvent DOES exposeisComposing. But the React-handler sites (BaseTypeahead, PowerSearch, Chat) deliberately reade.nativeEventto avoid any React normalization gap and to reachkeyCode. - Native-listener sites (focus-trap, Dialog, ContextMenu, Tooltip) pass the DOM
KeyboardEventdirectly. - The predicate is structurally typed (
{isComposing?; keyCode?}) so both shapes are accepted with no casting.
229 is the sentinel keyCode browsers report while a key event is being
processed by an IME. Some IMEs and older Safari fire the composing keydown
with isComposing not yet true but DO report 229. Dropping the fallback
would regress those. Keep both signals until a browser-matrix audit says
otherwise. (Documented once, in utils/ime.ts.)
utils/ already houses pure, event/data helpers (getKey, mergeProps,
composeEventHandlers, inputAria) each as <name>.ts + <name>.test.ts with
a barrel index.ts. isImeKeyEvent is a pure predicate — it belongs there, not
buried in a focus-trap hook. New file: packages/core/src/utils/ime.ts,
re-exported from utils/index.ts. useFocusTrap.ts keeps a deprecated
re-export so existing relative imports don't break.
A useImeSafeKeyDown(handler) that no-ops during composition sounds tidy but:
- Call sites are not uniform. 4 use native DOM listeners inside
useEffect(not a ReactonKeyDownprop the hook could wrap); 3 use React handlers. - Wrapping is wrong at 2 sites.
useFocusTrapandDialogcombine the IME check with other conditions (isTopEscapeHandler,defaultPrevented,hasActiveFocusTrapEscape()), and must still run that surrounding logic — a blanket "no-op the whole handler during composition" changes behavior. - The predicate is already the minimal correct primitive. An early
if (isImeKeyEvent(e)) return;is explicit, greppable, and lint-targetable. - A wrapper adds a hook, a render-identity concern, and a second thing the lint rule must special-case — net negative. Recommend: predicate + lint rule.
lab/CodeEditor guards onInput (would otherwise write partial CJK syllables
into value) via compositionstart/end ref tracking — a different concern than
"don't treat a composing keydown as a command", so the shared keydown predicate
doesn't replace it. Flag: its handleKeyDown handles Escape (arms
tab-moves-focus) and Tab without an IME guard, so a composing Escape/Tab could
misfire. Latent, in lab, and behavioral — left for a separate fix.
- New
utils/ime.ts(canonical predicate + the one authoritative doc comment) andutils/ime.test.ts(two-signal coverage). utils/index.tsre-exportsisImeKeyEvent.useFocusTrap.ts: local def removed; imports fromutils/ime; deprecated re-export kept for back-compat.- Migrated importers to the util path:
Dialog,BaseTypeahead,PowerSearch. - De-duplicated the 3 inline sites (
ContextMenu,Tooltip,ChatComposerInput) to callisImeKeyEvent, deferring rationale toutils/ime.ts. - No behavioral change — same boolean logic everywhere; pure consolidation.