Skip to content

refactor(ui-date-time-input): convert v2 to functional, drop Moment.js#2531

Open
balzss wants to merge 2 commits intoINSTUI-4791-time-date-input-rework-multi-versionfrom
INSTUI-4791-datetimeinput-functional-rewrite
Open

refactor(ui-date-time-input): convert v2 to functional, drop Moment.js#2531
balzss wants to merge 2 commits intoINSTUI-4791-time-date-input-rework-multi-versionfrom
INSTUI-4791-datetimeinput-functional-rewrite

Conversation

@balzss
Copy link
Copy Markdown
Contributor

@balzss balzss commented Apr 28, 2026

Builds on top of #2518. Rewrites DateTimeInput v2 internals so the component is functional, Moment-free, and easier to maintain — public API stays the same except for the documented messageFormat change. Also includes a follow-up commit hardening DateInput v2's parser, since DateTimeInput now leans on it as the sole authority for typed-input acceptance.

Commits

  1. refactor(ui-date-time-input): convert v2 to functional, drop Moment.js — the rewrite below.
  2. refactor(ui-date-input): harden v2 parser, simplify input messages — see DateInput v2 changes section.

Summary (DateTimeInput v2)

  • Class → functional component. useState / useEffect / useRef; mirrors DateInput v2's hook style.
  • Moment-free internals. All DateTime.parse call sites removed. formatMessage uses Intl.DateTimeFormat; parseIsoInTz handles consumer ISO input (with offset → new Date(); without offset → wall-clock components in props.timezone via the existing offset-rebuild trick); typed-text fallback gone — DateInput v2's locale parser is now the sole authority.
  • v2/utils.ts holds the pure timezone-aware date math (partsInTz, wallClockInTzToUtc, sameDayInTz, combineDateAndTime, setWallTime, parseIsoInTz, defaultMessageFormat). No React, no Moment. Marked as a candidate to promote to @instructure/ui-i18n once a second consumer needs it.
  • Drops legacy cruft. setTimeout(0) shims around onChange / onBlur (predate React 18 batching), the dead elementRef / handleRef, the redundant local formatDateInput re-pass on top of DateInput's own format, and the stale-state-prone setState updater in componentDidUpdate.
  • onChange is now StrictMode-safe. Side effects moved out of state updaters via a snapshotRef synced each render. Updaters are pure; onChange fires once, after commit.
  • Tests trimmed: 4 typed-text fallback tests removed (they tested Moment-only formats that v2 no longer accepts); the messageFormat test rewritten for the new function-shaped prop. 33/33 pass.

DateInput v2 changes

Now that DateTimeInput v2 forwards typed-input acceptance entirely to DateInput v2, the parser there has to be airtight. Changes in the second commit:

  • Stricter parsing. Each segment must be digits; there must be exactly 3 segments. Combined with calendar validation (Feb 30, year out of range, etc. via a Date round-trip), the parser no longer accepts input like "May/4/2017" or "2/30/2024".
  • Bidi-mark handling. Strips U+200E / U+200F / U+061C before splitting, so RTL-locale-formatted input parses cleanly.
  • Format-options centralization. A single FORMAT_OPTIONS constant (gregory + latn, 2-digit month/day) feeds parsing, formatting, and the placeholder hint — keeping the input contract predictable across locales.
  • Simpler input messages. Replaces the mirrored inputMessages state with a hasInternalError flag; renders the messages prop directly and appends invalidDateErrorMessage only while the internal error is active. Removes a stale-state surface where external messages updates could get masked.
  • Better placeholder hint. Generated from Intl.DateTimeFormat.formatToParts directly instead of regex-replacing digits in a formatted example. More robust to locales whose digits happen to collide with the example date.
  • Memoizes placeholderHint and selectedDate. Drops a stale TODO above displayName.

No public API changes here — purely internal refactor + stricter validation.

Breaking changes

Prop Before After
messageFormat (DateTimeInput) Moment format string (e.g. 'LLLL') (date: Date, locale: string, timezone: string) => string

Default output of messageFormat is unchanged ("Monday, May 1, 2017 1:30 PM" in en-US, "lundi 1 mai 2017 13:30" in fr-FR).

Typed input acceptance is now strictly governed by DateInput v2's locale parser. v1's lenient Moment-based fallback is gone — e.g. in en-US, "9/4/1986" and "09/04/1986" are accepted, but "Sep 4, 1986", "May 1 2017", "2017-05-01", and "2/30/2024" are no longer parsed.

docs/guides/upgrade-guide.md has both entries documented.

