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(
() => ({