@reliability-design/web 1.4.5 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/rel-alert/rel-alert.ts +1 -1
  2. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  3. package/dist/components/rel-badge/rel-badge.ts +6 -1
  4. package/dist/components/rel-calendar/rel-calendar.ts +64 -18
  5. package/dist/components/rel-carousel/rel-carousel.ts +25 -2
  6. package/dist/components/rel-chip/rel-chip.ts +31 -20
  7. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  8. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  9. package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
  10. package/dist/components/rel-drawer/rel-drawer.ts +6 -3
  11. package/dist/components/rel-menu/rel-menu.ts +6 -0
  12. package/dist/components/rel-otp/rel-otp.ts +12 -2
  13. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  14. package/dist/components/rel-popover/rel-popover.ts +33 -2
  15. package/dist/components/rel-progress/rel-progress.ts +13 -4
  16. package/dist/components/rel-radio/README.md +6 -3
  17. package/dist/components/rel-radio/rel-radio.ts +236 -60
  18. package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
  19. package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
  20. package/dist/components/rel-select/rel-select.ts +44 -13
  21. package/dist/components/rel-sheet/rel-sheet.ts +163 -18
  22. package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
  23. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  24. package/dist/components/rel-slider/rel-slider.ts +6 -3
  25. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  26. package/dist/components/rel-stepper/rel-stepper.ts +3 -1
  27. package/dist/components/rel-switch/rel-switch.ts +3 -1
  28. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  29. package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
  30. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  31. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  32. package/dist/components/rel-toast/rel-toast.ts +4 -2
  33. package/dist/components/rel-tooltip/README.md +13 -5
  34. package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
  35. package/dist/components/rel-upload/rel-upload.ts +25 -0
  36. package/dist/index.d.mts +241 -11
  37. package/dist/index.d.ts +241 -11
  38. package/dist/index.js +2573 -598
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2614 -640
  41. package/dist/index.mjs.map +1 -1
  42. package/package.json +2 -2
@@ -278,7 +278,7 @@ export class RelAlert extends LitElement {
278
278
 
279
279
  render() {
280
280
  return html`
281
- <div part="base" class="rel-alert alert-${this.type}" role="alert">
281
+ <div part="base" class="rel-alert alert-${this.type}" role="alert" aria-atomic="true">
282
282
  <div part="icon" class="rel-icon">${this.iconTemplate}</div>
283
283
 
284
284
  <div part="content" class="rel-content">
@@ -9,6 +9,7 @@ import type {
9
9
  TextFieldVariant,
10
10
  FieldLabelStyle,
11
11
  } from "../rel-textfield/rel-textfield.js";
12
+ import { announce } from "../../utils/announce";
12
13
 
13
14
  /** An item is a plain string or any object read through displayField/valueField. */
14
15
  export type AutocompleteItem = string | Record<string, unknown>;
@@ -246,6 +247,15 @@ export class RelAutocomplete extends LitElement {
246
247
  this.internalValueWrite = false;
247
248
  }
248
249
 
250
+ private wasLoading = false;
251
+
252
+ protected updated() {
253
+ if (this.open && this.isLoading && !this.wasLoading) {
254
+ announce("Loading suggestions");
255
+ }
256
+ this.wasLoading = this.isLoading;
257
+ }
258
+
249
259
  /** Full normalized source list (remote results while searchFn drives, else items). */
250
260
  private get allItems(): NormalizedItem[] {
251
261
  const source = this.searchFn && this.remoteItems !== null ? this.remoteItems : this.items;
@@ -290,13 +300,15 @@ export class RelAutocomplete extends LitElement {
290
300
  // pointerdown and on any outside scroll instead of re-anchoring) ──
291
301
 
292
302
  private addGlobalListeners() {
293
- document.addEventListener("pointerdown", this.onOutsidePointerDown);
303
+ // Capture phase so a pointerdown that another component stops in its own
304
+ // capture listener (e.g. a rel-popover trigger) still closes this panel.
305
+ document.addEventListener("pointerdown", this.onOutsidePointerDown, true);
294
306
  window.addEventListener("resize", this.updatePosition);
295
307
  window.addEventListener("scroll", this.onPageScroll, true);
296
308
  }
297
309
 
298
310
  private removeGlobalListeners() {
299
- document.removeEventListener("pointerdown", this.onOutsidePointerDown);
311
+ document.removeEventListener("pointerdown", this.onOutsidePointerDown, true);
300
312
  window.removeEventListener("resize", this.updatePosition);
301
313
  window.removeEventListener("scroll", this.onPageScroll, true);
302
314
  }
@@ -433,6 +445,17 @@ export class RelAutocomplete extends LitElement {
433
445
  this.debounceTimer = setTimeout(() => this.runSearch(this.inputText), this.debounceMs);
434
446
  } else {
435
447
  this.syncPanel();
448
+ this.announceResults();
449
+ }
450
+ }
451
+
452
+ private announceResults() {
453
+ if (!this.open || this.isLoading) return;
454
+ const n = this.filteredItems.length;
455
+ if (n === 0) {
456
+ if (this.inputText) announce(this.noResultsText);
457
+ } else {
458
+ announce(`${n} result${n === 1 ? "" : "s"} available`);
436
459
  }
437
460
  }
438
461
 
@@ -452,6 +475,7 @@ export class RelAutocomplete extends LitElement {
452
475
  if (seq === this.searchSeq) {
453
476
  this.searching = false;
454
477
  this.syncPanel();
478
+ this.announceResults();
455
479
  }
456
480
  }
457
481
  }
@@ -523,11 +547,13 @@ export class RelAutocomplete extends LitElement {
523
547
  return;
524
548
  }
525
549
  this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
550
+ this.announceHighlighted(filtered);
526
551
  break;
527
552
  case "ArrowUp":
528
553
  e.preventDefault();
529
554
  if (!this.open) return;
530
555
  this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
556
+ this.announceHighlighted(filtered);
531
557
  break;
532
558
  case "Enter":
533
559
  if (this.open && this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
@@ -552,6 +578,11 @@ export class RelAutocomplete extends LitElement {
552
578
  }
553
579
  }
554
580
 
581
+ private announceHighlighted(filtered: NormalizedItem[]) {
582
+ const item = filtered[this.highlightedIndex];
583
+ if (item) announce(`${item.label}, ${this.highlightedIndex + 1} of ${filtered.length}`);
584
+ }
585
+
555
586
  private onFocusIn() {
556
587
  if (this.disabled || this.readonly) return;
557
588
  if (this.queryLongEnough && (this.filteredItems.length > 0 || this.minSearchLength === 0)) {
@@ -142,6 +142,11 @@ export class RelBadge extends LitElement {
142
142
  return ["badge", `badge-${this.size}`, `badge-${this.variant}`].join(" ");
143
143
  }
144
144
 
145
+ private get computedAriaLabel() {
146
+ if (this.ariaLabel) return this.ariaLabel;
147
+ return this.number ? `${this.number} ${this.suffix}` : `New ${this.suffix}`;
148
+ }
149
+
145
150
  override updated(changedProperties: Map<PropertyKey, unknown>): void {
146
151
  if (changedProperties.has("color")) {
147
152
  if (this.color) {
@@ -162,7 +167,7 @@ export class RelBadge extends LitElement {
162
167
 
163
168
  render() {
164
169
  return html`
165
- <span part="base" class="${this.classes}" aria-label=${this.ariaLabel || `${this.number} ${this.suffix}`}>
170
+ <span part="base" class="${this.classes}" role="status" aria-label=${this.computedAriaLabel}>
166
171
  ${this.variant === "number" ? this.number : null}
167
172
  </span>
168
173
  `;
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
+ import { announce } from "../../utils/announce";
3
4
 
4
5
  interface CalendarDay {
5
6
  date: Date;
@@ -99,6 +100,13 @@ export class RelCalendar extends LitElement {
99
100
  gap: var(--rel-spacing-2xs);
100
101
  }
101
102
 
103
+ .calendar-week {
104
+ grid-column: 1 / -1;
105
+ display: grid;
106
+ grid-template-columns: repeat(7, 1fr);
107
+ gap: var(--rel-spacing-2xs);
108
+ }
109
+
102
110
  .weekday {
103
111
  text-align: center;
104
112
  color: var(--rel-text-placeholder);
@@ -315,6 +323,26 @@ export class RelCalendar extends LitElement {
315
323
  return formatter.format(new Date(this.viewYear, this.viewMonth, 1));
316
324
  }
317
325
 
326
+ private dayLabelFormatter: Intl.DateTimeFormat | null = null;
327
+ private dayLabelLocale = "";
328
+
329
+ private formatDayLabel(date: Date): string {
330
+ if (!this.dayLabelFormatter || this.dayLabelLocale !== this.locale) {
331
+ this.dayLabelLocale = this.locale;
332
+ this.dayLabelFormatter = new Intl.DateTimeFormat(this.locale, {
333
+ weekday: "long",
334
+ month: "long",
335
+ day: "numeric",
336
+ year: "numeric",
337
+ });
338
+ }
339
+ return this.dayLabelFormatter.format(date);
340
+ }
341
+
342
+ private announceMonth() {
343
+ announce(`${this.getMonthName()} ${this.viewYear}`);
344
+ }
345
+
318
346
  private previousMonth() {
319
347
  if (this.viewMonth === 0) {
320
348
  this.viewMonth = 11;
@@ -322,6 +350,7 @@ export class RelCalendar extends LitElement {
322
350
  } else {
323
351
  this.viewMonth--;
324
352
  }
353
+ this.announceMonth();
325
354
  }
326
355
 
327
356
  private nextMonth() {
@@ -331,6 +360,7 @@ export class RelCalendar extends LitElement {
331
360
  } else {
332
361
  this.viewMonth++;
333
362
  }
363
+ this.announceMonth();
334
364
  }
335
365
 
336
366
  private selectDay(day: CalendarDay) {
@@ -385,6 +415,7 @@ export class RelCalendar extends LitElement {
385
415
  e.preventDefault();
386
416
  if (e.shiftKey) {
387
417
  this.viewYear--;
418
+ this.announceMonth();
388
419
  } else {
389
420
  this.previousMonth();
390
421
  }
@@ -393,6 +424,7 @@ export class RelCalendar extends LitElement {
393
424
  e.preventDefault();
394
425
  if (e.shiftKey) {
395
426
  this.viewYear++;
427
+ this.announceMonth();
396
428
  } else {
397
429
  this.nextMonth();
398
430
  }
@@ -451,9 +483,13 @@ export class RelCalendar extends LitElement {
451
483
  const days = this.getCalendarDays();
452
484
  const weekdayLabels = this.getWeekdayLabels();
453
485
  const rovingIndex = this.getRovingIndex(days);
486
+ const weeks: CalendarDay[][] = [];
487
+ for (let i = 0; i < days.length; i += 7) {
488
+ weeks.push(days.slice(i, i + 7));
489
+ }
454
490
 
455
491
  return html`
456
- <div role="grid" aria-label="Calendar">
492
+ <div>
457
493
  <div part="header" class="header">
458
494
  <button
459
495
  part="action" class="nav-button"
@@ -483,6 +519,7 @@ export class RelCalendar extends LitElement {
483
519
  </button>
484
520
  </div>
485
521
 
522
+ <div role="grid" aria-label="${this.getMonthName()} ${this.viewYear}">
486
523
  <div part="list" class="calendar-grid" role="row">
487
524
  ${weekdayLabels.map(
488
525
  (label) => html`
@@ -493,26 +530,35 @@ export class RelCalendar extends LitElement {
493
530
  )}
494
531
  </div>
495
532
 
496
- <div part="list" class="calendar-grid">
497
- ${days.map(
498
- (day, index) => html`
499
- <button
500
- part="item" class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
501
- ? "today"
502
- : ""} ${!day.isCurrentMonth ? "other-month" : ""} ${day.isWeekend ? "weekend" : ""}"
503
- role="gridcell"
504
- aria-label="${this.formatISODate(day.date)}"
505
- aria-selected="${day.isSelected}"
506
- ?disabled=${day.isDisabled}
507
- @click=${() => this.selectDay(day)}
508
- @keydown=${(e: KeyboardEvent) => this.handleKeyDown(e, day, index)}
509
- tabindex="${index === rovingIndex ? 0 : -1}"
510
- >
511
- ${day.day}
512
- </button>
533
+ <div part="list" class="calendar-grid" role="rowgroup">
534
+ ${weeks.map(
535
+ (week, weekIndex) => html`
536
+ <div class="calendar-week" role="row">
537
+ ${week.map((day, dayIndex) => {
538
+ const index = weekIndex * 7 + dayIndex;
539
+ return html`
540
+ <button
541
+ part="item" class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
542
+ ? "today"
543
+ : ""} ${!day.isCurrentMonth ? "other-month" : ""} ${day.isWeekend ? "weekend" : ""}"
544
+ role="gridcell"
545
+ aria-label="${this.formatDayLabel(day.date)}"
546
+ aria-selected="${day.isSelected}"
547
+ aria-current=${day.isToday ? "date" : nothing}
548
+ ?disabled=${day.isDisabled}
549
+ @click=${() => this.selectDay(day)}
550
+ @keydown=${(e: KeyboardEvent) => this.handleKeyDown(e, day, index)}
551
+ tabindex="${index === rovingIndex ? 0 : -1}"
552
+ >
553
+ ${day.day}
554
+ </button>
555
+ `;
556
+ })}
557
+ </div>
513
558
  `
514
559
  )}
515
560
  </div>
561
+ </div>
516
562
  </div>
517
563
  `;
518
564
  }
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { announce } from "../../utils/announce";
3
4
 
4
5
  @customElement("rel-carousel")
5
6
  export class RelCarousel extends LitElement {
@@ -41,6 +42,7 @@ export class RelCarousel extends LitElement {
41
42
  // Number of clone slides prepended before the real slides
42
43
  private clonesBefore = 0;
43
44
  private _silentJumping = false;
45
+ private _autoplayTick = false;
44
46
  private _ro?: ResizeObserver;
45
47
 
46
48
  static styles = css`
@@ -307,6 +309,12 @@ export class RelCarousel extends LitElement {
307
309
  const elements = slot.assignedElements();
308
310
  this.slideCount = elements.length;
309
311
 
312
+ elements.forEach((el, i) => {
313
+ el.setAttribute("role", "group");
314
+ el.setAttribute("aria-roledescription", "slide");
315
+ el.setAttribute("aria-label", `${i + 1} of ${elements.length}`);
316
+ });
317
+
310
318
  // Clamp value to valid real-slide range
311
319
  if (this.value >= this.slideCount) this.value = Math.max(0, this.slideCount - 1);
312
320
  if (this.value < 0) this.value = 0;
@@ -397,9 +405,14 @@ export class RelCarousel extends LitElement {
397
405
  this.currentTranslate = translate;
398
406
  this.prevTranslate = translate;
399
407
  this.track!.style.transform = this.axisTransform(translate);
408
+ const fromAutoplay = this._autoplayTick;
400
409
  this.track!.addEventListener(
401
410
  "transitionend",
402
- () => this.silentJump(this.slideCount - 1),
411
+ () => {
412
+ this._autoplayTick = fromAutoplay;
413
+ this.silentJump(this.slideCount - 1);
414
+ this._autoplayTick = false;
415
+ },
403
416
  { once: true }
404
417
  );
405
418
  }
@@ -419,9 +432,14 @@ export class RelCarousel extends LitElement {
419
432
  this.currentTranslate = translate;
420
433
  this.prevTranslate = translate;
421
434
  this.track!.style.transform = this.axisTransform(translate);
435
+ const fromAutoplay = this._autoplayTick;
422
436
  this.track!.addEventListener(
423
437
  "transitionend",
424
- () => this.silentJump(0),
438
+ () => {
439
+ this._autoplayTick = fromAutoplay;
440
+ this.silentJump(0);
441
+ this._autoplayTick = false;
442
+ },
425
443
  { once: true }
426
444
  );
427
445
  }
@@ -452,6 +470,9 @@ export class RelCarousel extends LitElement {
452
470
  }
453
471
 
454
472
  private emitChange() {
473
+ if (!this._autoplayTick && this.slideCount > 0) {
474
+ announce(`Slide ${this.value + 1} of ${this.slideCount}`);
475
+ }
455
476
  this.dispatchEvent(
456
477
  new CustomEvent("rel-change", {
457
478
  detail: { value: this.value },
@@ -466,7 +487,9 @@ export class RelCarousel extends LitElement {
466
487
 
467
488
  this.stopAutoplay();
468
489
  this.autoplayTimer = window.setInterval(() => {
490
+ this._autoplayTick = true;
469
491
  this.goToNext();
492
+ this._autoplayTick = false;
470
493
  }, this.interval);
471
494
  }
472
495
 
@@ -2,6 +2,7 @@ import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
  import "../rel-avatar/rel-avatar.js";
4
4
  import "@reliability-design/icons/rel-icon";
5
+ import { announce } from "../../utils/announce";
5
6
 
6
7
  export type ChipTone = "neutral" | "danger" | "warning" | "success" | "info";
7
8
  export type ChipVariant = "fill" | "subtle";
@@ -76,6 +77,13 @@ export class RelChip extends LitElement {
76
77
  transition: background-color 0.15s ease;
77
78
  }
78
79
 
80
+ .chip-action {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: var(--rel-spacing-xs);
84
+ outline: none;
85
+ }
86
+
79
87
  /* ── Size ── */
80
88
  :host([size="lg"]) .chip {
81
89
  height: 32px;
@@ -262,7 +270,7 @@ export class RelChip extends LitElement {
262
270
  outward by strokeWeight — outline-offset must be 0, not a positive
263
271
  gap (a positive offset was the bug: it drew a visible gap that
264
272
  doesn't exist in Figma). ── */
265
- .chip:focus-visible {
273
+ .chip:has(.chip-action:focus-visible) {
266
274
  outline: 1.5px solid var(--rel-chip-focus-ring, var(--rel-border-focus));
267
275
  outline-offset: 1.5px;
268
276
  box-shadow: 0 0 0 1.5px var(--rel-border-focus-inner);
@@ -363,6 +371,7 @@ export class RelChip extends LitElement {
363
371
  composed: true,
364
372
  })
365
373
  );
374
+ announce(`${this.label} removed`);
366
375
  }
367
376
 
368
377
  /** The chip body is announced as a button, so it must also act on keys:
@@ -432,25 +441,27 @@ export class RelChip extends LitElement {
432
441
  }
433
442
 
434
443
  return html`
435
- <span
436
- part="base" class="${this.classes}"
437
- role="button"
438
- tabindex="0"
439
- aria-label="${this.label}"
440
- @keydown=${this.handleKeydown}
441
- >
442
- ${showAvatar
443
- ? html`<rel-avatar type="text" text=${this.avatar} size="xs"></rel-avatar>`
444
- : null}
445
- ${showLeadingIcon
446
- ? html`<rel-icon part="icon" class="chip-icon" name="${this.leadingIcon}"></rel-icon>`
447
- : null}
448
-
449
- <span part="label" class="chip-label">${this.label}</span>
450
-
451
- ${this.trailingIcon
452
- ? html`<rel-icon class="chip-icon" name="${this.trailingIcon}"></rel-icon>`
453
- : null}
444
+ <span part="base" class="${this.classes}">
445
+ <span
446
+ class="chip-action"
447
+ role="button"
448
+ tabindex="0"
449
+ aria-label="${this.label}"
450
+ @keydown=${this.handleKeydown}
451
+ >
452
+ ${showAvatar
453
+ ? html`<rel-avatar type="text" text=${this.avatar} size="xs"></rel-avatar>`
454
+ : null}
455
+ ${showLeadingIcon
456
+ ? html`<rel-icon part="icon" class="chip-icon" name="${this.leadingIcon}"></rel-icon>`
457
+ : null}
458
+
459
+ <span part="label" class="chip-label">${this.label}</span>
460
+
461
+ ${this.trailingIcon
462
+ ? html`<rel-icon class="chip-icon" name="${this.trailingIcon}"></rel-icon>`
463
+ : null}
464
+ </span>
454
465
  ${this.removable
455
466
  ? html`<button
456
467
  class="chip-remove"
@@ -0,0 +1,116 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ parseDateInput,
5
+ isDayFirstPattern,
6
+ isDayFirstLocale,
7
+ } from "./date-parse";
8
+
9
+ // Reference "today": 2026-07-21 (a Tuesday), noon to dodge TZ edges.
10
+ const ref = new Date(2026, 6, 21, 12, 0, 0);
11
+ const opts = { referenceDate: ref };
12
+
13
+ const ymd = (d: Date | null) =>
14
+ d === null
15
+ ? null
16
+ : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
17
+ d.getDate()
18
+ ).padStart(2, "0")}`;
19
+
20
+ test("shortcuts: t / today / t±N / tN", () => {
21
+ assert.equal(ymd(parseDateInput("t", opts)), "2026-07-21");
22
+ assert.equal(ymd(parseDateInput("T", opts)), "2026-07-21");
23
+ assert.equal(ymd(parseDateInput("today", opts)), "2026-07-21");
24
+ assert.equal(ymd(parseDateInput("t+1", opts)), "2026-07-22");
25
+ assert.equal(ymd(parseDateInput("t1", opts)), "2026-07-22");
26
+ assert.equal(ymd(parseDateInput("T + 7", opts)), "2026-07-28");
27
+ assert.equal(ymd(parseDateInput("t-1", opts)), "2026-07-20");
28
+ assert.equal(ymd(parseDateInput("t-30", opts)), "2026-06-21");
29
+ // month/year rollover
30
+ assert.equal(ymd(parseDateInput("t+11", opts)), "2026-08-01");
31
+ });
32
+
33
+ test("incomplete shortcut is not 'today'", () => {
34
+ assert.equal(parseDateInput("t+", opts), null);
35
+ assert.equal(parseDateInput("t-", opts), null);
36
+ });
37
+
38
+ test("ISO and year-first forms", () => {
39
+ assert.equal(ymd(parseDateInput("2026-07-21", opts)), "2026-07-21");
40
+ assert.equal(ymd(parseDateInput("2026/7/21", opts)), "2026-07-21");
41
+ assert.equal(ymd(parseDateInput("2026.07.21", opts)), "2026-07-21");
42
+ assert.equal(ymd(parseDateInput("20260721", opts)), "2026-07-21");
43
+ });
44
+
45
+ test("numeric M/D/Y vs D/M/Y via dayFirst", () => {
46
+ assert.equal(ymd(parseDateInput("7/21/2026", opts)), "2026-07-21");
47
+ assert.equal(
48
+ ymd(parseDateInput("21/7/2026", { ...opts, dayFirst: true })),
49
+ "2026-07-21"
50
+ );
51
+ // ambiguous "3/4": order decided by dayFirst
52
+ assert.equal(ymd(parseDateInput("3/4/2026", opts)), "2026-03-04");
53
+ assert.equal(
54
+ ymd(parseDateInput("3/4/2026", { ...opts, dayFirst: true })),
55
+ "2026-04-03"
56
+ );
57
+ // a part > 12 forces the order regardless of dayFirst
58
+ assert.equal(ymd(parseDateInput("21/7/2026", opts)), "2026-07-21");
59
+ assert.equal(ymd(parseDateInput("7/21/2026", { ...opts, dayFirst: true })), "2026-07-21");
60
+ });
61
+
62
+ test("two-digit years map to 20YY", () => {
63
+ assert.equal(ymd(parseDateInput("7/21/26", opts)), "2026-07-21");
64
+ assert.equal(ymd(parseDateInput("21/7/26", { ...opts, dayFirst: true })), "2026-07-21");
65
+ });
66
+
67
+ test("month names, long and short, any case", () => {
68
+ assert.equal(ymd(parseDateInput("21 Jul 2026", opts)), "2026-07-21");
69
+ assert.equal(ymd(parseDateInput("Jul 21 2026", opts)), "2026-07-21");
70
+ assert.equal(ymd(parseDateInput("July 21, 2026", opts)), "2026-07-21");
71
+ assert.equal(ymd(parseDateInput("21 december 2026", opts)), "2026-12-21");
72
+ });
73
+
74
+ test("partial input fills from the reference date", () => {
75
+ assert.equal(ymd(parseDateInput("5", opts)), "2026-07-05"); // day in ref month
76
+ assert.equal(ymd(parseDateInput("21/7", { ...opts, dayFirst: true })), "2026-07-21");
77
+ assert.equal(ymd(parseDateInput("12/25", opts)), "2026-12-25");
78
+ assert.equal(ymd(parseDateInput("25 Dec", opts)), "2026-12-25");
79
+ assert.equal(ymd(parseDateInput("Dec 25", opts)), "2026-12-25");
80
+ });
81
+
82
+ test("impossible dates rejected", () => {
83
+ assert.equal(parseDateInput("2026-02-30", opts), null);
84
+ assert.equal(parseDateInput("31/4/2026", { ...opts, dayFirst: true }), null);
85
+ assert.equal(parseDateInput("0", opts), null);
86
+ assert.equal(parseDateInput("32", opts), null);
87
+ assert.equal(parseDateInput("13/13/2026", opts), null);
88
+ });
89
+
90
+ test("garbage rejected", () => {
91
+ assert.equal(parseDateInput("", opts), null);
92
+ assert.equal(parseDateInput("asdf", opts), null);
93
+ assert.equal(parseDateInput("1/2/3/4", opts), null);
94
+ assert.equal(parseDateInput("Jul", opts), null);
95
+ assert.equal(parseDateInput("tomorrow+1", opts), null);
96
+ });
97
+
98
+ test("leap day handling", () => {
99
+ assert.equal(ymd(parseDateInput("2028-02-29", opts)), "2028-02-29");
100
+ assert.equal(parseDateInput("2026-02-29", opts), null);
101
+ });
102
+
103
+ test("isDayFirstPattern", () => {
104
+ assert.equal(isDayFirstPattern("DD/MM/YYYY"), true);
105
+ assert.equal(isDayFirstPattern("MM/DD/YYYY"), false);
106
+ assert.equal(isDayFirstPattern("YYYY-MM-DD"), false);
107
+ assert.equal(isDayFirstPattern("D MMM YYYY"), true);
108
+ assert.equal(isDayFirstPattern("[Due] MM/DD"), false);
109
+ assert.equal(isDayFirstPattern("YYYY"), null);
110
+ });
111
+
112
+ test("isDayFirstLocale", () => {
113
+ assert.equal(isDayFirstLocale("en-US"), false);
114
+ assert.equal(isDayFirstLocale("en-GB"), true);
115
+ assert.equal(isDayFirstLocale("de-DE"), true);
116
+ });