Skip to content

feat(date-field): integrate date controls with Field wiring - #392

Open
IzumiSy wants to merge 18 commits into
mainfrom
refactor/date-field-field-composition
Open

feat(date-field): integrate date controls with Field wiring#392
IzumiSy wants to merge 18 commits into
mainfrom
refactor/date-field-field-composition

Conversation

@IzumiSy

@IzumiSy IzumiSy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Motivation

DateField and DatePicker had drifted into an awkward spot within AppShell's form API.

On one side, they are AppShell-owned composite widgets: segmented spinbuttons, custom keyboard behavior, an optional calendar popover, and a proxy input for form submission/validity. On the other side, consumers reasonably expect them to behave like the rest of AppShell's form controls inside Field.Root — especially because Input, Select, Combobox, and related controls already do.

Keeping the date controls standalone-only meant every consumer had to manually wire:

  • labels
  • descriptions
  • external errors
  • invalid state
  • aria-labelledby / aria-describedby

That made the date controls feel inconsistent with the rest of the library and forced the example page to carry a custom DemoField wrapper just to recover the usual field UX.

At the same time, we do not want to re-implement all of Base UI's field/form machinery from scratch, and we do not want the coupling to sprawl across the date control implementation.

Design Decision

Use Base UI's exported internals as a narrow bridge

Base UI does not currently provide a first-class public adapter for registering an arbitrary composite widget as a Field control, but it does export the internal building blocks needed to do so via package subpaths such as:

  • @base-ui/react/internals/field-register-control
  • @base-ui/react/internals/field-root-context
  • @base-ui/react/internals/labelable-provider
  • @base-ui/react/internals/form-context

This PR intentionally depends on those internals for now because they match the behavior we want and let us support normal Field composition without re-implementing form field management ourselves yet.

The bridge is intentionally centralized in a single hook, useDateFieldFieldBridge, so the dependency surface stays small and maintainable if we replace this approach later.

Keep the date widgets as composite widgets

The date controls do not become thin wrappers around Base UI field controls.

They still own:

  • segmented date entry
  • shortcut handling
  • calendar popover behavior
  • locale/timezone formatting
  • min/max and unavailable-date logic
  • DateValue emission

The bridge only adapts them to field-shell concerns:

  • control registration
  • label association
  • description/error association
  • disabled/invalid propagation
  • dirty/touched/filled/focused updates
  • validation / form error clearing hooks

Intentionally reset the prop surface

This PR intentionally changes the DateField / DatePicker prop contract relative to main.

The previous component-owned label, description, errorMessage, and hideTimeZone props are removed in favor of two explicit usage modes:

  • standalone usage with id, aria-label, aria-labelledby, and aria-describedby
  • composed usage inside Field.Root with Field.Label, Field.Description, and Field.Error

This is a deliberate breaking change, but acceptable for now because these date controls do not yet have known external consumers and are only used internally by us at this stage.

The goal of this reset is to align the date controls with the rest of AppShell's form model instead of carrying a parallel label/error API indefinitely.

Preserve standalone usage

The bridge is additive at the behavior level.

Outside Field.Root / Form, Base UI's exported internals resolve to inert default contexts, so DateField and DatePicker continue to work as standalone controls with explicit ARIA wiring.

That means consumers still keep a valid standalone path, even though this PR does not preserve full prop-level backward compatibility with main.

Summary

  • integrate DateField / DatePicker with Field.Root using Base UI's exported internals
  • centralize that coupling in useDateFieldFieldBridge
  • intentionally remove the old component-owned label/error prop surface in favor of ARIA wiring or Field.* composition
  • preserve standalone date-control behavior outside Field / Form
  • update the Vite demo page to use standard Field.* composition
  • update tests and snapshots for the field integration

@IzumiSy
IzumiSy force-pushed the refactor/date-field-field-composition branch from 045166b to ccdac38 Compare July 16, 2026 08:02

@interacsean interacsean left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to start to align the interface with the rest of the form-based components we have already and with RHF.  We should also bring updates to the Calendar component to keep them all aligned.

Ontop of this, I am concerned about whether we should be maintaining backwards compatibility at all and give future deprecation warnings, or try to survey if any of these components are in use already if we want to make a breaking change

  1. The field is not working within a Form. In the example page, there is a form to test this functionality and it has broken... AI analysis says:

"The refactor registers the date control as a Base UI form field via the internal useRegisterFieldControl. Base UI's Form.onSubmit only calls your onSubmit/onFormSubmit when zero registered fields are invalid; otherwise it preventDefault()s and focuses the first "invalid" field (Form.js, the if (invalidFields.length) branch). A field's validity is !invalid && validityData.state.valid, and validityData.state.valid starts at null and is only committed via queueMicrotask, gated behind shouldValidateOnChange() (date-field.tsx:156). Under the default validationMode="onSubmit" it never resolves to true, so the date field is permanently counted invalid"

