@@ -185,6 +185,8 @@ public class DateTimeConverter implements Converter, PartialStateHolder {
185185 );
186186
187187 private static final Pattern ESCAPED_DATE_TIME_PATTERN = Pattern .compile ("'[^']*+'" );
188+ private static final Pattern FIXED_WIDTH_WHITESPACE = Pattern .compile ("[\u00a0 \u1680 \u2000 -\u200a \u202f \u205f \u3000 ]" );
189+ private static final Pattern ZERO_WIDTH_WHITESPACE = Pattern .compile ("[\u200b -\u200d \u2060 \ufeff ]" );
188190
189191 // ------------------------------------------------------ Instance Variables
190192
@@ -387,7 +389,7 @@ public Object getAsObject(FacesContext context, UIComponent component, String va
387389 Locale locale = getLocale (context );
388390
389391 // Create and configure the parser to be used
390- parser = getDateFormat (locale );
392+ parser = getDateFormat (locale , true );
391393 if (timeZone != null ) {
392394 parser .setTimeZone (timeZone );
393395 }
@@ -428,21 +430,32 @@ private static class FormatWrapper {
428430 private final DateFormat dateFormat ;
429431 private final DateTimeFormatter dateTimeFormatter ;
430432 private final TemporalQuery <Object > from ;
433+ private final boolean normalizeWhitespaceOnParse ;
431434
432435 private FormatWrapper (DateFormat dataFormat ) {
433436 this .dateFormat = dataFormat ;
434437 dateTimeFormatter = null ;
435438 from = null ;
439+ normalizeWhitespaceOnParse = false ;
436440 }
437441
438- private FormatWrapper (DateTimeFormatter dateTimeFormatter , TemporalQuery <Object > from ) {
442+ private FormatWrapper (DateTimeFormatter dateTimeFormatter , TemporalQuery <Object > from , boolean normalizeWhitespaceOnParse ) {
439443 dateFormat = null ;
440444 this .dateTimeFormatter = dateTimeFormatter ;
441445 this .from = from ;
446+ this .normalizeWhitespaceOnParse = normalizeWhitespaceOnParse ;
442447 }
443448
444449 private Object parse (CharSequence text ) throws ParseException {
445- return dateFormat != null ? dateFormat .parse ((String ) text ) : dateTimeFormatter .parse (text , from );
450+ if (dateFormat != null ) {
451+ return dateFormat .parse ((String ) text );
452+ }
453+
454+ if (normalizeWhitespaceOnParse ) {
455+ text = normalizeWhitespace (text );
456+ }
457+
458+ return dateTimeFormatter .parse (text , from );
446459 }
447460
448461 private String format (Object obj ) {
@@ -488,7 +501,7 @@ public String getAsString(FacesContext context, UIComponent component, Object va
488501 Locale locale = getLocale (context );
489502
490503 // Create and configure the formatter to be used
491- FormatWrapper formatter = getDateFormat (locale );
504+ FormatWrapper formatter = getDateFormat (locale , false );
492505 if (null != timeZone ) {
493506 formatter .setTimeZone (timeZone );
494507 }
@@ -513,9 +526,11 @@ public String getAsString(FacesContext context, UIComponent component, Object va
513526 * </p>
514527 *
515528 * @param locale The <code>Locale</code> used to select formatting and parsing conventions
529+ * @param forParsing {@code true} if the result will be used for parsing user input (enables whitespace normalization),
530+ * {@code false} if it will be used for formatting output
516531 * @throws ConverterException if no instance can be created
517532 */
518- private FormatWrapper getDateFormat (Locale locale ) {
533+ private FormatWrapper getDateFormat (Locale locale , boolean forParsing ) {
519534
520535 // PENDING(craigmcc) - Implement pooling if needed for performance?
521536
@@ -541,11 +556,11 @@ private FormatWrapper getDateFormat(Locale locale) {
541556 } else if (type .equals ("time" )) {
542557 df = DateFormat .getTimeInstance (getStyle (timeStyle ), locale );
543558 } else if (type .equals ("localDate" )) {
544- dtfBuilder = new DateTimeFormatterBuilder (). appendLocalized ( getFormatStyle (dateStyle ), null );
559+ dtfBuilder = createLocalizedBuilder ( getFormatStyle (dateStyle ), null , locale , forParsing );
545560 } else if (type .equals ("localDateTime" )) {
546- dtfBuilder = new DateTimeFormatterBuilder (). appendLocalized ( getFormatStyle (dateStyle ), getFormatStyle (timeStyle ));
561+ dtfBuilder = createLocalizedBuilder ( getFormatStyle (dateStyle ), getFormatStyle (timeStyle ), locale , forParsing );
547562 } else if (type .equals ("localTime" )) {
548- dtfBuilder = new DateTimeFormatterBuilder (). appendLocalized ( null , getFormatStyle (timeStyle ));
563+ dtfBuilder = createLocalizedBuilder ( null , getFormatStyle (timeStyle ), locale , forParsing );
549564 } else if (type .equals ("offsetTime" )) {
550565 dtf = DateTimeFormatter .ISO_OFFSET_TIME .withLocale (locale );
551566 } else if (type .equals ("offsetDateTime" )) {
@@ -570,7 +585,7 @@ private FormatWrapper getDateFormat(Locale locale) {
570585 }
571586
572587 if (dtf != null ) {
573- return new FormatWrapper (dtf , fromJavaTime );
588+ return new FormatWrapper (dtf , fromJavaTime , forParsing );
574589 }
575590 }
576591
@@ -624,7 +639,27 @@ private static int getStyle(String name) {
624639 throw new ConverterException ("Invalid style '" + name + '\'' );
625640 }
626641
627- private static FormatStyle getFormatStyle (String name ) {
642+ /**
643+ * Returns a {@link DateTimeFormatterBuilder} with the localized date/time pattern appended. When {@code forParsing} is {@code true}, the pattern is
644+ * normalized to replace fixed-width whitespace (such as NNBSP U+202F) with regular spaces and strip zero-width characters, so that user input with regular
645+ * spaces is accepted.
646+ */
647+ private static DateTimeFormatterBuilder createLocalizedBuilder (FormatStyle dateStyle , FormatStyle timeStyle , Locale locale , boolean forParsing ) {
648+ if (forParsing ) {
649+ var localizedPattern = DateTimeFormatterBuilder .getLocalizedDateTimePattern (dateStyle , timeStyle , IsoChronology .INSTANCE , locale );
650+ var normalizedPattern = normalizeWhitespace (localizedPattern );
651+ return new DateTimeFormatterBuilder ().appendPattern (normalizedPattern );
652+ }
653+
654+ return new DateTimeFormatterBuilder ().appendLocalized (dateStyle , timeStyle );
655+ }
656+
657+ private static String normalizeWhitespace (CharSequence text ) {
658+ String normalized = FIXED_WIDTH_WHITESPACE .matcher (text ).replaceAll (" " );
659+ return ZERO_WIDTH_WHITESPACE .matcher (normalized ).replaceAll ("" );
660+ }
661+
662+ private static FormatStyle getFormatStyle (String name ) {
628663 if (null != name ) {
629664 switch (name ) {
630665 case "default" :
0 commit comments