@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.
Files changed (43) hide show
  1. package/README.md +3 -0
  2. package/dist/stimulus-plumbers-controllers.es.js +450 -436
  3. package/dist/stimulus-plumbers-controllers.umd.js +1 -1
  4. package/package.json +1 -1
  5. package/src/controllers/calendar_month_controller.js +3 -1
  6. package/src/controllers/calendar_month_observer_controller.js +1 -1
  7. package/src/controllers/clipboard_controller.js +1 -1
  8. package/src/controllers/combobox_date_controller.js +2 -2
  9. package/src/controllers/combobox_dropdown_controller.js +25 -16
  10. package/src/controllers/combobox_time_controller.js +1 -1
  11. package/src/controllers/flipper_controller.js +1 -1
  12. package/src/controllers/input_combobox_controller.js +33 -24
  13. package/src/controllers/input_format_controller.js +18 -23
  14. package/src/controllers/input_search_controller.js +44 -0
  15. package/src/controllers/modal_controller.js +20 -19
  16. package/src/index.js +11 -4
  17. package/src/plumbers/calendar.js +1 -1
  18. package/src/plumbers/content_loader.js +9 -63
  19. package/src/plumbers/dismisser.js +3 -51
  20. package/src/plumbers/flipper.js +12 -120
  21. package/src/plumbers/formatter.js +65 -0
  22. package/src/plumbers/{input_format/formatters → formatters}/credit_card.js +1 -1
  23. package/src/plumbers/{input_format/formatters → formatters}/currency.js +1 -1
  24. package/src/plumbers/{input_format/formatters → formatters}/date.js +2 -2
  25. package/src/plumbers/{input_format/formatters → formatters}/phone.js +1 -1
  26. package/src/plumbers/{input_format/formatters → formatters}/plain.js +1 -1
  27. package/src/plumbers/{input_format/formatters → formatters}/time.js +2 -2
  28. package/src/plumbers/index.js +1 -2
  29. package/src/plumbers/plumber/config.js +6 -0
  30. package/src/plumbers/plumber/date.js +14 -0
  31. package/src/plumbers/plumber/geometry.js +36 -0
  32. package/src/plumbers/plumber/index.js +2 -1
  33. package/src/plumbers/plumber/window_observer.js +22 -0
  34. package/src/plumbers/shifter.js +8 -80
  35. package/src/plumbers/visibility.js +1 -1
  36. package/src/requestor.js +24 -0
  37. package/src/researcher.js +39 -0
  38. package/src/plumbers/combobox_dropdown.js +0 -60
  39. package/src/plumbers/input_format/index.js +0 -90
  40. package/src/plumbers/plumber/support.js +0 -101
  41. /package/src/{aria.js → accessibility/aria.js} +0 -0
  42. /package/src/{focus.js → accessibility/focus.js} +0 -0
  43. /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 ModalController extends Controller {
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
- this.isNativeDialog = this.modalTarget instanceof HTMLDialogElement;
16
-
19
+ modalTargetConnected(modal) {
20
+ this.isNativeDialog = modal instanceof HTMLDialogElement;
17
21
  if (this.isNativeDialog) {
18
- this.modalTarget.addEventListener('cancel', this.close);
19
- this.modalTarget.addEventListener('click', this.onBackdropClick);
22
+ modal.addEventListener('cancel', this.onCancel);
23
+ modal.addEventListener('click', this.onBackdropClick);
20
24
  } else {
21
- this.focusTrap = new FocusTrap(this.modalTarget, {
22
- escapeDeactivates: true,
23
- });
25
+ this.focusTrap = new FocusTrap(modal, { escapeDeactivates: true });
26
+ attachDismisser(this, { element: modal });
27
+ }
28
+ }
24
29
 
25
- attachDismisser(this, { element: this.modalTarget });
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 (framework-agnostic)
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';
@@ -1,5 +1,5 @@
1
1
  import Plumber from './plumber';
2
- import { isValidDate, tryParseDate } from './plumber/support';
2
+ import { isValidDate, tryParseDate } from './plumber/date';
3
3
 
4
4
  const DAYS_OF_WEEK = 7;
5
5
 
@@ -1,5 +1,6 @@
1
1
  import Plumber from './plumber';
2
- import { tryParseDate } from './plumber/support';
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, onLoading, onLoaded } = config;
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
- * Fetches content from a remote URL.
91
- * Override this method to customize remote loading.
92
- * @param {string} url - URL to fetch from
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 Plumber from './plumber';
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 Plumber {
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);
@@ -1,6 +1,6 @@
1
- import Plumber from './plumber';
2
- import { defineRect, viewportRect, directionMap } from './plumber/support';
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 Plumber {
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
- const context = this;
255
- const superDisconnect = context.controller.disconnect.bind(context.controller);
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 CreditCardInputFormatter = {
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 CurrencyInputFormatter = {
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 DateInputFormatter = {
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 = DateInputFormatter.normalize(value);
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 PhoneInputFormatter = {
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,4 +1,4 @@
1
- export const PlainInputFormatter = {
1
+ export const PlainFormatter = {
2
2
  normalize(raw) {
3
3
  if (typeof raw !== 'string') return '';
4
4
  return raw;
@@ -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 TimeInputFormatter = {
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 TimeInputFormatter.normalize(value) !== '';
41
+ return TimeFormatter.normalize(value) !== '';
42
42
  },
43
43
 
44
44
  /**