Skip to content

[✨] Improving focus-related accessibility after modal exit #1002

@sangpok

Description

@sangpok

Is your feature request related to a problem?

The W3C's APG has accessibility guidance for the Modal pattern, which explains what should happen when the modal ends.

When a dialog closes, focus returns to the element that invoked the dialog unless either:
The invoking element no longer exists. Then, focus is set on another element that provides logical work flow.
The work flow design includes the following conditions that can occasionally make focusing a different element a more logical choice:
It is very unlikely users need to immediately re-invoke the dialog.
The task completed in the dialog is directly related to a subsequent step in the work flow.
For example, a grid has an associated toolbar with a button for adding rows. The Add Rows button opens a dialog that prompts for the number of rows. After the dialog closes, focus is placed in the first cell of the first new row.

https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal

QwikUI's modals don't have a default logic for moving focus when a modal ends. So it would be nice to add functionality for this accessibility.

Describe the solution you'd like

If possible, I was wondering if it might be possible to set up something like returnFocusOnClose in the Root property of a QwikUI Modal. This would move the focus to the trigger button after the modal closes.

Where to attach the property

I think the Root component property is a good fit because it's the parent component that contains the Panel and the trigger button behaviour.

The name

The name returnFocusOnClose, which I mentioned earlier, is based on the idea of returning to the trigger button. I think it would be helpful if the documentation explained that, but a different name would also work.

Describe alternatives you've considered

I thought of it like this:

  1. Change the modal context
    Add defaultFocusOnClose.
/** modal-context.tsx */
export type ModalContext = {
  // core state
  localId: string;
  showSig: Signal<boolean>;
  onShow$?: QRL<() => void>;
  onClose$?: QRL<() => void>;
+  defaultFocusOnClose?: boolean;
  closeOnBackdropClick?: boolean;
  alert?: boolean;
};
  1. Change the modal type of the root.
    Add returnFocusOnClose to the modal root prop type.
/** modal-root.tsx */
type ModalRootProps = {
  onShow$?: QRL<() => void>;
  onClose$?: QRL<() => void>;
  'bind:show'?: Signal<boolean>;
  closeOnBackdropClick?: boolean;
+  returnFocusOnClose?: boolean;
  alert?: boolean;
} & PropsOf<'div'>;
  1. Change the root's context provider value
/** modal-root.tsx */
  const {
    'bind:show': givenShowSig,
    closeOnBackdropClick,
+    returnFocusOnClose,
    onShow$,
    onClose$,
    alert,
  } = props;

  const defaultShowSig = useSignal<boolean>(false);
  const showSig = givenShowSig ?? defaultShowSig;
+  const defaultFocusOnClose = returnFocusOnClose ?? true;

  const context: ModalContext = {
    localId,
    showSig,
    closeOnBackdropClick,
+    defaultFocusOnClose,
    onShow$,
    onClose$,
    alert,
  };

  useContextProvider(modalContextId, context);
  1. Change the logic for the modal trigger.
/** modal-trigger.tsx */
export const HModalTrigger = component$((props: PropsOf<'button'>) => {
  const context = useContext(modalContextId);

+  const triggerRef = useSignal<HTMLDialogElement>();
+
+  useTask$(async function focusOnTrigger({ track }) {
+    const isOpen = track(() => context.showSig.value);
+
+    if (!triggerRef.value) return;
+
+    if (!isOpen && context.defaultFocusOnClose) {
+      /**
+       * this code is from 'select-trigger'
+       * https://github.com/qwikifiers/qwik-ui/blob/50c61ba65fb84999a93aac486236fcea36a32352/packages/kit-headless/src/components/select/select-trigger.tsx#L116
+       **/
+      while (triggerRef.value !== document.activeElement) {
+        await new Promise((resolve) => setTimeout(resolve, 5));
+        triggerRef.value?.focus();
+      }
+    }
+  });

  const handleClick$ = $(() => {
    context.showSig.value = !context.showSig.value;
  });

  return (
    <button
+      ref={triggerRef}
      aria-haspopup="dialog"
      aria-expanded={context.showSig.value}
      data-open={context.showSig.value ? '' : undefined}
      data-closed={!context.showSig.value ? '' : undefined}
      onClick$={[handleClick$, props.onClick$]}
      {...props}
    >
      <Slot />
    </button>
  );
});

Usage

Then, you can use it like this

<Modal.Root returnFocusOnClose={false}>

Additional context

I'm new to Qwik library. I followed the guide but may have missed something. I appreciate your understanding.
and if possible, I'd like to write a PR with the above. Is that possible?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions