feat(ui): add ConflictResolver + ConflictDialog (closes #7)#13
Merged
Conversation
New `formdraft/ui` subpath ships two helpers for field-level multi-tab conflict resolution: - `<ConflictResolver>` — headless render-prop. Shallow-diffs local vs remote, exposes per-field pickLocal/pickRemote/picked plus bulk pickAllLocal/pickAllRemote and apply/keepLocal. Resets picks when the diff key set changes (handles re-fired conflict events without leaking prior picks). Deletion-aware merge: if the chosen side lacks a key, the merged result drops it instead of leaving `undefined` behind. - `<ConflictDialog>` — styled modal wrapping ConflictResolver. Mount unconditionally; renders `null` when there's no conflict. Esc key resolves with local, initial focus moves into the dialog, focus is restored on close (split into an inner component so focus management correctly fires on deferred-show, not just initial mount). aria-modal=true, aria-labelledby, data-testid hooks sanitized for keys with special characters. Main bundle remains 5.42 KB / 8 KB; UI ships as a separate chunk. 18 unit tests covering happy path plus regressions for diff-set change, deletion symmetry, undefined-vs-missing-key, StrictMode double-mount, Esc handler, deferred-show focus, focus restore, key-order signature stability, and dev warn on unresolved apply.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #7.
Summary
Adds a
formdraft/uisubpath with two helpers for field-level multi-tab conflict resolution.<ConflictResolver>— headless render-prop. Shallow-diffs local vs remote (Object.isper key, treating "missing key" as a diff viahasOwnProperty). Exposes per-fieldpickLocal/pickRemote/picked, bulkpickAllLocal/pickAllRemote, andapply/keepLocal. Picks reset when the diff key set changes, so a re-fired conflict event doesn't leak prior selections. Deletion-aware merge: if the chosen side lacks a key, the merged result drops it instead of leavingundefinedbehind.<ConflictDialog>— styled drop-in modal wrapping the headless component. Mount unconditionally — it rendersnullwhen there's no conflict. Esc → resolve-with-local, initial focus moves into the dialog, focus restores on close. Split into outer guard + inner component so the focus useEffect correctly fires on deferred-show (the dominant consumer pattern), not just initial mount.aria-modal=true,aria-labelledby,aria-pressedon choice buttons,data-testidkeys sanitized.Bundle
dist/ui/)../uisubpath export added topackage.json.ui/indexentry added totsup.config.ts.Tests
18 unit tests covering:
pickLocal/pickRemoteper field + mixed merge viaapplypickAllLocal/pickAllRemotebulk shortcutskeepLocalcallsonResolve('local')totalCount=0,applyresolves cleanly)local/remoteremotefor key absent from remote → merged drops the key (and reverse for local)diffKeystreats missing key as diffed even when both read asundefinedapply()called with unresolved picksJSON.stringify([...diffed].sort()))Audit rounds
Iterated through 4 rounds of code review + adversarial audit:
Test plan
npm run typecheck— cleannpm run lint— cleannpm test— 201 passing (was 183 on main; +18 UI)npm run build— clean,dist/ui/{index.mjs,index.js,index.d.ts,index.d.mts}producednpm run size— 5.42 KB / 8 KB