A typed out-of-range / unavailable date shows a red border but no message, the control does its own validation internally but never reports that validity back up to Field.Root.

  1. Existing form components have { readOnly?: boolean, disabled?: boolean }

mode="editable" | "readonly" | "disabled" does not align, and should become two independent booleans

disabled and readOnly are orthogonal, not one axis. In HTML they mean different things — a disabled field isn't focusable, isn't submitted, and isn't validated; a read-only field is focusable, is submitted, and is validated

It breaks native/RHF interop. register() and a spread {...field} set disabled as a boolean; Base UI's own Field.Root cascades disabled as a boolean. With mode, every integration point needs a translation layer — and it's already caused a bug in this PR: Field.Root disabled flows to the group but not to calState/the state hook (they only read mode === "disabled")

  1. Re constraints. Again { required?: boolean } is the existing convention, not nested. And we could shift min|maxValue to just min|max top-level props, which is how a native number-based input expects this type of range restriction. However we would be passing objects not strings so perhaps retaining Value​ is valid

  2. There are direct imports from @base-ui/react/internals/*​ which is brittle as they are not semver protected, and sounds like it is linked to the form no longer submitting

5. We lost a few tests related to typing/auto-advance, day-clamping, leap years etc. Is there rationale / scope decision to this? 

@IzumiSy

IzumiSy commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@interacsean

Thanks for your feedback.

I should have separated the intentional API break from the regressions in the current branch more clearly.

The intentional break in this PR is the composition model: I want DateField / DatePicker to move to the same control-first Field.Root composition pattern as our other form controls, and I think the date input surface is still young enough that it's better to take that break now rather than support both the old prop-based field chrome and the composed model.

That said, I agree that some of what landed here should not be part of that break. In particular, I'm going to keep mode / constraints out of scope for this PR. I agree disabled and readOnly should remain separate semantics, and I want the semantic date props to stay aligned across the date family.

For that reason, I'm treating Calendar as the baseline for the date semantics, not as a field control that needs the same composition model. DatePicker uses Calendar internally, but Calendar is still a standalone public component, so my goal is:

  • DateField / DatePicker: move to Field.Root composition
  • Calendar: remain standalone
  • shared date semantics across all three: isDisabled, isReadOnly, minValue, maxValue, isDateUnavailable, etc.

Separately, I agree the current branch still has regressions that I need to fix regardless of the API direction: the Base UI Form / Field validation wiring, and the behavioral coverage we dropped around typed entry / clamping / leap-year cases.

If that direction sounds reasonable, I'll narrow the PR to that shape.

@IzumiSy IzumiSy changed the title refactor(date-field): align DateField and DatePicker with Field composition feat(date-field): integrate date controls with Field wiring Jul 31, 2026
@IzumiSy

IzumiSy commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

API Design Review completed successfully!

API Design Review complete for PR #392 (feat(date-field): integrate date controls with Field wiring).

Summary:

Impact analysis confirms all 3 changed packages/ files. date-field.tsx is a public export; date-input-group.tsx and date-field.test.tsx are internal. The single internal consumer (toolbar.tsx) has already been migrated to the new API.

Findings (Low only — no High/Medium issues):

[1/3 — Low] isDateUnavailable type narrowed to (date: T) with internal cast
The interface changed from (date: DateValue) => boolean to (date: T) => boolean, which is more ergonomic for concrete generics (e.g. DateField<CalendarDate>), but the internal cast as ((date: DateValue) => boolean) hides the variance. Generic callbacks shared across multiple date types are now slightly less flexible. No runtime risk since the engine always emits T values.

[2/3 — Low] @example JSDoc blocks removed from DateField and DatePicker
Both components previously showed concise copy-paste usage examples. The new docs only describe behavior ("Provide an accessible name...") without a code snippet. Reduces API discoverability for new consumers.

[3/3 — Low] hideTimeZone removal undocumented in component JSDoc
hideTimeZone was removed from DatePickerProps. It's noted in the changeset but absent from any component-level JSDoc. A brief note in the DatePicker JSDoc (or DatePickerProps typedef) would help consumers who grep the source.

Verdict: Approve
No unresolved High or Medium issues. The Field integration is well-designed and well-isolated in useDateFieldFieldBridge. The three Low findings are non-blocking documentation/ergonomics suggestions.

@IzumiSy
IzumiSy marked this pull request as ready for review July 31, 2026 06:39
@IzumiSy
IzumiSy requested a review from a team as a code owner July 31, 2026 06:39
@IzumiSy
IzumiSy requested a review from interacsean July 31, 2026 07:00
@IzumiSy

IzumiSy commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

E2E testing is failing due to the platform outage now. Please ignore it. resolved

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants