@stimulus-plumbers/controllers 0.2.7 → 0.2.9

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.
@@ -0,0 +1,94 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { setPressed } from '../aria';
3
+ import { attachFormatter } from '../plumbers';
4
+
5
+ export default class extends Controller {
6
+ static targets = ['input', 'toggle'];
7
+ static values = {
8
+ type: { type: String, default: 'plain' },
9
+ options: { type: Object, default: {} },
10
+ revealed: { type: Boolean, default: false },
11
+ };
12
+
13
+ connect() {
14
+ attachFormatter(this, { type: this.typeValue, options: this.optionsValue });
15
+ this.format(this.readValue());
16
+ this.drawToggle();
17
+ }
18
+
19
+ typeValueChanged() {
20
+ if (!this.formatter) return;
21
+ attachFormatter(this, { type: this.typeValue, options: this.optionsValue });
22
+ this.format(this.readValue());
23
+ this.drawToggle();
24
+ }
25
+
26
+ optionsValueChanged() {
27
+ if (!this.formatter) return;
28
+ attachFormatter(this, { type: this.typeValue, options: this.optionsValue });
29
+ this.format(this.readValue());
30
+ }
31
+
32
+ revealedValueChanged() {
33
+ if (!this.formatter) return;
34
+ this.format(this.readValue());
35
+ this.drawToggle();
36
+ }
37
+
38
+ onChange(event) {
39
+ this.format(event?.detail?.value ?? '');
40
+ }
41
+
42
+ format(value) {
43
+ if (!this.formatter) return;
44
+ this.onFormatting(value);
45
+ }
46
+
47
+ toggle() {
48
+ if (!this.formatter.maskable() && this.typeValue !== 'password') return;
49
+ this.revealedValue = !this.revealedValue;
50
+ }
51
+
52
+ onPaste(event) {
53
+ const raw = event.detail?.text ?? '';
54
+ if (!this.formatter || !raw) return;
55
+ const value = this.formatter.normalize(raw);
56
+ if (!this.formatter.validate(value)) return;
57
+ this.format(value);
58
+ }
59
+
60
+ drawToggle() {
61
+ if (!this.hasToggleTarget) return;
62
+ const hasToggleBehavior = this.formatter?.maskable() || this.typeValue === 'password';
63
+ this.toggleTarget.hidden = !hasToggleBehavior;
64
+ if (hasToggleBehavior) setPressed(this.toggleTarget, this.revealedValue);
65
+ }
66
+
67
+ readValue() {
68
+ if (!this.hasInputTarget) return '';
69
+ return this.inputTarget instanceof HTMLInputElement ? this.inputTarget.value : this.inputTarget.textContent;
70
+ }
71
+
72
+ onFormatting(raw) {
73
+ if (!this.formatter) return;
74
+
75
+ if (this.typeValue === 'password') {
76
+ if (this.hasInputTarget) this.inputTarget.type = this.revealedValue ? 'text' : 'password';
77
+ return;
78
+ }
79
+
80
+ const value = this.formatter.normalize(raw);
81
+ const formatted =
82
+ this.revealedValue || !this.formatter.maskable() ? this.formatter.format(value) : this.formatter.mask(value);
83
+
84
+ if (this.hasInputTarget) {
85
+ if (this.inputTarget instanceof HTMLInputElement) {
86
+ this.inputTarget.value = formatted;
87
+ } else {
88
+ this.inputTarget.textContent = formatted;
89
+ }
90
+ }
91
+
92
+ this.dispatch('formatted', { detail: { value: formatted } });
93
+ }
94
+ }
@@ -0,0 +1,44 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static targets = ['input', 'clear'];
5
+
6
+ initialize() {
7
+ this.onInput = this.draw.bind(this);
8
+ this.onEscape = this.handleEscape.bind(this);
9
+ }
10
+
11
+ connect() {
12
+ this.draw();
13
+ }
14
+
15
+ inputTargetConnected(input) {
16
+ input.addEventListener('input', this.onInput);
17
+ input.addEventListener('keydown', this.onEscape);
18
+ }
19
+
20
+ inputTargetDisconnected(input) {
21
+ input.removeEventListener('input', this.onInput);
22
+ input.removeEventListener('keydown', this.onEscape);
23
+ }
24
+
25
+ clear() {
26
+ if (!this.hasInputTarget) return;
27
+ this.inputTarget.value = '';
28
+ this.draw();
29
+ this.inputTarget.focus();
30
+ this.inputTarget.dispatchEvent(new Event('input', { bubbles: true }));
31
+ }
32
+
33
+ draw() {
34
+ if (!this.hasInputTarget || !this.hasClearTarget) return;
35
+ this.clearTarget.hidden = this.inputTarget.value.length === 0;
36
+ }
37
+
38
+ handleEscape(event) {
39
+ if (event.key !== 'Escape') return;
40
+ if (this.inputTarget.value === '') return;
41
+ event.preventDefault();
42
+ this.clear();
43
+ }
44
+ }
@@ -3,31 +3,34 @@ import { FocusTrap } from '../focus';
3
3
  import { announce } from '../aria';