Test plan

  • pnpm run test:vitest ui-date-time-input (33/33 should pass)
  • pnpm run test:vitest ui-date-input
  • pnpm run cy:component --spec cypress/component/DateTimeInput.cy.tsx
  • pnpm run dev — exercise each example on /v11_7/DateTimeInput:
    • columns layout with default value renders 1/18/2018 + 1:30 PM + Thursday, January 18, 2018 1:30 PM
    • French/Africa-Nairobi example renders 18/01/2018 + 16:00 + jeudi 18 janvier 2018 16:00
    • disabled example: inputs show but are non-editable
    • open calendar, pick a different day → time preserved
    • change time in TimeSelect → date preserved
    • type 9/4/1986, blur → accepted; type Sep 4, 1986, blur → Invalid date!
    • type 2017-05-01, blur → Invalid date! (was accepted in v1)
    • type 2/30/2024, blur → Invalid date!
    • disabled-dates example: type 4/3/2022Disabled date shown
    • year-picker example renders the year combobox
    • reset example: click "Clear" wipes both inputs
  • sanity-check /v11_7/DateInput examples — placeholder hint, parsing, error message wiring
  • visual regression: regression-test /datetimeinput and /form-errors look unchanged

Out of scope

  • Promoting helpers to @instructure/ui-i18n (deferred until a second consumer — likely TimeSelect v2's eventual hooks rewrite — needs them).
  • Forwarding DateInput v2's renderCalendarIcon / width / margin / isInline props (separate enhancement).
  • Migrating messageFormat's default away from a custom formatter to e.g. Intl.DateTimeFormatOptions — keeping it as a function gives consumers full control.

🤖 Generated with Claude Code

Rewrites DateTimeInput v2 from a class to a functional component, removes
all Moment.js usage from internal logic, and extracts pure timezone-aware
date math to a sibling utils.ts. Drops setTimeout(0) shims, dead refs, and
the formatDateInput / DateInput dual-formatting redundancy.

Side effects of state updaters now happen after commit (via a snapshotRef),
which makes onChange StrictMode-safe.

BREAKING CHANGE:
- messageFormat prop type changed from a Moment format string (e.g. 'LLLL')
  to a formatter function: (date: Date, locale: string, timezone: string) => string.
  Default output is unchanged ("Monday, May 1, 2017 1:30 PM" in en-US).
- Typed input is now strictly governed by DateInput v2's locale parser.
  v1's lenient Moment-based fallback ("Sep 4, 1986", "2017-05-01" in en-US,
  etc.) is no longer accepted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 28, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://instructure.design/pr-preview/pr-2531/

Built to branch gh-pages at 2026-04-28 12:23 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Tightens DateInput v2's locale parser so it can serve as the sole authority
on what's accepted (which DateTimeInput v2 now relies on after dropping its
Moment-based fallback).

- Validate that each segment is digits and there are exactly 3 segments
  (so things like "May/4/2017" no longer slip through).
- Reject impossible calendar dates (Feb 30, etc.) via a Date round-trip.
- Strip bidi marks (U+200E / U+200F / U+061C) from the formatted input
  before parsing so the split regex doesn't have to care about them.
- Centralize format options in a single FORMAT_OPTIONS constant shared by
  parse, format, and placeholder-hint generation. Forces gregory + latn so
  the input contract stays predictable across locales.
- Replace the mirrored `inputMessages` state with a `hasInternalError`
  flag; render the `messages` prop directly and append `invalidDateErrorMessage`
  only when the internal error is on. Removes a stale-state surface where
  external `messages` updates could get masked by a previous internal error.
- Generate the placeholder hint from `formatToParts` directly instead of
  regex-replacing digits in a formatted example. More robust to locales
  whose digits happen to collide with the example values.
- Memoize `placeholderHint` and `selectedDate`.
- Drop a stale TODO above `displayName`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ToMESSKa
Copy link
Copy Markdown
Contributor

image image This branch vs master: the date in the error message do not really match and it also says 19:30 when the selected time is 1:30 PM. May it has to do the examples using moment but it worth a look.

@ToMESSKa
Copy link
Copy Markdown
Contributor

ToMESSKa commented Apr 29, 2026

If I clear the date in the input field, the time is cleared too automatically on master. Now this does not happen.

@ToMESSKa
Copy link
Copy Markdown
Contributor

ToMESSKa commented Apr 29, 2026

Can you please check the cypress tests? Some of them are failing in DateInput and DateTimeInput.

@ToMESSKa ToMESSKa self-requested a review April 29, 2026 15:07
Copy link
Copy Markdown
Contributor

@ToMESSKa ToMESSKa left a comment

Choose a reason for hiding this comment

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

see my comments

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