react-material: date/time pickers wire `autoFocus`, `onFocus` and `onBlur` to an aria-hidden input

Open
#2,615 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
react, typescript
Domain
frontend, testing

Research direction

Start with packages/material-renderers/MaterialDateControl.tsx, MaterialDateTimeControl.tsx, and MaterialTimeControl.tsx, then inspect createOnBlurHandler in util/datejs.tsx and the existing date, time, and date-time tests. Exercise the picker through its section spans and component props rather than the hidden input. Done means focus, blur, autofocus, and description behavior work on the accessible field, with no dead control wiring and regression coverage for the three controls.

Written by the indexing model from the issue text.

Description

material react
Describe the bug

The React Material date, time and date-time controls pass autoFocus, onFocus and onBlur through slotProps.textField.inputProps, which targets the picker's hidden mirror <input> rather than the field the user interacts with. As a result options.focus and showUnfocusedDescription do not work on these controls, and createOnBlurHandler never runs.

Since @mui/x-date-pickers v8, enableAccessibleFieldDOMStructure defaults to true, so DatePicker renders the accessible field DOM: one contenteditable <span role="spinbutton"> per date section, plus a single <input aria-hidden="true" tabindex="-1"> that only mirrors the value.

<div class="MuiPickersInputBase-root" role="group">
  <div class="MuiPickersSectionList-root" tabindex="0">
    <span role="spinbutton" contenteditable="true" aria-label="Year">1980</span>
    <span role="spinbutton" contenteditable="true" aria-label="Month">06</span>
    <span role="spinbutton" contenteditable="true" aria-label="Day">04</span>
  </div>
  <input
    class="MuiPickersInputBase-input"
    aria-hidden="true"
    tabindex="-1"
    value="1980-06-04"
  />
</div>

Rendering MaterialDateControl and driving it with real DOM focus() / blur() on the section spans shows:

  • With options: { focus: true }, document.activeElement is the aria-hidden / tabindex="-1" input, not a date section.
  • Focusing and blurring the section spans (what a user reaches with Tab) fires neither onFocus nor onBlur. The hidden input is a sibling of the sections container, not an ancestor, so focus events never bubble through it.

Concretely this breaks three things:

  1. options.focus does not focus date, time or date-time controls.
  2. showUnfocusedDescription never takes effect, because onFocus never fires and the control stays unfocused as far as isDescriptionHidden is concerned.
  3. createOnBlurHandler is dead code, and so is the key / updateChild remount it drives. The behavior it implemented, clearing the data and resetting the visible field when the typed text does not parse, no longer happens.

Data entry itself still works: field edits reach setValue with the default changeImportance: 'accept', so the picker's onAccept still fires and createOnChangeHandler still commits. Only the focus, description and invalid-input-reset behaviors are affected.

The existing tests do not catch any of this because they locate the hidden input with find('input') and then assert React props on it or simulate events directly on it, so they pass whether or not the wiring reaches the real field.

Expected behavior
  • options: { focus: true } puts the caret on the first editable date section, the way it focuses the input of a plain text control.
  • onFocus and onBlur fire when the user enters and leaves the field, so showUnfocusedDescription behaves the same as on other controls.
  • No dead focus/blur wiring is left pointing at an element the user cannot reach.
Steps to reproduce the issue
  1. Start the React Material example app (packages/material-renderers, pnpm run dev).
  2. Pick an example containing a date control, or use this UI schema against a { "type": "string", "format": "date" } property:
    {
      "type": "Control",
      "scope": "#/properties/birthDate",
      "options": { "focus": true, "showUnfocusedDescription": false }
    }
    
  3. Reload the form. The date field does not receive the caret: no date section is highlighted and typing goes nowhere until you click the field.
  4. Click into the date field. The control's description does not appear on focus, and it does not disappear again when you Tab away.
  5. Inspect the field in the browser devtools. The focusable elements are the MuiPickersSectionList spans, while the <input> carrying the JSON Forms autoFocus / onFocus / onBlur is aria-hidden="true" with tabindex="-1".
Screenshots

No response

Which Version of JSON Forms are you using?

v3.9.0-alpha.0 / v3.9.0-alpha.1

Package

React Material Renderers

Additional context

Use the picker's own autoFocus prop, which resolves to pickerContext.autoFocus && !pickerContext.open and focuses the first editable section, and put onFocus / onBlur on the textField slot itself, where PickersTextField forwards them to the field root. Both were confirmed to reach the section spans. The htmlInput props then only carry type: 'text'.

createOnBlurHandler reads e.target.value, which does not exist on the sections container, so it cannot be rehomed and should be dropped from the three controls. It is exported from the package, so keep and deprecate the util itself rather than deleting it.

Changes for MaterialDateControl.tsx (apply the same to MaterialDateTimeControl.tsx and MaterialTimeControl.tsx)
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useMemo, useState } from 'react';
 ...
 import {
-  createOnBlurHandler,
   createOnChangeHandler,
   getData,
   useFocus,
   useInputVariant,
 } from '../util';
@@
-  const [key, setKey] = useState<number>(0);
   const [open, setOpen] = useState<boolean>(false);
@@
-  const updateChild = useCallback(() => setKey((key) => key + 1), []);
-
   const onChange = useMemo(
     () => createOnChangeHandler(path, handleChange, saveFormat),
     [path, handleChange, saveFormat]
   );
-
-  const onBlurHandler = useMemo(
-    () =>
-      createOnBlurHandler(
-        path,
-        handleChange,
-        format,
-        saveFormat,
-        updateChild,
-        onBlur
-      ),
-    [path, handleChange, format, saveFormat, updateChild, onBlur]
-  );
@@
       <DatePicker
         open={open}
         onOpen={() => setOpen(true)}
         onClose={() => setOpen(false)}
-        key={key}
         label={label}
         value={value}
         onAccept={onChange}
         format={format}
         views={views}
         disabled={!enabled}
         closeOnSelect={closeOnSelect}
+        autoFocus={appliedUiSchemaOptions.focus}
         slotProps={{
           actionBar: ...,
           textField: {
             id: id + '-input',
             required: required && !appliedUiSchemaOptions.hideRequiredAsterisk,
             error: !isValid,
             fullWidth: !appliedUiSchemaOptions.trim,
             variant: inputVariant,
-            inputProps: {
-              autoFocus: appliedUiSchemaOptions.focus,
-              type: 'text',
-              onFocus: onFocus,
-              onBlur: onBlurHandler,
-            },
+            onFocus: onFocus,
+            onBlur: onBlur,
+            inputProps: { type: 'text' },
             InputLabelProps: data ? { shrink: true } : undefined,
           },
         }}
       />

After #2614 the last two prop lines become slotProps: { htmlInput: { type: 'text' }, inputLabel: data ? { shrink: true } : undefined }. onFocus / onBlur stay at the textField slot level and autoFocus stays on the picker either way.

Deprecate createOnBlurHandler in util/datejs.tsx
+/**
+ * @deprecated Since @mui/x-date-pickers v8 the accessible field DOM structure is the
+ * default, so blur events on the field no longer carry input values and this handler
+ * can no longer be attached to anything the user can reach. Rely on the picker's
+ * onChange/onAccept callbacks instead.
+ */
 export const createOnBlurHandler =
   (
     path: string,

If the invalid-input reset from point 3 is still wanted, it should hook the picker's validation error rather than a blur event, since the field no longer surfaces unparseable text to the outside.

The date, time and date-time tests should be reworked to drive the picker through its component props or the section spans instead of the hidden input, so a regression here fails the build.

Dominant language
TypeScript
Stars
2.7k
Forks
423
Avg merge
17d 8h
Merged PRs (30d)
1

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from eclipsesource/jsonforms

All issues in eclipsesource/jsonforms

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.