4
4
  import { attachDismisser } from '../plumbers';
5
5
 
6
- /**
7
- * Modal Dialog Controller
8
- * Implements WAI-ARIA Dialog (Modal) pattern
9
- * Supports both native <dialog> elements and custom implementations
10
- */
11
- export default class ModalController extends Controller {
6
+ export default class extends Controller {
12
7
  static targets = ['modal', 'overlay'];
13
8
 
9
+ initialize() {
10
+ this.onCancel = this.close.bind(this);
11
+ }
12
+
14
13
  connect() {
15
14
  if (!this.hasModalTarget) {
16
15
  console.error('ModalController requires a modal target. Add data-modal-target="modal" to your element.');
17
- return;
18
16
  }
17
+ }
19
18
 
20
- this.isNativeDialog = this.modalTarget instanceof HTMLDialogElement;
21
-
19
+ modalTargetConnected(modal) {
20
+ this.isNativeDialog = modal instanceof HTMLDialogElement;
22
21
  if (this.isNativeDialog) {
23
- this.modalTarget.addEventListener('cancel', this.close);
24
- this.modalTarget.addEventListener('click', this.handleBackdropClick);
22
+ modal.addEventListener('cancel', this.onCancel);
23
+ modal.addEventListener('click', this.onBackdropClick);
25
24
  } else {
26
- this.focusTrap = new FocusTrap(this.modalTarget, {
27
- escapeDeactivates: true,
28
- });
25
+ this.focusTrap = new FocusTrap(modal, { escapeDeactivates: true });
26
+ attachDismisser(this, { element: modal });
27
+ }
28
+ }
29
29
 
30
- attachDismisser(this, { element: this.modalTarget });
30
+ modalTargetDisconnected(modal) {
31
+ if (this.isNativeDialog) {
32
+ modal.removeEventListener('cancel', this.onCancel);
33
+ modal.removeEventListener('click', this.onBackdropClick);
31
34
  }
32
35
  }
33
36
 
@@ -35,13 +38,6 @@ export default class ModalController extends Controller {
35
38
  this.close();
36
39
  };
37
40
 
38
- disconnect() {
39
- if (this.isNativeDialog) {
40
- this.modalTarget.removeEventListener('cancel', this.close);
41
- this.modalTarget.removeEventListener('click', this.handleBackdropClick);
42
- }
43
- }
44
-
45
41
  open(event) {
46
42
  if (event) event.preventDefault();
47
43
  if (!this.hasModalTarget) return;
@@ -89,7 +85,7 @@ export default class ModalController extends Controller {
89
85
  announce('Modal closed');
90
86
  }
91
87
 
92
- handleBackdropClick = (event) => {
88
+ onBackdropClick = (event) => {
93
89
  const rect = this.modalTarget.getBoundingClientRect();
94
90
  const isOutsideDialog =
95
91
  event.clientY < rect.top ||
@@ -39,7 +39,7 @@ export default class extends Controller {
39
39
  await this.load();
40
40
  }
41
41
 
42
- contentLoad() {
42
+ canLoad() {
43
43
  if (this.hasContentTarget && this.contentTarget.tagName.toLowerCase() === 'turbo-frame') {
44
44
  if (this.hasUrlValue) this.contentTarget.setAttribute('src', this.urlValue);
45
45
  return false;
package/src/index.js CHANGED
@@ -10,12 +10,20 @@ export * from './focus.js';
10
10
  export * from './keyboard.js';
11
11
  export * from './aria.js';
12
12
 
13
+ export { Formatter, FORMATTER_TYPES } from './plumbers/formatter.js';
14
+
13
15
  // Export Stimulus controllers
14
- export { default as ModalController } from './controllers/modal_controller.js';
15
- export { default as DismisserController } from './controllers/dismisser_controller.js';
16
- export { default as FlipperController } from './controllers/flipper_controller.js';
17
- export { default as PopoverController } from './controllers/popover_controller.js';
18
16
  export { default as CalendarMonthController } from './controllers/calendar_month_controller.js';
19
17
  export { default as CalendarMonthObserverController } from './controllers/calendar_month_observer_controller.js';
20
- export { default as DatepickerController } from './controllers/datepicker_controller.js';
18
+ export { default as ClipboardController } from './controllers/clipboard_controller.js';
19
+ export { default as ComboboxDateController } from './controllers/combobox_date_controller.js';
20
+ export { default as ComboboxDropdownController } from './controllers/combobox_dropdown_controller.js';
21
+ export { default as ComboboxTimeController } from './controllers/combobox_time_controller.js';
22
+ export { default as DismisserController } from './controllers/dismisser_controller.js';
23
+ export { default as FlipperController } from './controllers/flipper_controller.js';
24
+ export { default as InputComboboxController } from './controllers/input_combobox_controller.js';
25
+ export { default as InputFormatController } from './controllers/input_format_controller.js';
26
+ export { default as InputSearchController } from './controllers/input_search_controller.js';
27
+ export { default as ModalController } from './controllers/modal_controller.js';
21
28
  export { default as PannerController } from './controllers/panner_controller.js';
29
+ export { default as PopoverController } from './controllers/popover_controller.js';
@@ -0,0 +1,60 @@
1
+ import Plumber from './plumber';
2
+
3
+ export class ComboboxDropdown extends Plumber {
4
+ constructor(controller, options = {}) {
5
+ super(controller, options);
6
+ this.debounceTimer = null;
7
+ this.abortController = null;
8
+ }
9
+
10
+ fuzzyFilter(listbox, query) {
11
+ const needle = query.toLowerCase();
12
+ let visible = 0;
13
+ listbox.querySelectorAll('[role="option"]').forEach((opt) => {
14
+ const match = this.fuzzyMatch(needle, opt.textContent.trim().toLowerCase());
15
+ opt.hidden = !match;
16
+ if (match) visible++;
17
+ });
18
+ return visible;
19
+ }
20
+
21
+ fuzzyMatch(needle, haystack) {
22
+ let ni = 0;
23
+ for (let i = 0; i < haystack.length && ni < needle.length; i++) {
24
+ if (haystack[i] === needle[ni]) ni++;
25
+ }
26
+ return ni === needle.length;
27
+ }
28
+
29
+ scheduleFetch(query, delay, callback) {
30
+ clearTimeout(this.debounceTimer);
31
+ this.debounceTimer = setTimeout(() => this.fetch(query, callback), delay);
32
+ }
33
+
34
+ async fetch(query, { url, field, onLoading, onLoaded, onError }) {
35
+ this.abortController?.abort();
36
+ this.abortController = new AbortController();
37
+ onLoading?.(true);
38
+ const fetchUrl = new URL(url, window.location.href);
39
+ fetchUrl.searchParams.set(field, query);
40
+ try {
41
+ const res = await fetch(fetchUrl, {
42
+ signal: this.abortController.signal,
43
+ headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
44
+ });
45
+ if (!res.ok) throw new Error(`${res.status}`);
46
+ onLoaded?.(await res.text());
47
+ } catch (err) {
48
+ if (err.name !== 'AbortError') onError?.(err);
49
+ } finally {
50
+ onLoading?.(false);
51
+ }
52
+ }
53
+
54
+ cancel() {
55
+ clearTimeout(this.debounceTimer);
56
+ this.abortController?.abort();
57
+ }
58
+ }
59
+
60
+ export const initComboboxDropdown = (controller, options) => new ComboboxDropdown(controller, options);
@@ -6,7 +6,7 @@ const defaultOptions = {
6
6
  url: '',
7
7
  reload: 'never',
8
8
  stale: 3600,
9
- onLoad: 'contentLoad',
9
+ onLoad: 'canLoad',
10
10
  onLoading: 'contentLoading',
11
11
  onLoaded: 'contentLoaded',
12
12
  };
@@ -20,7 +20,7 @@ export class ContentLoader extends Plumber {
20
20
  * @param {string} [options.url=''] - URL to fetch content from
21
21
  * @param {string} [options.reload='never'] - Reload strategy ('never', 'always', or 'stale')
22
22
  * @param {number} [options.stale=3600] - Seconds before content becomes stale
23
- * @param {string} [options.onLoad='contentLoad'] - Callback name to check if loadable
23
+ * @param {string} [options.onLoad='canLoad'] - Callback name to check if loadable
24
24
  * @param {string} [options.onLoading='contentLoading'] - Callback name to load content
25
25
  * @param {string} [options.onLoaded='contentLoaded'] - Callback name after loading
26
26
  */
@@ -0,0 +1,65 @@
1
+ import Plumber from './plumber';
2
+ import { PlainFormatter } from './formatters/plain';
3
+ import { CreditCardFormatter } from './formatters/credit_card';
4
+ import { PhoneFormatter } from './formatters/phone';
5
+ import { CurrencyFormatter } from './formatters/currency';
6
+ import { DateFormatter } from './formatters/date';
7
+ import { TimeFormatter } from './formatters/time';
8
+
9
+ export const FORMATTER_TYPES = {
10
+ PLAIN: 'plain',
11
+ CREDIT_CARD: 'creditCard',
12
+ PHONE: 'phone',
13
+ CURRENCY: 'currency',
14
+ DATE: 'date',
15
+ TIME: 'time',
16
+ };
17
+
18
+ const registry = new Map([
19
+ [FORMATTER_TYPES.PLAIN, PlainFormatter],
20
+ [FORMATTER_TYPES.CREDIT_CARD, CreditCardFormatter],
21
+ [FORMATTER_TYPES.PHONE, PhoneFormatter],
22
+ [FORMATTER_TYPES.CURRENCY, CurrencyFormatter],
23
+ [FORMATTER_TYPES.DATE, DateFormatter],
24
+ [FORMATTER_TYPES.TIME, TimeFormatter],
25
+ ]);
26
+
27
+ const defaultOptions = {
28
+ type: FORMATTER_TYPES.PLAIN,
29
+ options: {},
30
+ };
31
+
32
+ export class Formatter extends Plumber {
33
+ static register(type, formatter) {
34
+ registry.set(type, formatter);
35
+ }
36
+
37
+ constructor(controller, options = {}) {
38
+ super(controller, options);
39
+ this.type = options.type ?? defaultOptions.type;
40
+ this.options = options.options ?? defaultOptions.options;
41
+ this.enhance();
42
+ }
43
+
44
+ enhance() {
45
+ const context = this;
46
+ const formatter = registry.get(context.type) ?? registry.get(FORMATTER_TYPES.PLAIN);
47
+
48
+ const helpers = {
49
+ normalize: (raw) => formatter.normalize?.(raw, context.options) ?? (typeof raw === 'string' ? raw : ''),
50
+ validate: (value) => formatter.validate?.(value, context.options) ?? true,
51
+ format: (value) => formatter.format?.(value, context.options) ?? (typeof value === 'string' ? value : ''),
52
+ mask: (value) => formatter.mask?.(value, context.options) ?? null,
53
+ maskable: () => typeof formatter.mask === 'function',
54
+ };
55
+
56
+ Object.defineProperty(this.controller, 'formatter', {
57
+ get() {
58
+ return helpers;
59
+ },
60
+ configurable: true,
61
+ });
62
+ }
63
+ }
64
+
65
+ export const attachFormatter = (controller, options) => new Formatter(controller, options);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Validates a credit card number string using the Luhn algorithm.
3
+ *
4
+ * Starting from the rightmost digit, double every second digit. If doubling
5
+ * produces a number greater than 9, subtract 9 from it. Sum all digits.
6
+ * A valid card number produces a total divisible by 10.
7
+ *
8
+ * @param {string} digits - Digit-only string (no spaces or dashes)
9
+ * @returns {boolean} true if the number passes the Luhn check
10
+ */
11
+ function luhn(digits) {
12
+ let sum = 0;
13
+ let alternate = false;
14
+ for (let i = digits.length - 1; i >= 0; i--) {
15
+ let n = parseInt(digits[i], 10);
16
+ if (alternate) {
17
+ n *= 2;
18
+ if (n > 9) n -= 9;
19
+ }
20
+ sum += n;
21
+ alternate = !alternate;
22
+ }
23
+ return sum % 10 === 0;
24
+ }
25
+
26
+ /** Strips all non-digit characters from raw input */
27
+ const STRIP_NON_DIGITS = /\D/g;
28
+
29
+ /** Matches a valid card digit count (13–19 digits, no other characters) */
30
+ const VALID_CARD_LENGTH = /^\d{13,19}$/;
31
+
32
+ /** Captures groups of up to 4 characters followed by at least one more character */
33
+ const GROUP_FOUR_DIGITS = /(.{4})(?=.)/g;
34
+
35
+ export const CreditCardFormatter = {
36
+ /**
37
+ * Converts raw input to the canonical stored form: digits only, no separators.
38
+ * e.g. '4242 4242 4242 4242' → '4242424242424242'
39
+ * @param {string} raw - Raw input (may contain spaces, dashes, etc.)
40
+ * @returns {string} Digit-only string, or '' for non-string input
41
+ */
42
+ normalize(raw) {
43
+ if (typeof raw !== 'string') return '';
44
+ return raw.replace(STRIP_NON_DIGITS, '');
45
+ },
46
+
47
+ /**
48
+ * Validates the canonical card number using length and Luhn checks.
49
+ * Expects the output of normalize() — digits only.
50
+ * @param {string} value - Canonical value from normalize()
51
+ * @returns {boolean}
52
+ */
53
+ validate(value) {
54
+ if (typeof value !== 'string') return false;
55
+ if (!VALID_CARD_LENGTH.test(value)) return false;
56
+ return luhn(value);
57
+ },
58
+
59
+ /**
60
+ * Formats a canonical card number for display: groups of 4 separated by spaces.
61
+ * e.g. '4242424242424242' → '4242 4242 4242 4242'
62
+ * @param {string} value - Canonical value from normalize()
63
+ * @returns {string} Formatted display string, or '' for non-string input
64
+ */
65
+ format(value) {
66
+ if (typeof value !== 'string') return '';
67
+ return value.replace(GROUP_FOUR_DIGITS, '$1 ');
68
+ },
69
+ };
@@ -0,0 +1,87 @@
1
+ /** Strips everything except digits, period, comma, and negative sign */
2
+ const STRIP_NON_NUMERIC = /[^\d.,-]/g;
3
+
4
+ /** Matches a valid canonical amount: optional negative sign, digits, optional decimal part */
5
+ const VALID_AMOUNT_PATTERN = /^-?\d+(\.\d+)?$/;
6
+
7
+ export const CurrencyFormatter = {
8
+ /**
9
+ * Converts raw input to the canonical stored form: a plain decimal number string.
10
+ * Handles US format (1,234.56), European format (1.234,56), and integers ($1,000).
11
+ * The heuristic for ambiguous comma-only input: ≤2 digits after comma → decimal
12
+ * separator; 3 digits after → thousands separator.
13
+ * @param {string} raw - Raw input (may contain currency symbols, separators)
14
+ * @returns {string} Canonical decimal string (e.g. '1234.56'), or '' for non-string/empty
15
+ */
16
+ normalize(raw) {
17
+ if (typeof raw !== 'string') return '';
18
+ const stripped = raw.replace(STRIP_NON_NUMERIC, '');
19
+ if (!stripped) return '';
20
+ const lastComma = stripped.lastIndexOf(',');
21
+ const lastDot = stripped.lastIndexOf('.');
22
+
23
+ if (lastComma > -1 && lastDot > -1) {
24
+ // Both present: the later one is the decimal separator
25
+ if (lastComma > lastDot) {
26
+ // European: 1.234,56 — comma is decimal, dot is thousands
27
+ return stripped.replace(/\./g, '').replace(',', '.');
28
+ }
29
+ // US: 1,234.56 — dot is decimal, comma is thousands
30
+ return stripped.replace(/,/g, '');
31
+ }
32
+
33
+ if (lastComma > -1) {
34
+ // Comma only: 1-2 digits after = decimal separator; 3 digits after = thousands separator
35
+ const afterComma = stripped.slice(lastComma + 1);
36
+ if (afterComma.length <= 2) return stripped.replace(',', '.');
37
+ return stripped.replace(/,/g, '');
38
+ }
39
+
40
+ // Dot only or no separator
41
+ return stripped;
42
+ },
43
+
44
+ /**
45
+ * Validates the canonical amount: optional negative sign, integer digits,
46
+ * and an optional decimal part of any length.
47
+ * @param {string} value - Canonical value from normalize()
48
+ * @returns {boolean}
49
+ */
50
+ validate(value) {
51
+ if (typeof value !== 'string') return false;
52
+ return VALID_AMOUNT_PATTERN.test(value);
53
+ },
54
+
55
+ /**
56
+ * Formats a canonical amount for display using Intl.NumberFormat.
57
+ * Fraction digit defaults come from the currency itself (e.g. JPY uses 0,
58
+ * USD uses 2, KWD uses 3) — no hardcoded overrides unless opts.fractionDigits
59
+ * is explicitly provided.
60
+ * @param {string} value - Canonical value from normalize()
61
+ * @param {Object} [opts={}] - Options
62
+ * @param {string} [opts.locale='en-US'] - BCP 47 locale tag
63
+ * @param {string} [opts.currency='USD'] - ISO 4217 currency code
64
+ * @param {number} [opts.fractionDigits] - Override min and max fraction digits
65
+ * @returns {string} Formatted currency string, or value as-is for non-numeric input
66
+ */
67
+ format(value, opts = {}) {
68
+ if (typeof value !== 'string') return '';
69
+ const num = parseFloat(value);
70
+ if (isNaN(num)) return value;
71
+ const locale = opts.locale || 'en-US';
72
+ const currency = opts.currency || 'USD';
73
+ const fractionOpts =
74
+ opts.fractionDigits !== undefined
75
+ ? { minimumFractionDigits: opts.fractionDigits, maximumFractionDigits: opts.fractionDigits }
76
+ : {};
77
+ try {
78
+ return new Intl.NumberFormat(locale, {
79
+ style: 'currency',
80
+ currency,
81
+ ...fractionOpts,
82
+ }).format(num);
83
+ } catch {
84
+ return value;
85
+ }
86
+ },
87
+ };
@@ -0,0 +1,98 @@
1
+ /** Matches an ISO 8601 date: YYYY-MM-DD */
2
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
3
+
4
+ /** Matches a date with separators: number-sep-number-sep-number (/, -, or .) */
5
+ const SEPARATED_DATE_PATTERN = /^(\d{1,4})[/\-.](\d{1,2})[/\-.](\d{1,4})$/;
6
+
7
+ /** Strips all non-digit characters (used when extracting 8-digit compact dates) */
8
+ const STRIP_NON_DIGITS = /\D/g;
9
+
10
+ export const DateFormatter = {
11
+ /**
12
+ * Converts raw input to the canonical stored form: ISO 8601 YYYY-MM-DD.
13
+ * Accepts a variety of common date formats:
14
+ * - YYYY-MM-DD (ISO, returned unchanged)
15
+ * - MM/DD/YYYY, DD/MM/YYYY, YYYY/MM/DD (with /, -, or . separators)
16
+ * - YYYYMMDD (8 compact digits, first 4 digits treated as year)
17
+ * When the first component of a separated date is 4 digits, it is treated as the year.
18
+ * Otherwise, MM/DD/YYYY ordering is assumed.
19
+ * @param {string} raw - Raw input in any supported format
20
+ * @returns {string} ISO date string (YYYY-MM-DD), or '' for non-string input
21
+ */
22
+ normalize(raw) {
23
+ if (typeof raw !== 'string') return '';
24
+ const trimmed = raw.trim();
25
+
26
+ // Already ISO YYYY-MM-DD
27
+ if (ISO_DATE_PATTERN.test(trimmed)) return trimmed;
28
+
29
+ // Date with separators: MM/DD/YYYY, DD/MM/YYYY, or YYYY/MM/DD
30
+ const sepMatch = trimmed.match(SEPARATED_DATE_PATTERN);
31
+ if (sepMatch) {
32
+ const [, a, b, c] = sepMatch;
33
+ if (a.length === 4) {
34
+ return `${a}-${b.padStart(2, '0')}-${c.padStart(2, '0')}`;
35
+ }
36
+ if (c.length === 4) {
37
+ // Default MM/DD/YYYY assumption
38
+ return `${c}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`;
39
+ }
40
+ }
41
+
42
+ // 8 pure digits: YYYYMMDD or MMDDYYYY
43
+ const digits = trimmed.replace(STRIP_NON_DIGITS, '');
44
+ if (digits.length === 8) {
45
+ const potentialYear = parseInt(digits.slice(0, 4), 10);
46
+ if (potentialYear >= 1000 && potentialYear <= 9999) {
47
+ return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
48
+ }
49
+ return `${digits.slice(4, 8)}-${digits.slice(0, 2)}-${digits.slice(2, 4)}`;
50
+ }
51
+
52
+ return trimmed;
53
+ },
54
+
55
+ /**
56
+ * Validates the input, accepting any format supported by normalize().
57
+ * Internally normalizes to ISO form first, then checks calendar validity.
58
+ * e.g. validate('04/13/2025') → true, validate('2025-02-29') → false (not a leap year)
59
+ * @param {string} value - Input in any format (ISO, MM/DD/YYYY, etc.)
60
+ * @returns {boolean}
61
+ */
62
+ validate(value) {
63
+ if (typeof value !== 'string') return false;
64
+ const iso = DateFormatter.normalize(value);
65
+ if (!ISO_DATE_PATTERN.test(iso)) return false;
66
+ const date = new Date(`${iso}T00:00:00Z`);
67
+ return !isNaN(date.getTime()) && date.toISOString().startsWith(iso);
68
+ },
69
+
70
+ /**
71
+ * Formats an ISO date string for display using Intl.DateTimeFormat.
72
+ * All Intl options are configurable; defaults produce MM/DD/YYYY in en-US.
73
+ * @param {string} value - Canonical ISO date string (YYYY-MM-DD) from normalize()
74
+ * @param {Object} [opts={}] - Options
75
+ * @param {string} [opts.locale='en-US'] - BCP 47 locale tag
76
+ * @param {string} [opts.timeZone='UTC'] - IANA time zone
77
+ * @param {string} [opts.year='numeric'] - Intl year format
78
+ * @param {string} [opts.month='2-digit'] - Intl month format
79
+ * @param {string} [opts.day='2-digit'] - Intl day format
80
+ * @returns {string} Formatted date string, or value as-is for invalid input
81
+ */
82
+ format(value, opts = {}) {
83
+ if (typeof value !== 'string') return '';
84
+ const date = new Date(`${value}T00:00:00Z`);
85
+ if (isNaN(date.getTime())) return value;
86
+ const locale = opts.locale || 'en-US';
87
+ try {
88
+ return new Intl.DateTimeFormat(locale, {
89
+ year: opts.year || 'numeric',
90
+ month: opts.month || '2-digit',
91
+ day: opts.day || '2-digit',
92
+ timeZone: opts.timeZone || 'UTC',
93
+ }).format(date);
94
+ } catch {
95
+ return value;
96
+ }
97
+ },
98
+ };