@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.
- package/README.md +81 -59
- package/dist/stimulus-plumbers-controllers.es.js +760 -237
- 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} +18 -32
- package/src/controllers/combobox_dropdown_controller.js +88 -0
- package/src/controllers/combobox_time_controller.js +56 -0
- package/src/controllers/flipper_controller.js +1 -1
- package/src/controllers/input_combobox_controller.js +69 -0
- package/src/controllers/input_format_controller.js +94 -0
- package/src/controllers/input_search_controller.js +44 -0
- package/src/controllers/modal_controller.js +19 -23
- package/src/controllers/popover_controller.js +1 -1
- package/src/index.js +13 -5
- package/src/plumbers/combobox_dropdown.js +60 -0
- package/src/plumbers/content_loader.js +2 -2
- package/src/plumbers/formatter.js +65 -0
- package/src/plumbers/formatters/credit_card.js +69 -0
- package/src/plumbers/formatters/currency.js +87 -0
- package/src/plumbers/formatters/date.js +98 -0
- package/src/plumbers/formatters/phone.js +73 -0
- package/src/plumbers/formatters/plain.js +10 -0
- package/src/plumbers/formatters/time.js +62 -0
- package/src/plumbers/index.js +2 -0
|
@@ -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
|
-
|
|
21
|
-
|
|
19
|
+
modalTargetConnected(modal) {
|
|
20
|
+
this.isNativeDialog = modal instanceof HTMLDialogElement;
|
|
22
21
|
if (this.isNativeDialog) {
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
modal.addEventListener('cancel', this.onCancel);
|
|
23
|
+
modal.addEventListener('click', this.onBackdropClick);
|
|
25
24
|
} else {
|
|
26
|
-
this.focusTrap = new FocusTrap(
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
this.focusTrap = new FocusTrap(modal, { escapeDeactivates: true });
|
|
26
|
+
attachDismisser(this, { element: modal });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
29
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
*/
|
|
@@ -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
|
+
};
|