@stimulus-plumbers/controllers 0.2.8 → 0.3.0
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 +3 -0
- package/dist/stimulus-plumbers-controllers.es.js +450 -436
- package/dist/stimulus-plumbers-controllers.umd.js +1 -1
- package/package.json +1 -1
- package/src/controllers/calendar_month_controller.js +3 -1
- package/src/controllers/calendar_month_observer_controller.js +1 -1
- package/src/controllers/clipboard_controller.js +1 -1
- package/src/controllers/combobox_date_controller.js +2 -2
- package/src/controllers/combobox_dropdown_controller.js +25 -16
- package/src/controllers/combobox_time_controller.js +1 -1
- package/src/controllers/flipper_controller.js +1 -1
- package/src/controllers/input_combobox_controller.js +33 -24
- package/src/controllers/input_format_controller.js +18 -23
- package/src/controllers/input_search_controller.js +44 -0
- package/src/controllers/modal_controller.js +20 -19
- package/src/index.js +11 -4
- package/src/plumbers/calendar.js +1 -1
- package/src/plumbers/content_loader.js +9 -63
- package/src/plumbers/dismisser.js +3 -51
- package/src/plumbers/flipper.js +12 -120
- package/src/plumbers/formatter.js +65 -0
- package/src/plumbers/{input_format/formatters → formatters}/credit_card.js +1 -1
- package/src/plumbers/{input_format/formatters → formatters}/currency.js +1 -1
- package/src/plumbers/{input_format/formatters → formatters}/date.js +2 -2
- package/src/plumbers/{input_format/formatters → formatters}/phone.js +1 -1
- package/src/plumbers/{input_format/formatters → formatters}/plain.js +1 -1
- package/src/plumbers/{input_format/formatters → formatters}/time.js +2 -2
- package/src/plumbers/index.js +1 -2
- package/src/plumbers/plumber/config.js +6 -0
- package/src/plumbers/plumber/date.js +14 -0
- package/src/plumbers/plumber/geometry.js +36 -0
- package/src/plumbers/plumber/index.js +2 -1
- package/src/plumbers/plumber/window_observer.js +22 -0
- package/src/plumbers/shifter.js +8 -80
- package/src/plumbers/visibility.js +1 -1
- package/src/requestor.js +24 -0
- package/src/researcher.js +39 -0
- package/src/plumbers/combobox_dropdown.js +0 -60
- package/src/plumbers/input_format/index.js +0 -90
- package/src/plumbers/plumber/support.js +0 -101
- /package/src/{aria.js → accessibility/aria.js} +0 -0
- /package/src/{focus.js → accessibility/focus.js} +0 -0
- /package/src/{keyboard.js → accessibility/keyboard.js} +0 -0
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
|
-
import { FocusTrap } from '../focus';
|
|
3
|
-
import { announce } from '../aria';
|
|
2
|
+
import { FocusTrap } from '../accessibility/focus';
|
|
3
|
+
import { announce } from '../accessibility/aria';
|
|
4
4
|
import { attachDismisser } from '../plumbers';
|
|
5
5
|
|
|
6
|
-
export default class
|
|
6
|
+
export default class extends Controller {
|
|
7
7
|
static targets = ['modal', 'overlay'];
|
|
8
8
|
|
|
9
|
+
initialize() {
|
|
10
|
+
this.onCancel = this.close.bind(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
connect() {
|
|
10
14
|
if (!this.hasModalTarget) {
|
|
11
15
|
console.error('ModalController requires a modal target. Add data-modal-target="modal" to your element.');
|
|
12
|
-
return;
|
|
13
16
|
}
|
|
17
|
+
}
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
modalTargetConnected(modal) {
|
|
20
|
+
this.isNativeDialog = modal instanceof HTMLDialogElement;
|
|
17
21
|
if (this.isNativeDialog) {
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
modal.addEventListener('cancel', this.onCancel);
|
|
23
|
+
modal.addEventListener('click', this.onBackdropClick);
|
|
20
24
|
} else {
|
|
21
|
-
this.focusTrap = new FocusTrap(
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
this.focusTrap = new FocusTrap(modal, { escapeDeactivates: true });
|
|
26
|
+
attachDismisser(this, { element: modal });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
modalTargetDisconnected(modal) {
|
|
31
|
+
if (this.isNativeDialog) {
|
|
32
|
+
modal.removeEventListener('cancel', this.onCancel);
|
|
33
|
+
modal.removeEventListener('click', this.onBackdropClick);
|
|
26
34
|
}
|
|
27
35
|
}
|
|
28
36
|
|
|
@@ -30,13 +38,6 @@ export default class ModalController extends Controller {
|
|
|
30
38
|
this.close();
|
|
31
39
|
};
|
|
32
40
|
|
|
33
|
-
disconnect() {
|
|
34
|
-
if (this.isNativeDialog) {
|
|
35
|
-
this.modalTarget.removeEventListener('cancel', this.close);
|
|
36
|
-
this.modalTarget.removeEventListener('click', this.onBackdropClick);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
41
|
open(event) {
|
|
41
42
|
if (event) event.preventDefault();
|
|
42
43
|
if (!this.hasModalTarget) return;
|
package/src/index.js
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* Following WCAG 2.1+ and WAI-ARIA best practices
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
// Export utilities
|
|
9
|
-
export * from './focus.js';
|
|
10
|
-
export * from './keyboard.js';
|
|
11
|
-
export * from './aria.js';
|
|
8
|
+
// Export accessibility utilities
|
|
9
|
+
export * from './accessibility/focus.js';
|
|
10
|
+
export * from './accessibility/keyboard.js';
|
|
11
|
+
export * from './accessibility/aria.js';
|
|
12
|
+
|
|
13
|
+
// Export utilities
|
|
14
|
+
export { Requestor } from './requestor.js';
|
|
15
|
+
export { fuzzyMatcher, filterOptions } from './researcher.js';
|
|
16
|
+
|
|
17
|
+
export { Formatter, FORMATTER_TYPES } from './plumbers/formatter.js';
|
|
12
18
|
|
|
13
19
|
// Export Stimulus controllers
|
|
14
20
|
export { default as CalendarMonthController } from './controllers/calendar_month_controller.js';
|
|
@@ -21,6 +27,7 @@ export { default as DismisserController } from './controllers/dismisser_controll
|
|
|
21
27
|
export { default as FlipperController } from './controllers/flipper_controller.js';
|
|
22
28
|
export { default as InputComboboxController } from './controllers/input_combobox_controller.js';
|
|
23
29
|
export { default as InputFormatController } from './controllers/input_format_controller.js';
|
|
30
|
+
export { default as InputSearchController } from './controllers/input_search_controller.js';
|
|
24
31
|
export { default as ModalController } from './controllers/modal_controller.js';
|
|
25
32
|
export { default as PannerController } from './controllers/panner_controller.js';
|
|
26
33
|
export { default as PopoverController } from './controllers/popover_controller.js';
|
package/src/plumbers/calendar.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Plumber from './plumber';
|
|
2
|
-
import { tryParseDate } from './plumber/
|
|
2
|
+
import { tryParseDate } from './plumber/date';
|
|
3
|
+
import { Requestor } from '../requestor';
|
|
3
4
|
|
|
4
5
|
const defaultOptions = {
|
|
5
6
|
content: null,
|
|
@@ -7,23 +8,10 @@ const defaultOptions = {
|
|
|
7
8
|
reload: 'never',
|
|
8
9
|
stale: 3600,
|
|
9
10
|
onLoad: 'canLoad',
|
|
10
|
-
onLoading: 'contentLoading',
|
|
11
11
|
onLoaded: 'contentLoaded',
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
export class ContentLoader extends Plumber {
|
|
15
|
-
/**
|
|
16
|
-
* Creates a new ContentLoader plumber instance for async content loading.
|
|
17
|
-
* @param {Object} controller - Stimulus controller instance
|
|
18
|
-
* @param {Object} [options] - Configuration options
|
|
19
|
-
* @param {*} [options.content] - Initial content value
|
|
20
|
-
* @param {string} [options.url=''] - URL to fetch content from
|
|
21
|
-
* @param {string} [options.reload='never'] - Reload strategy ('never', 'always', or 'stale')
|
|
22
|
-
* @param {number} [options.stale=3600] - Seconds before content becomes stale
|
|
23
|
-
* @param {string} [options.onLoad='canLoad'] - Callback name to check if loadable
|
|
24
|
-
* @param {string} [options.onLoading='contentLoading'] - Callback name to load content
|
|
25
|
-
* @param {string} [options.onLoaded='contentLoaded'] - Callback name after loading
|
|
26
|
-
*/
|
|
27
15
|
constructor(controller, options = {}) {
|
|
28
16
|
super(controller, options);
|
|
29
17
|
|
|
@@ -34,18 +22,14 @@ export class ContentLoader extends Plumber {
|
|
|
34
22
|
this.reload = typeof reload === 'string' ? reload : defaultOptions.reload;
|
|
35
23
|
this.stale = typeof stale === 'number' ? stale : defaultOptions.stale;
|
|
36
24
|
|
|
37
|
-
const { onLoad,
|
|
25
|
+
const { onLoad, onLoaded } = config;
|
|
38
26
|
this.onLoad = onLoad;
|
|
39
|
-
this.onLoading = onLoading;
|
|
40
27
|
this.onLoaded = onLoaded;
|
|
41
28
|
|
|
29
|
+
this._requestor = new Requestor();
|
|
42
30
|
this.enhance();
|
|
43
31
|
}
|
|
44
32
|
|
|
45
|
-
/**
|
|
46
|
-
* Checks if content should be reloaded based on reload strategy.
|
|
47
|
-
* @returns {boolean} True if content should be reloaded
|
|
48
|
-
*/
|
|
49
33
|
get reloadable() {
|
|
50
34
|
switch (this.reload) {
|
|
51
35
|
case 'never':
|
|
@@ -59,47 +43,15 @@ export class ContentLoader extends Plumber {
|
|
|
59
43
|
}
|
|
60
44
|
}
|
|
61
45
|
|
|
62
|
-
/**
|
|
63
|
-
* Checks if content should be loaded based on URL presence.
|
|
64
|
-
* Override this method to provide custom loading conditions.
|
|
65
|
-
* @param {Object} params - Load parameters
|
|
66
|
-
* @param {string} params.url - URL to load from
|
|
67
|
-
* @returns {Promise<boolean>} True if content should be loaded
|
|
68
|
-
*/
|
|
69
46
|
contentLoadable = ({ url }) => !!url;
|
|
70
47
|
|
|
71
|
-
/**
|
|
72
|
-
* Loads content from remote or local source.
|
|
73
|
-
* Override this method to provide custom loading logic.
|
|
74
|
-
* @param {Object} params - Load parameters
|
|
75
|
-
* @param {string} params.url - URL to load from
|
|
76
|
-
* @returns {Promise<string>} Loaded content
|
|
77
|
-
*/
|
|
78
|
-
contentLoading = async ({ url }) => {
|
|
79
|
-
return url ? await this.remoteContentLoader(url) : await this.contentLoader();
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Provides local/static content when no URL is available.
|
|
84
|
-
* Override this method to provide static content.
|
|
85
|
-
* @returns {Promise<string>} Local content
|
|
86
|
-
*/
|
|
87
48
|
contentLoader = async () => '';
|
|
88
49
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
* @returns {Promise<string>} Fetched content
|
|
94
|
-
*/
|
|
95
|
-
remoteContentLoader = async (url) => (await fetch(url)).text();
|
|
50
|
+
remoteContentLoader = async (url) => {
|
|
51
|
+
const res = await this._requestor.request(url);
|
|
52
|
+
return res.text();
|
|
53
|
+
};
|
|
96
54
|
|
|
97
|
-
/**
|
|
98
|
-
* Loads content from remote or local source with lifecycle events.
|
|
99
|
-
* Checks if loadable via onLoad, fetches content via onLoading,
|
|
100
|
-
* and notifies via onLoaded callback.
|
|
101
|
-
* @returns {Promise<void>}
|
|
102
|
-
*/
|
|
103
55
|
load = async () => {
|
|
104
56
|
if (this.loadedAt && !this.reloadable) return;
|
|
105
57
|
|
|
@@ -108,8 +60,8 @@ export class ContentLoader extends Plumber {
|
|
|
108
60
|
this.dispatch('load', { detail: { url: this.url } });
|
|
109
61
|
if (!loadable) return;
|
|
110
62
|
|
|
111
|
-
const content = this.url ? await this.remoteContentLoader(this.url) : await this.contentLoader();
|
|
112
63
|
this.dispatch('loading', { detail: { url: this.url } });
|
|
64
|
+
const content = this.url ? await this.remoteContentLoader(this.url) : await this.contentLoader();
|
|
113
65
|
if (!content) return;
|
|
114
66
|
|
|
115
67
|
await this.awaitCallback(this.onLoaded, { url: this.url, content });
|
|
@@ -125,10 +77,4 @@ export class ContentLoader extends Plumber {
|
|
|
125
77
|
}
|
|
126
78
|
}
|
|
127
79
|
|
|
128
|
-
/**
|
|
129
|
-
* Factory function to create and attach a ContentLoader plumber to a controller.
|
|
130
|
-
* @param {Object} controller - Stimulus controller instance
|
|
131
|
-
* @param {Object} [options] - Configuration options
|
|
132
|
-
* @returns {ContentLoader} ContentLoader plumber instance
|
|
133
|
-
*/
|
|
134
80
|
export const attachContentLoader = (controller, options) => new ContentLoader(controller, options);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import WindowObserver from './plumber/window_observer';
|
|
2
2
|
|
|
3
3
|
const defaultOptions = {
|
|
4
4
|
trigger: null,
|
|
@@ -6,15 +6,7 @@ const defaultOptions = {
|
|
|
6
6
|
onDismissed: 'dismissed',
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
export class Dismisser extends
|
|
10
|
-
/**
|
|
11
|
-
* Creates a new Dismisser plumber instance for handling outside-click dismissals.
|
|
12
|
-
* @param {Object} controller - Stimulus controller instance
|
|
13
|
-
* @param {Object} [options] - Configuration options
|
|
14
|
-
* @param {HTMLElement} [options.trigger] - Trigger element (defaults to controller element)
|
|
15
|
-
* @param {string[]} [options.events=['click']] - Events to listen for dismissal
|
|
16
|
-
* @param {string} [options.onDismissed='dismissed'] - Callback name when dismissed
|
|
17
|
-
*/
|
|
9
|
+
export class Dismisser extends WindowObserver {
|
|
18
10
|
constructor(controller, options = {}) {
|
|
19
11
|
super(controller, options);
|
|
20
12
|
|
|
@@ -24,14 +16,9 @@ export class Dismisser extends Plumber {
|
|
|
24
16
|
this.events = events;
|
|
25
17
|
|
|
26
18
|
this.enhance();
|
|
27
|
-
this.observe();
|
|
19
|
+
this.observe(this.dismiss);
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
/**
|
|
31
|
-
* Handles dismissal when clicking outside the element.
|
|
32
|
-
* @param {Event} event - DOM event
|
|
33
|
-
* @returns {Promise<void>}
|
|
34
|
-
*/
|
|
35
22
|
dismiss = async (event) => {
|
|
36
23
|
const { target } = event;
|
|
37
24
|
if (!(target instanceof HTMLElement)) return;
|
|
@@ -42,41 +29,6 @@ export class Dismisser extends Plumber {
|
|
|
42
29
|
await this.awaitCallback(this.onDismissed, { target: this.trigger });
|
|
43
30
|
this.dispatch('dismissed');
|
|
44
31
|
};
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Starts observing configured events for dismissal.
|
|
48
|
-
*/
|
|
49
|
-
observe() {
|
|
50
|
-
this.events.forEach((event) => {
|
|
51
|
-
window.addEventListener(event, this.dismiss, true);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Stops observing events for dismissal.
|
|
57
|
-
*/
|
|
58
|
-
unobserve() {
|
|
59
|
-
this.events.forEach((event) => {
|
|
60
|
-
window.removeEventListener(event, this.dismiss, true);
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
enhance() {
|
|
65
|
-
const context = this;
|
|
66
|
-
const superDisconnect = context.controller.disconnect.bind(context.controller);
|
|
67
|
-
Object.assign(this.controller, {
|
|
68
|
-
disconnect: () => {
|
|
69
|
-
context.unobserve();
|
|
70
|
-
superDisconnect();
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
32
|
}
|
|
75
33
|
|
|
76
|
-
/**
|
|
77
|
-
* Factory function to create and attach a Dismisser plumber to a controller.
|
|
78
|
-
* @param {Object} controller - Stimulus controller instance
|
|
79
|
-
* @param {Object} [options] - Configuration options
|
|
80
|
-
* @returns {Dismisser} Dismisser plumber instance
|
|
81
|
-
*/
|
|
82
34
|
export const attachDismisser = (controller, options) => new Dismisser(controller, options);
|
package/src/plumbers/flipper.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { defineRect, viewportRect, directionMap } from './plumber/
|
|
3
|
-
import { connectTriggerToTarget } from '../aria';
|
|
1
|
+
import WindowObserver from './plumber/window_observer';
|
|
2
|
+
import { defineRect, viewportRect, directionMap } from './plumber/geometry';
|
|
3
|
+
import { connectTriggerToTarget } from '../accessibility/aria';
|
|
4
4
|
|
|
5
5
|
const defaultOptions = {
|
|
6
6
|
anchor: null,
|
|
@@ -12,19 +12,7 @@ const defaultOptions = {
|
|
|
12
12
|
respectMotion: true,
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
export class Flipper extends
|
|
16
|
-
/**
|
|
17
|
-
* Creates a new Flipper plumber instance for smart positioning relative to an anchor.
|
|
18
|
-
* @param {Object} controller - Stimulus controller instance
|
|
19
|
-
* @param {Object} [options] - Configuration options
|
|
20
|
-
* @param {HTMLElement} [options.anchor] - Anchor element for positioning
|
|
21
|
-
* @param {string[]} [options.events=['click']] - Events triggering flip calculation
|
|
22
|
-
* @param {string} [options.placement='bottom'] - Initial placement direction ('top', 'bottom', 'left', 'right')
|
|
23
|
-
* @param {string} [options.alignment='start'] - Alignment ('start', 'center', 'end')
|
|
24
|
-
* @param {string} [options.onFlipped='flipped'] - Callback name when flipped
|
|
25
|
-
* @param {string} [options.ariaRole=null] - ARIA role to set on element
|
|
26
|
-
* @param {boolean} [options.respectMotion=true] - Respect prefers-reduced-motion preference
|
|
27
|
-
*/
|
|
15
|
+
export class Flipper extends WindowObserver {
|
|
28
16
|
constructor(controller, options = {}) {
|
|
29
17
|
super(controller, options);
|
|
30
18
|
|
|
@@ -43,22 +31,13 @@ export class Flipper extends Plumber {
|
|
|
43
31
|
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
44
32
|
|
|
45
33
|
if (this.anchor && this.element) {
|
|
46
|
-
connectTriggerToTarget({
|
|
47
|
-
trigger: this.anchor,
|
|
48
|
-
target: this.element,
|
|
49
|
-
role: this.ariaRole,
|
|
50
|
-
});
|
|
34
|
+
connectTriggerToTarget({ trigger: this.anchor, target: this.element, role: this.ariaRole });
|
|
51
35
|
}
|
|
52
36
|
|
|
53
37
|
this.enhance();
|
|
54
|
-
this.observe();
|
|
38
|
+
this.observe(this.flip);
|
|
55
39
|
}
|
|
56
40
|
|
|
57
|
-
/**
|
|
58
|
-
* Attempts to place element in configured direction, flipping to opposite direction if needed.
|
|
59
|
-
* For example, if placement is 'bottom' but no space below anchor, flips to 'top'.
|
|
60
|
-
* @returns {Promise<void>}
|
|
61
|
-
*/
|
|
62
41
|
flip = async () => {
|
|
63
42
|
if (!this.visible) return;
|
|
64
43
|
|
|
@@ -68,7 +47,6 @@ export class Flipper extends Plumber {
|
|
|
68
47
|
|
|
69
48
|
const placement = this.flippedRect(this.anchor.getBoundingClientRect(), this.element.getBoundingClientRect());
|
|
70
49
|
|
|
71
|
-
// Disable transitions for users who prefer reduced motion
|
|
72
50
|
this.element.style.transition = this.respectMotion && this.prefersReducedMotion ? 'none' : '';
|
|
73
51
|
|
|
74
52
|
for (const [key, value] of Object.entries(placement)) {
|
|
@@ -79,12 +57,6 @@ export class Flipper extends Plumber {
|
|
|
79
57
|
this.dispatch('flipped', { detail: { placement } });
|
|
80
58
|
};
|
|
81
59
|
|
|
82
|
-
/**
|
|
83
|
-
* Determines the best position that fits within viewport boundaries.
|
|
84
|
-
* @param {DOMRect} anchorRect - Anchor element's bounding rect
|
|
85
|
-
* @param {DOMRect} referenceRect - Reference element's bounding rect
|
|
86
|
-
* @returns {Object} Position object with top and left styles
|
|
87
|
-
*/
|
|
88
60
|
flippedRect(anchorRect, referenceRect) {
|
|
89
61
|
const candidateRects = this.quadrumRect(anchorRect, viewportRect());
|
|
90
62
|
const candidates = [this.placement, directionMap[this.placement]];
|
|
@@ -92,7 +64,6 @@ export class Flipper extends Plumber {
|
|
|
92
64
|
while (!Object.keys(flipped).length && candidates.length > 0) {
|
|
93
65
|
const candidate = candidates.shift();
|
|
94
66
|
if (!this.biggerRectThan(candidateRects[candidate], referenceRect)) continue;
|
|
95
|
-
|
|
96
67
|
const placementRect = this.quadrumPlacement(anchorRect, candidate, referenceRect);
|
|
97
68
|
const alignmentRect = this.quadrumAlignment(anchorRect, candidate, placementRect);
|
|
98
69
|
flipped['top'] = `${alignmentRect['top'] + window.scrollY}px`;
|
|
@@ -105,32 +76,16 @@ export class Flipper extends Plumber {
|
|
|
105
76
|
return flipped;
|
|
106
77
|
}
|
|
107
78
|
|
|
108
|
-
/**
|
|
109
|
-
* Calculates available space in each direction around the inner rect within outer rect.
|
|
110
|
-
* @param {Object} inner - Inner rect object
|
|
111
|
-
* @param {Object} outer - Outer rect object
|
|
112
|
-
* @returns {Object} Rect objects for each direction (left, right, top, bottom)
|
|
113
|
-
*/
|
|
114
79
|
quadrumRect(inner, outer) {
|
|
115
80
|
return {
|
|
116
|
-
left: defineRect({
|
|
117
|
-
x: outer.x,
|
|
118
|
-
y: outer.y,
|
|
119
|
-
width: inner.x - outer.x,
|
|
120
|
-
height: outer.height,
|
|
121
|
-
}),
|
|
81
|
+
left: defineRect({ x: outer.x, y: outer.y, width: inner.x - outer.x, height: outer.height }),
|
|
122
82
|
right: defineRect({
|
|
123
83
|
x: inner.x + inner.width,
|
|
124
84
|
y: outer.y,
|
|
125
85
|
width: outer.width - (inner.x + inner.width),
|
|
126
86
|
height: outer.height,
|
|
127
87
|
}),
|
|
128
|
-
top: defineRect({
|
|
129
|
-
x: outer.x,
|
|
130
|
-
y: outer.y,
|
|
131
|
-
width: outer.width,
|
|
132
|
-
height: inner.y - outer.y,
|
|
133
|
-
}),
|
|
88
|
+
top: defineRect({ x: outer.x, y: outer.y, width: outer.width, height: inner.y - outer.y }),
|
|
134
89
|
bottom: defineRect({
|
|
135
90
|
x: outer.x,
|
|
136
91
|
y: inner.y + inner.height,
|
|
@@ -140,14 +95,6 @@ export class Flipper extends Plumber {
|
|
|
140
95
|
};
|
|
141
96
|
}
|
|
142
97
|
|
|
143
|
-
/**
|
|
144
|
-
* Calculates placement rect for reference element in given direction from anchor.
|
|
145
|
-
* @param {Object} anchor - Anchor rect object
|
|
146
|
-
* @param {string} direction - Direction ('top', 'bottom', 'left', 'right')
|
|
147
|
-
* @param {Object} reference - Reference rect object
|
|
148
|
-
* @returns {Object} Placed rect object
|
|
149
|
-
* @throws {string} If direction is invalid
|
|
150
|
-
*/
|
|
151
98
|
quadrumPlacement(anchor, direction, reference) {
|
|
152
99
|
switch (direction) {
|
|
153
100
|
case 'top':
|
|
@@ -183,14 +130,6 @@ export class Flipper extends Plumber {
|
|
|
183
130
|
}
|
|
184
131
|
}
|
|
185
132
|
|
|
186
|
-
/**
|
|
187
|
-
* Applies alignment adjustment to placed rect based on configuration.
|
|
188
|
-
* @param {Object} anchor - Anchor rect object
|
|
189
|
-
* @param {string} direction - Direction ('top', 'bottom', 'left', 'right')
|
|
190
|
-
* @param {Object} reference - Reference rect object
|
|
191
|
-
* @returns {Object} Aligned rect object
|
|
192
|
-
* @throws {string} If direction is invalid
|
|
193
|
-
*/
|
|
194
133
|
quadrumAlignment(anchor, direction, reference) {
|
|
195
134
|
switch (direction) {
|
|
196
135
|
case 'top':
|
|
@@ -198,75 +137,28 @@ export class Flipper extends Plumber {
|
|
|
198
137
|
let alignment = anchor.x;
|
|
199
138
|
if (this.alignment === 'center') alignment = anchor.x + anchor.width / 2 - reference.width / 2;
|
|
200
139
|
else if (this.alignment === 'end') alignment = anchor.x + anchor.width - reference.width;
|
|
201
|
-
return defineRect({
|
|
202
|
-
x: alignment,
|
|
203
|
-
y: reference.y,
|
|
204
|
-
width: reference.width,
|
|
205
|
-
height: reference.height,
|
|
206
|
-
});
|
|
140
|
+
return defineRect({ x: alignment, y: reference.y, width: reference.width, height: reference.height });
|
|
207
141
|
}
|
|
208
142
|
case 'left':
|
|
209
143
|
case 'right': {
|
|
210
144
|
let alignment = anchor.y;
|
|
211
145
|
if (this.alignment === 'center') alignment = anchor.y + anchor.height / 2 - reference.height / 2;
|
|
212
146
|
else if (this.alignment === 'end') alignment = anchor.y + anchor.height - reference.height;
|
|
213
|
-
return defineRect({
|
|
214
|
-
x: reference.x,
|
|
215
|
-
y: alignment,
|
|
216
|
-
width: reference.width,
|
|
217
|
-
height: reference.height,
|
|
218
|
-
});
|
|
147
|
+
return defineRect({ x: reference.x, y: alignment, width: reference.width, height: reference.height });
|
|
219
148
|
}
|
|
220
149
|
default:
|
|
221
150
|
throw `Unable align at the quadrum, ${direction}`;
|
|
222
151
|
}
|
|
223
152
|
}
|
|
224
153
|
|
|
225
|
-
/**
|
|
226
|
-
* Checks if the big rect can contain the small rect dimensions.
|
|
227
|
-
* @param {Object} big - Larger rect object
|
|
228
|
-
* @param {Object} small - Smaller rect object
|
|
229
|
-
* @returns {boolean} True if big rect can contain small rect
|
|
230
|
-
*/
|
|
231
154
|
biggerRectThan(big, small) {
|
|
232
155
|
return big.height >= small.height && big.width >= small.width;
|
|
233
156
|
}
|
|
234
157
|
|
|
235
|
-
/**
|
|
236
|
-
* Starts observing configured events for flipping.
|
|
237
|
-
*/
|
|
238
|
-
observe() {
|
|
239
|
-
this.events.forEach((event) => {
|
|
240
|
-
window.addEventListener(event, this.flip, true);
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Stops observing events for flipping.
|
|
246
|
-
*/
|
|
247
|
-
unobserve() {
|
|
248
|
-
this.events.forEach((event) => {
|
|
249
|
-
window.removeEventListener(event, this.flip, true);
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
158
|
enhance() {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
Object.assign(this.controller, {
|
|
257
|
-
disconnect: () => {
|
|
258
|
-
context.unobserve();
|
|
259
|
-
superDisconnect();
|
|
260
|
-
},
|
|
261
|
-
flip: context.flip.bind(context),
|
|
262
|
-
});
|
|
159
|
+
super.enhance();
|
|
160
|
+
this.controller.flip = this.flip;
|
|
263
161
|
}
|
|
264
162
|
}
|
|
265
163
|
|
|
266
|
-
/**
|
|
267
|
-
* Factory function to create and attach a Flipper plumber to a controller.
|
|
268
|
-
* @param {Object} controller - Stimulus controller instance
|
|
269
|
-
* @param {Object} [options] - Configuration options
|
|
270
|
-
* @returns {Flipper} Flipper plumber instance
|
|
271
|
-
*/
|
|
272
164
|
export const attachFlipper = (controller, options) => new Flipper(controller, options);
|
|
@@ -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);
|
|
@@ -32,7 +32,7 @@ const VALID_CARD_LENGTH = /^\d{13,19}$/;
|
|
|
32
32
|
/** Captures groups of up to 4 characters followed by at least one more character */
|
|
33
33
|
const GROUP_FOUR_DIGITS = /(.{4})(?=.)/g;
|
|
34
34
|
|
|
35
|
-
export const
|
|
35
|
+
export const CreditCardFormatter = {
|
|
36
36
|
/**
|
|
37
37
|
* Converts raw input to the canonical stored form: digits only, no separators.
|
|
38
38
|
* e.g. '4242 4242 4242 4242' → '4242424242424242'
|
|
@@ -4,7 +4,7 @@ const STRIP_NON_NUMERIC = /[^\d.,-]/g;
|
|
|
4
4
|
/** Matches a valid canonical amount: optional negative sign, digits, optional decimal part */
|
|
5
5
|
const VALID_AMOUNT_PATTERN = /^-?\d+(\.\d+)?$/;
|
|
6
6
|
|
|
7
|
-
export const
|
|
7
|
+
export const CurrencyFormatter = {
|
|
8
8
|
/**
|
|
9
9
|
* Converts raw input to the canonical stored form: a plain decimal number string.
|
|
10
10
|
* Handles US format (1,234.56), European format (1.234,56), and integers ($1,000).
|
|
@@ -7,7 +7,7 @@ const SEPARATED_DATE_PATTERN = /^(\d{1,4})[/\-.](\d{1,2})[/\-.](\d{1,4})$/;
|
|
|
7
7
|
/** Strips all non-digit characters (used when extracting 8-digit compact dates) */
|
|
8
8
|
const STRIP_NON_DIGITS = /\D/g;
|
|
9
9
|
|
|
10
|
-
export const
|
|
10
|
+
export const DateFormatter = {
|
|
11
11
|
/**
|
|
12
12
|
* Converts raw input to the canonical stored form: ISO 8601 YYYY-MM-DD.
|
|
13
13
|
* Accepts a variety of common date formats:
|
|
@@ -61,7 +61,7 @@ export const DateInputFormatter = {
|
|
|
61
61
|
*/
|
|
62
62
|
validate(value) {
|
|
63
63
|
if (typeof value !== 'string') return false;
|
|
64
|
-
const iso =
|
|
64
|
+
const iso = DateFormatter.normalize(value);
|
|
65
65
|
if (!ISO_DATE_PATTERN.test(iso)) return false;
|
|
66
66
|
const date = new Date(`${iso}T00:00:00Z`);
|
|
67
67
|
return !isNaN(date.getTime()) && date.toISOString().startsWith(iso);
|
|
@@ -18,7 +18,7 @@ const STRIP_NON_DIGITS = /\D/g;
|
|
|
18
18
|
/** Matches an E.164 international phone number: + followed by 7–15 digits */
|
|
19
19
|
const E164_PATTERN = /^\+\d{7,15}$/;
|
|
20
20
|
|
|
21
|
-
export const
|
|
21
|
+
export const PhoneFormatter = {
|
|
22
22
|
/**
|
|
23
23
|
* Converts raw input to canonical form.
|
|
24
24
|
* If input starts with '+', produces E.164 (+digits); otherwise strips to digits only.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Matches a 24-hour time: HH:MM */
|
|
2
2
|
const H24_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
3
3
|
|
|
4
|
-
export const
|
|
4
|
+
export const TimeFormatter = {
|
|
5
5
|
/**
|
|
6
6
|
* Converts raw input to canonical 24-hour form: HH:MM.
|
|
7
7
|
* Accepts HH:MM (24h) and h:mm AM/PM (12h).
|
|
@@ -38,7 +38,7 @@ export const TimeInputFormatter = {
|
|
|
38
38
|
* @returns {boolean}
|
|
39
39
|
*/
|
|
40
40
|
validate(value) {
|
|
41
|
-
return
|
|
41
|
+
return TimeFormatter.normalize(value) !== '';
|
|
42
42
|
},
|
|
43
43
|
|
|
44
44
|
/**
|