@stimulus-plumbers/controllers 0.2.6 → 0.2.8
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.
- package/README.md +78 -59
- package/dist/stimulus-plumbers-controllers.es.js +734 -248
- package/dist/stimulus-plumbers-controllers.umd.js +1 -1
- package/package.json +1 -1
- package/src/controllers/calendar_month_observer_controller.js +9 -6
- package/src/controllers/clipboard_controller.js +27 -0
- package/src/controllers/{datepicker_controller.js → combobox_date_controller.js} +19 -33
- package/src/controllers/combobox_dropdown_controller.js +88 -0
- package/src/controllers/combobox_time_controller.js +56 -0
- package/src/controllers/input_combobox_controller.js +60 -0
- package/src/controllers/input_format_controller.js +99 -0
- package/src/controllers/modal_controller.js +3 -8
- package/src/controllers/popover_controller.js +1 -1
- package/src/index.js +10 -5
- package/src/plumbers/combobox_dropdown.js +60 -0
- package/src/plumbers/content_loader.js +2 -2
- package/src/plumbers/index.js +2 -0
- package/src/plumbers/input_format/formatters/credit_card.js +69 -0
- package/src/plumbers/input_format/formatters/currency.js +87 -0
- package/src/plumbers/input_format/formatters/date.js +98 -0
- package/src/plumbers/input_format/formatters/phone.js +73 -0
- package/src/plumbers/input_format/formatters/plain.js +10 -0
- package/src/plumbers/input_format/formatters/time.js +62 -0
- package/src/plumbers/input_format/index.js +90 -0
|
@@ -3,11 +3,6 @@ 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
6
|
export default class ModalController extends Controller {
|
|
12
7
|
static targets = ['modal', 'overlay'];
|
|
13
8
|
|
|
@@ -21,7 +16,7 @@ export default class ModalController extends Controller {
|
|
|
21
16
|
|
|
22
17
|
if (this.isNativeDialog) {
|
|
23
18
|
this.modalTarget.addEventListener('cancel', this.close);
|
|
24
|
-
this.modalTarget.addEventListener('click', this.
|
|
19
|
+
this.modalTarget.addEventListener('click', this.onBackdropClick);
|
|
25
20
|
} else {
|
|
26
21
|
this.focusTrap = new FocusTrap(this.modalTarget, {
|
|
27
22
|
escapeDeactivates: true,
|
|
@@ -38,7 +33,7 @@ export default class ModalController extends Controller {
|
|
|
38
33
|
disconnect() {
|
|
39
34
|
if (this.isNativeDialog) {
|
|
40
35
|
this.modalTarget.removeEventListener('cancel', this.close);
|
|
41
|
-
this.modalTarget.removeEventListener('click', this.
|
|
36
|
+
this.modalTarget.removeEventListener('click', this.onBackdropClick);
|
|
42
37
|
}
|
|
43
38
|
}
|
|
44
39
|
|
|
@@ -89,7 +84,7 @@ export default class ModalController extends Controller {
|
|
|
89
84
|
announce('Modal closed');
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
|
|
87
|
+
onBackdropClick = (event) => {
|
|
93
88
|
const rect = this.modalTarget.getBoundingClientRect();
|
|
94
89
|
const isOutsideDialog =
|
|
95
90
|
event.clientY < rect.top ||
|
|
@@ -39,7 +39,7 @@ export default class extends Controller {
|
|
|
39
39
|
await this.load();
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
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
|
@@ -11,11 +11,16 @@ export * from './keyboard.js';
|
|
|
11
11
|
export * from './aria.js';
|
|
12
12
|
|
|
13
13
|
// 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
14
|
export { default as CalendarMonthController } from './controllers/calendar_month_controller.js';
|
|
19
15
|
export { default as CalendarMonthObserverController } from './controllers/calendar_month_observer_controller.js';
|
|
20
|
-
export { default as
|
|
16
|
+
export { default as ClipboardController } from './controllers/clipboard_controller.js';
|
|
17
|
+
export { default as ComboboxDateController } from './controllers/combobox_date_controller.js';
|
|
18
|
+
export { default as ComboboxDropdownController } from './controllers/combobox_dropdown_controller.js';
|
|
19
|
+
export { default as ComboboxTimeController } from './controllers/combobox_time_controller.js';
|
|
20
|
+
export { default as DismisserController } from './controllers/dismisser_controller.js';
|
|
21
|
+
export { default as FlipperController } from './controllers/flipper_controller.js';
|
|
22
|
+
export { default as InputComboboxController } from './controllers/input_combobox_controller.js';
|
|
23
|
+
export { default as InputFormatController } from './controllers/input_format_controller.js';
|
|
24
|
+
export { default as ModalController } from './controllers/modal_controller.js';
|
|
21
25
|
export { default as PannerController } from './controllers/panner_controller.js';
|
|
26
|
+
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: '
|
|
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='
|
|
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
|
*/
|
package/src/plumbers/index.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { initCalendar } from './calendar';
|
|
6
|
+
export { initComboboxDropdown } from './combobox_dropdown';
|
|
6
7
|
export { attachContentLoader } from './content_loader';
|
|
7
8
|
export { attachDismisser } from './dismisser';
|
|
8
9
|
export { attachFlipper } from './flipper';
|
|
10
|
+
export { attachInputFormat } from './input_format';
|
|
9
11
|
export { attachShifter } from './shifter';
|
|
10
12
|
export { attachVisibility } from './visibility';
|
|
@@ -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 CreditCardInputFormatter = {
|
|
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 CurrencyInputFormatter = {
|
|
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 DateInputFormatter = {
|
|
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 = DateInputFormatter.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
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map of E.164 country calling code → expected local digit count.
|
|
3
|
+
* Key: calling code string (without '+').
|
|
4
|
+
* Value: local number digit count (excluding the country code).
|
|
5
|
+
*
|
|
6
|
+
* Example: '1' → 10 means USA/Canada use a 10-digit local number
|
|
7
|
+
* (3-digit area code + 7-digit subscriber number).
|
|
8
|
+
*
|
|
9
|
+
* Extend this object to add formatting support for additional countries.
|
|
10
|
+
*/
|
|
11
|
+
const DIALING_CODE_LOCAL_DIGITS = {
|
|
12
|
+
1: 10, // USA, Canada, and NANP countries
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Strips all non-digit characters */
|
|
16
|
+
const STRIP_NON_DIGITS = /\D/g;
|
|
17
|
+
|
|
18
|
+
/** Matches an E.164 international phone number: + followed by 7–15 digits */
|
|
19
|
+
const E164_PATTERN = /^\+\d{7,15}$/;
|
|
20
|
+
|
|
21
|
+
export const PhoneInputFormatter = {
|
|
22
|
+
/**
|
|
23
|
+
* Converts raw input to canonical form.
|
|
24
|
+
* If input starts with '+', produces E.164 (+digits); otherwise strips to digits only.
|
|
25
|
+
* e.g. '(555) 123-4567' → '5551234567', '+1 555 123 4567' → '+15551234567'
|
|
26
|
+
* @param {string} raw - Raw input (may contain spaces, dashes, parentheses, etc.)
|
|
27
|
+
* @returns {string} Canonical string, or '' for non-string input
|
|
28
|
+
*/
|
|
29
|
+
normalize(raw) {
|
|
30
|
+
if (typeof raw !== 'string') return '';
|
|
31
|
+
const hasPlus = raw.trimStart().startsWith('+');
|
|
32
|
+
const digits = raw.replace(STRIP_NON_DIGITS, '');
|
|
33
|
+
return hasPlus ? `+${digits}` : digits;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validates the canonical phone number.
|
|
38
|
+
* Accepts E.164 format or a local number whose digit count appears in DIALING_CODE_LOCAL_DIGITS.
|
|
39
|
+
* @param {string} value - Canonical value from normalize()
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
validate(value) {
|
|
43
|
+
if (typeof value !== 'string') return false;
|
|
44
|
+
if (E164_PATTERN.test(value)) return true;
|
|
45
|
+
const digits = value.replace(STRIP_NON_DIGITS, '');
|
|
46
|
+
return Object.values(DIALING_CODE_LOCAL_DIGITS).includes(digits.length);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Formats a canonical phone number for display.
|
|
51
|
+
* Recognises numbers matching entries in DIALING_CODE_LOCAL_DIGITS.
|
|
52
|
+
* e.g. '5551234567' → '(555) 123-4567', '+15551234567' → '+1 (555) 123-4567'
|
|
53
|
+
* Returns value unchanged for unrecognised international numbers.
|
|
54
|
+
* @param {string} value - Canonical value from normalize()
|
|
55
|
+
* @returns {string} Formatted display string, or '' for non-string input
|
|
56
|
+
*/
|
|
57
|
+
format(value) {
|
|
58
|
+
if (typeof value !== 'string') return '';
|
|
59
|
+
const digits = value.replace(STRIP_NON_DIGITS, '');
|
|
60
|
+
for (const [code, localDigits] of Object.entries(DIALING_CODE_LOCAL_DIGITS)) {
|
|
61
|
+
if (digits.length === localDigits) {
|
|
62
|
+
// Local number without country code prefix
|
|
63
|
+
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
|
64
|
+
}
|
|
65
|
+
const totalWithCode = localDigits + code.length;
|
|
66
|
+
if (digits.length === totalWithCode && digits.startsWith(code)) {
|
|
67
|
+
const local = digits.slice(code.length);
|
|
68
|
+
return `+${code} (${local.slice(0, 3)}) ${local.slice(3, 6)}-${local.slice(6)}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Matches a 24-hour time: HH:MM */
|
|
2
|
+
const H24_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
3
|
+
|
|
4
|
+
export const TimeInputFormatter = {
|
|
5
|
+
/**
|
|
6
|
+
* Converts raw input to canonical 24-hour form: HH:MM.
|
|
7
|
+
* Accepts HH:MM (24h) and h:mm AM/PM (12h).
|
|
8
|
+
* @param {string} raw
|
|
9
|
+
* @returns {string} "HH:MM" or "" for invalid input
|
|
10
|
+
*/
|
|
11
|
+
normalize(raw) {
|
|
12
|
+
if (typeof raw !== 'string') return '';
|
|
13
|
+
const trimmed = raw.trim();
|
|
14
|
+
|
|
15
|
+
if (H24_PATTERN.test(trimmed)) {
|
|
16
|
+
const [h, m] = trimmed.split(':');
|
|
17
|
+
return `${String(parseInt(h, 10)).padStart(2, '0')}:${m}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// h:mm AM/PM
|
|
21
|
+
const ampm = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
|
22
|
+
if (ampm) {
|
|
23
|
+
let h = parseInt(ampm[1], 10);
|
|
24
|
+
const m = ampm[2];
|
|
25
|
+
const period = ampm[3].toUpperCase();
|
|
26
|
+
if (period === 'AM') h = h === 12 ? 0 : h;
|
|
27
|
+
else h = h === 12 ? 12 : h + 12;
|
|
28
|
+
if (h > 23 || parseInt(m, 10) > 59) return '';
|
|
29
|
+
return `${String(h).padStart(2, '0')}:${m}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return '';
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validates that the value is a parseable time.
|
|
37
|
+
* @param {string} value
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
validate(value) {
|
|
41
|
+
return TimeInputFormatter.normalize(value) !== '';
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Formats a canonical HH:MM value for display.
|
|
46
|
+
* @param {string} value - Canonical "HH:MM" from normalize()
|
|
47
|
+
* @param {Object} [opts={}]
|
|
48
|
+
* @param {string} [opts.format='h12'] - 'h12' or 'h24'
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
format(value, opts = {}) {
|
|
52
|
+
if (typeof value !== 'string') return '';
|
|
53
|
+
const match = value.match(/^(\d{2}):(\d{2})$/);
|
|
54
|
+
if (!match) return value;
|
|
55
|
+
const h = parseInt(match[1], 10);
|
|
56
|
+
const m = match[2];
|
|
57
|
+
if (opts.format === 'h24') return `${String(h).padStart(2, '0')}:${m}`;
|
|
58
|
+
const period = h < 12 ? 'AM' : 'PM';
|
|
59
|
+
const displayH = h % 12 || 12;
|
|
60
|
+
return `${displayH}:${m} ${period}`;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Plumber from '../plumber';
|
|
2
|
+
import { PlainInputFormatter } from './formatters/plain';
|
|
3
|
+
import { CreditCardInputFormatter } from './formatters/credit_card';
|
|
4
|
+
import { PhoneInputFormatter } from './formatters/phone';
|
|
5
|
+
import { CurrencyInputFormatter } from './formatters/currency';
|
|
6
|
+
import { DateInputFormatter } from './formatters/date';
|
|
7
|
+
import { TimeInputFormatter } from './formatters/time';
|
|
8
|
+
|
|
9
|
+
export { PlainInputFormatter } from './formatters/plain';
|
|
10
|
+
export { CreditCardInputFormatter } from './formatters/credit_card';
|
|
11
|
+
export { PhoneInputFormatter } from './formatters/phone';
|
|
12
|
+
export { CurrencyInputFormatter } from './formatters/currency';
|
|
13
|
+
export { DateInputFormatter } from './formatters/date';
|
|
14
|
+
export { TimeInputFormatter } from './formatters/time';
|
|
15
|
+
|
|
16
|
+
export const FORMATTER_TYPES = {
|
|
17
|
+
PLAIN: 'plain',
|
|
18
|
+
CREDIT_CARD: 'creditCard',
|
|
19
|
+
PHONE: 'phone',
|
|
20
|
+
CURRENCY: 'currency',
|
|
21
|
+
DATE: 'date',
|
|
22
|
+
TIME: 'time',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const registry = new Map([
|
|
26
|
+
[FORMATTER_TYPES.PLAIN, PlainInputFormatter],
|
|
27
|
+
[FORMATTER_TYPES.CREDIT_CARD, CreditCardInputFormatter],
|
|
28
|
+
[FORMATTER_TYPES.PHONE, PhoneInputFormatter],
|
|
29
|
+
[FORMATTER_TYPES.CURRENCY, CurrencyInputFormatter],
|
|
30
|
+
[FORMATTER_TYPES.DATE, DateInputFormatter],
|
|
31
|
+
[FORMATTER_TYPES.TIME, TimeInputFormatter],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const defaultOptions = {
|
|
35
|
+
type: FORMATTER_TYPES.PLAIN,
|
|
36
|
+
options: {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export class InputFormat extends Plumber {
|
|
40
|
+
/**
|
|
41
|
+
* Registers a custom input formatter for a given type identifier.
|
|
42
|
+
* @param {string} type - The type identifier (e.g. 'iban')
|
|
43
|
+
* @param {Object} formatter - Object with normalize, validate, and optionally format/mask methods
|
|
44
|
+
*/
|
|
45
|
+
static register(type, formatter) {
|
|
46
|
+
registry.set(type, formatter);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new InputFormat plumber instance.
|
|
51
|
+
* @param {Object} controller - Stimulus controller instance
|
|
52
|
+
* @param {Object} [options] - Configuration options
|
|
53
|
+
* @param {string} [options.type='plain'] - Formatter type identifier
|
|
54
|
+
* @param {Object} [options.options={}] - Type-specific options (e.g. locale, currency)
|
|
55
|
+
*/
|
|
56
|
+
constructor(controller, options = {}) {
|
|
57
|
+
super(controller, options);
|
|
58
|
+
this.type = options.type ?? defaultOptions.type;
|
|
59
|
+
this.options = options.options ?? defaultOptions.options;
|
|
60
|
+
this.enhance();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
enhance() {
|
|
64
|
+
const context = this;
|
|
65
|
+
const formatter = registry.get(context.type) ?? registry.get(FORMATTER_TYPES.PLAIN);
|
|
66
|
+
|
|
67
|
+
const helpers = {
|
|
68
|
+
normalize: (raw) => formatter.normalize?.(raw, context.options) ?? (typeof raw === 'string' ? raw : ''),
|
|
69
|
+
validate: (value) => formatter.validate?.(value, context.options) ?? true,
|
|
70
|
+
format: (value) => formatter.format?.(value, context.options) ?? (typeof value === 'string' ? value : ''),
|
|
71
|
+
mask: (value) => formatter.mask?.(value, context.options) ?? null,
|
|
72
|
+
maskable: () => typeof formatter.mask === 'function',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
Object.defineProperty(this.controller, 'inputFormat', {
|
|
76
|
+
get() {
|
|
77
|
+
return helpers;
|
|
78
|
+
},
|
|
79
|
+
configurable: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Factory function to create and attach an InputFormat plumber to a controller.
|
|
86
|
+
* @param {Object} controller - Stimulus controller instance
|
|
87
|
+
* @param {Object} [options] - Configuration options
|
|
88
|
+
* @returns {InputFormat} InputFormat plumber instance
|
|
89
|
+
*/
|
|
90
|
+
export const attachInputFormat = (controller, options) => new InputFormat(controller, options);
|