diff --git a/src/components/content/InlineInput/InlineInput.test.tsx b/src/components/content/InlineInput/InlineInput.test.tsx index eebd83b02..25c0d19f9 100644 --- a/src/components/content/InlineInput/InlineInput.test.tsx +++ b/src/components/content/InlineInput/InlineInput.test.tsx @@ -996,6 +996,136 @@ describe('', () => { expect(getByTestId('II')).not.toHaveAttribute('data-focused'); }); + it('returns focus to the display span without a ring after committing with Enter', async () => { + const user = userEvent.setup(); + const { getByTestId, getByRole } = renderWithRoot( + , + ); + + const root = getByTestId('II'); + + // Keyboard-initiate edit mode → ring on while editing. + await user.tab(); + await act(async () => { + fireEvent.keyDown(root, { key: 'Enter' }); + }); + const input = getByRole('textbox') as HTMLInputElement; + expect(document.activeElement).toBe(input); + + // Commit with Enter → focus returns to the display span, but the + // programmatic restore must NOT draw a focus ring. + await act(async () => { + fireEvent.change(input, { target: { value: 'World' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + + expect(document.activeElement).toBe(getByTestId('II')); + expect(getByTestId('II')).not.toHaveAttribute('data-focused'); + }); + + it('does not pull focus back when onSubmit synchronously moves it elsewhere', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const { getByTestId, getByRole } = renderWithRoot( + <> + { + onSubmit(next); + // Handlers may synchronously move focus elsewhere; the + // programmatic restore must respect that and leave it alone. + (getByTestId('Other') as HTMLButtonElement).focus(); + }} + /> + + , + ); + + const root = getByTestId('II'); + + // Keyboard-initiate edit mode, then commit with Enter. + await user.tab(); + await act(async () => { + fireEvent.keyDown(root, { key: 'Enter' }); + }); + const input = getByRole('textbox') as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { value: 'World' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + + expect(onSubmit).toHaveBeenCalledWith('World'); + // The handler's focus move wins — focus stays on the button and is NOT + // yanked back to the inline input's display span. + expect(document.activeElement).toBe(getByTestId('Other')); + expect(getByTestId('II')).not.toHaveAttribute('data-focused'); + }); + + it('does not pull focus back when onCancel synchronously moves it elsewhere', async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const { getByTestId, getByRole } = renderWithRoot( + <> + { + onCancel(); + (getByTestId('Other') as HTMLButtonElement).focus(); + }} + /> + + , + ); + + const root = getByTestId('II'); + + // Keyboard-initiate edit mode, then cancel with Escape. + await user.tab(); + await act(async () => { + fireEvent.keyDown(root, { key: 'Enter' }); + }); + const input = getByRole('textbox') as HTMLInputElement; + await act(async () => { + fireEvent.keyDown(input, { key: 'Escape' }); + }); + + expect(onCancel).toHaveBeenCalled(); + expect(document.activeElement).toBe(getByTestId('Other')); + expect(getByTestId('II')).not.toHaveAttribute('data-focused'); + }); + + it('clears the focus ring when focus moves to another component while editing', async () => { + const user = userEvent.setup(); + const { getByTestId, getByRole } = renderWithRoot( + <> + + + , + ); + + const root = getByTestId('II'); + + // Tab to the inline input and enter edit mode via keyboard. + await user.tab(); + await act(async () => { + fireEvent.keyDown(root, { key: 'Enter' }); + }); + const input = getByRole('textbox') as HTMLInputElement; + expect(document.activeElement).toBe(input); + + // Move focus to the sibling button (submit-on-blur commits, focus has + // already moved away). The inline input must not retain the ring. + const other = getByTestId('Other') as HTMLButtonElement; + await act(async () => { + other.focus(); + }); + + expect(getByTestId('II')).not.toHaveAttribute('data-focused'); + expect(document.activeElement).toBe(other); + }); + it('marks the display with aria-disabled / aria-readonly when applicable', () => { const { getByTestId, rerender } = renderWithRoot( , diff --git a/src/components/content/InlineInput/InlineInput.tsx b/src/components/content/InlineInput/InlineInput.tsx index 92169a76b..69cfe27cd 100644 --- a/src/components/content/InlineInput/InlineInput.tsx +++ b/src/components/content/InlineInput/InlineInput.tsx @@ -365,6 +365,19 @@ export const InlineInput = forwardRef( const [draft, setDraft] = useState(value); const [inputWidth, setInputWidth] = useState(null); + // Reliable focus presence for the whole component, driven by + // `useFocusWithin` (which, unlike `useFocusRing`/`useFocus` on a bare + // span, has an element-removal fallback). Used to gate the display focus + // ring so it can never get stuck on after editing ends or after focus + // moves to another component. + const [isFocusWithin, setIsFocusWithin] = useState(false); + + // Suppresses the display focus ring for a single programmatic focus + // restore (when we return focus to the display span after a keyboard / + // programmatic edit-end). Reset whenever focus truly leaves the component + // so a later real keyboard Tab back shows the ring again. + const [ringSuppressed, setRingSuppressed] = useState(false); + // Token to invalidate in-flight onSubmit promises if a newer commit / // re-entry happens before they settle. const submitTokenRef = useRef(0); @@ -382,6 +395,12 @@ export const InlineInput = forwardRef( // commits normally. const programmaticEditStartRef = useRef(null); + // Set when an edit-end should return focus to the display span (keyboard + // Enter/Escape, imperative `stopEditing`). NOT set for submit-on-blur / + // tab-away, where focus has already moved elsewhere. Consumed by a layout + // effect after the display span re-mounts. + const pendingRestoreFocusRef = useRef(false); + // Synchronous mirror of `isEditing`. We need this because cancel/commit // call user callbacks (`onCancel`/`onSubmit`) that may synchronously // re-focus another element — that causes a synchronous blur on the @@ -415,13 +434,27 @@ export const InlineInput = forwardRef( setIsEditing(true); }); - const commit = useEvent((rawValue: string) => { + // Whether an edit-end should return focus to the display span. Only when + // this component owns its own keyboard/focus story (standalone usage). + // Hosts that drive editing through `ref.startEditing()` with + // `keyboardActivation={false}` (e.g. `Tabs`) keep ownership of focus. + const canRestoreFocus = () => + keyboardActivation && + editTrigger !== 'none' && + !isDisabled && + !isReadOnly; + + const commit = useEvent((rawValue: string, restoreFocus = true) => { // Re-entry guard. `onSubmit`/`onCancel` may synchronously refocus and // trigger another blur-driven commit before the state update lands. if (!isEditingRef.current) return; const next = trimOnSubmit ? rawValue.trim() : rawValue; + if (restoreFocus && canRestoreFocus()) { + pendingRestoreFocusRef.current = true; + } + if (!next && !allowEmpty) { isEditingRef.current = false; programmaticEditStartRef.current = null; @@ -462,8 +495,11 @@ export const InlineInput = forwardRef( } }); - const cancel = useEvent(() => { + const cancel = useEvent((restoreFocus = true) => { if (!isEditingRef.current) return; + if (restoreFocus && canRestoreFocus()) { + pendingRestoreFocusRef.current = true; + } isEditingRef.current = false; programmaticEditStartRef.current = null; setIsEditing(false); @@ -481,6 +517,33 @@ export const InlineInput = forwardRef( inputRef.current?.select(); }, [isEditing]); + // Return focus to the display span after a keyboard / programmatic + // edit-end so keyboard navigation continues from this control. The focus + // is programmatic (react-aria treats it as non-keyboard) and we set + // `ringSuppressed` so it does not draw a focus ring; a later real Tab to + // the element resets the suppression (see `onFocusWithinChange`). + // + // `onSubmit`/`onCancel` may synchronously move focus elsewhere. When the + // input unmounts, the browser drops focus to ``, so we only restore + // focus to the display span when focus actually landed there (or nowhere). + // If a handler deliberately moved focus to another element, we leave it. + useLayoutEffect(() => { + if (isEditing || !pendingRestoreFocusRef.current) return; + pendingRestoreFocusRef.current = false; + + const root = rootRef.current; + if (!root) return; + + const active = root.ownerDocument.activeElement; + const focusLost = !active || active === root.ownerDocument.body; + // A handler moved focus to a real element outside this control — respect + // it and don't pull focus back. + if (!focusLost && !root.contains(active)) return; + + setRingSuppressed(true); + root.focus(); + }, [isEditing]); + // Measure the draft so the input width follows the typed glyphs. useLayoutEffect(() => { if (!isEditing) return; @@ -526,17 +589,35 @@ export const InlineInput = forwardRef( setDraft(e.target.value); }); - // Keyboard focus ring on the root. Only fires when the span itself is - // keyboard-focused (i.e. `wantsKeyboard` made it tabbable) — clicking - // does not trigger focus-visible, and once edit mode starts focus moves - // to the inner input, so the ring vanishes naturally. + // Keyboard focus-visible modality (mouse vs keyboard). Used together with + // `isFocusWithin` to drive the display focus ring. We rely on + // `useFocusWithin` (below) for the *presence* of focus because, unlike + // `useFocusRing`/`useFocus` on a bare span, it has an element-removal + // fallback — so the ring can never get stuck on after the inner input is + // unmounted on edit-end or after focus moves away. const { isFocusVisible, focusProps: focusRingProps } = useFocusRing(); + // Always enabled so it reliably reports focus presence in BOTH display + // and edit modes. The submit-on-blur logic is internally guarded by + // `isEditingRef`, so running `onBlurWithin` in display mode is a no-op. const { focusWithinProps } = useFocusWithin({ - isDisabled: !isEditing, + onFocusWithinChange: (within) => { + setIsFocusWithin(within); + // Once focus truly leaves the component, clear the one-shot ring + // suppression so a later real keyboard Tab back shows the ring again. + if (!within) setRingSuppressed(false); + }, onBlurWithin: () => { + if (!isEditingRef.current) return; if (!submitOnBlur) return; + // Spurious-blur guard. `useFocusWithin` fires a synthetic blur via its + // element-removal fallback when the display span unmounts as we enter + // edit mode (focus actually moved INTO the inner input). Ignore it if + // focus is in fact still within the component. + const active = rootRef.current?.ownerDocument.activeElement; + if (active && rootRef.current?.contains(active)) return; + // Grace-period guard against focus theft right after a programmatic // `startEditing()` — e.g. a `` inside a // closing Menu popover yanking focus back to its trigger after the @@ -561,7 +642,9 @@ export const InlineInput = forwardRef( return; } - commit(draft); + // Focus has already moved elsewhere (tab-away / click-away), so do + // NOT pull it back to the display span. + commit(draft, false); }, }); @@ -623,16 +706,23 @@ export const InlineInput = forwardRef( // `autoFocus`), so we show the focus indicator on the root regardless // of how the user activated edit mode (click vs keyboard). // - // In display mode, we keep the original behaviour: only show the - // indicator on keyboard focus (focus-visible), and only when the display - // element is actually focusable (i.e. `isEditable`). + // In display mode we show the indicator only on keyboard focus + // (`isFocusVisible`) AND when focus is actually within the component + // (`isFocusWithin`) AND the display element is focusable (`isEditable`). + // The `isFocusWithin` gate is the key fix: it reliably drops to `false` + // on edit-end / tab-away (it has an element-removal fallback that + // `useFocusRing` lacks on a bare span), so the ring can never get stuck + // on. `ringSuppressed` hides the ring for a single programmatic focus + // restore after a keyboard / programmatic edit-end. // // When `keyboardActivation` is `false` the host (e.g. `Tabs`) owns the // entire focus story for this control, so we suppress the focus ring // entirely — including while editing — to avoid drawing a redundant // indicator on top of the host's own focus ring. const showFocusRing = - keyboardActivation && (isEditing || (isFocusVisible && isEditable)); + keyboardActivation && + (isEditing || + (isFocusVisible && isFocusWithin && isEditable && !ringSuppressed)); const mods = useMemo( () => ({