@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
@@ -3,10 +3,9 @@
3
3
  */
4
4
 
5
5
  export { initCalendar } from './calendar';
6
- export { initComboboxDropdown } from './combobox_dropdown';
7
6
  export { attachContentLoader } from './content_loader';
8
7
  export { attachDismisser } from './dismisser';
9
8
  export { attachFlipper } from './flipper';
10
- export { attachInputFormat } from './input_format';
9
+ export { attachFormatter } from './formatter';
11
10
  export { attachShifter } from './shifter';
12
11
  export { attachVisibility } from './visibility';
@@ -0,0 +1,6 @@
1
+ export const visibilityConfig = {
2
+ get visibleOnly() {
3
+ return true;
4
+ },
5
+ hiddenClass: null,
6
+ };
@@ -0,0 +1,14 @@
1
+ export function isValidDate(value) {
2
+ return value instanceof Date && !isNaN(value);
3
+ }
4
+
5
+ export function tryParseDate(...values) {
6
+ if (values.length === 0) throw 'Missing values to parse as date';
7
+ if (values.length === 1) {
8
+ const parsed = new Date(values[0]);
9
+ if (values[0] && isValidDate(parsed)) return parsed;
10
+ } else {
11
+ const parsed = new Date(...values);
12
+ if (isValidDate(parsed)) return parsed;
13
+ }
14
+ }
@@ -0,0 +1,36 @@
1
+ export const directionMap = {
2
+ get top() {
3
+ return 'bottom';
4
+ },
5
+ get bottom() {
6
+ return 'top';
7
+ },
8
+ get left() {
9
+ return 'right';
10
+ },
11
+ get right() {
12
+ return 'left';
13
+ },
14
+ };
15
+
16
+ export function defineRect({ x, y, width, height }) {
17
+ return { x, y, width, height, left: x, right: x + width, top: y, bottom: y + height };
18
+ }
19
+
20
+ export function viewportRect() {
21
+ return defineRect({
22
+ x: 0,
23
+ y: 0,
24
+ width: window.innerWidth || document.documentElement.clientWidth,
25
+ height: window.innerHeight || document.documentElement.clientHeight,
26
+ });
27
+ }
28
+
29
+ export function isWithinViewport(target) {
30
+ if (!(target instanceof HTMLElement)) return false;
31
+ const outer = viewportRect();
32
+ const inner = target.getBoundingClientRect();
33
+ const vertical = inner.top <= outer.height && inner.top + inner.height > 0;
34
+ const horizontal = inner.left <= outer.width && inner.left + inner.width > 0;
35
+ return vertical && horizontal;
36
+ }
@@ -1,4 +1,5 @@
1
- import { isWithinViewport, visibilityConfig } from './support';
1
+ import { isWithinViewport } from './geometry';
2
+ import { visibilityConfig } from './config';
2
3
 
3
4
  const defaultOptions = {
4
5
  element: null,
@@ -0,0 +1,22 @@
1
+ import Plumber from './index';
2
+
3
+ export default class WindowObserver extends Plumber {
4
+ observe(handler) {
5
+ this._handler = handler;
6
+ this.events.forEach((e) => window.addEventListener(e, handler, true));
7
+ }
8
+
9
+ unobserve() {
10
+ if (!this._handler) return;
11
+ this.events.forEach((e) => window.removeEventListener(e, this._handler, true));
12
+ }
13
+
14
+ enhance() {
15
+ const context = this;
16
+ const superDisconnect = this.controller.disconnect?.bind(this.controller) || (() => {});
17
+ this.controller.disconnect = () => {
18
+ context.unobserve();
19
+ superDisconnect();
20
+ };
21
+ }
22
+ }
@@ -1,5 +1,5 @@
1
- import Plumber from './plumber';
2
- import { viewportRect, directionMap, defineRect } from './plumber/support';
1
+ import WindowObserver from './plumber/window_observer';
2
+ import { viewportRect, directionMap, defineRect } from './plumber/geometry';
3
3
 
4
4
  const defaultOptions = {
5
5
  events: ['resize'],
@@ -8,16 +8,7 @@ const defaultOptions = {
8
8
  respectMotion: true,
9
9
  };
10
10
 
11
- export class Shifter extends Plumber {
12
- /**
13
- * Creates a new Shifter plumber instance for viewport boundary shifting.
14
- * @param {Object} controller - Stimulus controller instance
15
- * @param {Object} [options] - Configuration options
16
- * @param {string[]} [options.events=['resize']] - Events triggering shift calculation
17
- * @param {string[]} [options.boundaries=['top','left','right']] - Boundaries to check (valid values: 'top', 'bottom', 'left', 'right')
18
- * @param {string} [options.onShifted='shifted'] - Callback name when shifted
19
- * @param {boolean} [options.respectMotion=true] - Respect prefers-reduced-motion preference
20
- */
11
+ export class Shifter extends WindowObserver {
21
12
  constructor(controller, options = {}) {
22
13
  super(controller, options);
23
14
 
@@ -29,13 +20,9 @@ export class Shifter extends Plumber {
29
20
  this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
30
21
 
31
22
  this.enhance();
32
- this.observe();
23
+ this.observe(this.shift);
33
24
  }
34
25
 
35
- /**
36
- * Calculates and applies transform to shift element within viewport boundaries.
37
- * @returns {Promise<void>}
38
- */
39
26
  shift = async () => {
40
27
  if (!this.visible) return;
41
28
 
@@ -44,21 +31,13 @@ export class Shifter extends Plumber {
44
31
  const translateX = overflow['left'] || overflow['right'] || 0;
45
32
  const translateY = overflow['top'] || overflow['bottom'] || 0;
46
33
 
47
- // Disable transitions for users who prefer reduced motion
48
34
  this.element.style.transition = this.respectMotion && this.prefersReducedMotion ? 'none' : '';
49
-
50
35
  this.element.style.transform = `translate(${translateX}px, ${translateY}px)`;
51
36
 
52
37
  await this.awaitCallback(this.onShifted, overflow);
53
38
  this.dispatch('shifted', { detail: overflow });
54
39
  };
55
40
 
56
- /**
57
- * Calculates overflow distances for each boundary direction.
58
- * @param {DOMRect} targetRect - Target element's bounding rect
59
- * @param {Object} translations - Current transform translations
60
- * @returns {Object} Overflow distances by direction
61
- */
62
41
  overflowRect(targetRect, translations) {
63
42
  const overflow = {};
64
43
  const viewport = viewportRect();
@@ -81,14 +60,6 @@ export class Shifter extends Plumber {
81
60
  return overflow;
82
61
  }
83
62
 
84
- /**
85
- * Calculates distance from inner rect to outer boundary in given direction.
86
- * @param {Object} inner - Inner rect object
87
- * @param {string} direction - Direction ('top', 'bottom', 'left', 'right')
88
- * @param {Object} outer - Outer rect object
89
- * @returns {number} Distance to boundary (negative if overflowing)
90
- * @throws {string} If direction is invalid
91
- */
92
63
  directionDistance(inner, direction, outer) {
93
64
  switch (direction) {
94
65
  case 'top':
@@ -102,63 +73,20 @@ export class Shifter extends Plumber {
102
73
  }
103
74
  }
104
75
 
105
- /**
106
- * Extracts current translate values from element's transform style.
107
- * @param {HTMLElement} target - Target element
108
- * @returns {Object} Translation object with x and y values
109
- */
110
76
  elementTranslations(target) {
111
77
  const style = window.getComputedStyle(target);
112
78
  const matrix = style['transform'] || style['webkitTransform'] || style['mozTransform'];
113
-
114
- if (matrix === 'none' || typeof matrix === 'undefined') {
115
- return { x: 0, y: 0 };
116
- }
117
-
79
+ if (matrix === 'none' || typeof matrix === 'undefined') return { x: 0, y: 0 };
118
80
  const matrixType = matrix.includes('3d') ? '3d' : '2d';
119
81
  const matrixValues = matrix.match(/matrix.*\((.+)\)/)[1].split(', ');
120
-
121
- if (matrixType === '2d') {
122
- return { x: Number(matrixValues[4]), y: Number(matrixValues[5]) };
123
- }
82
+ if (matrixType === '2d') return { x: Number(matrixValues[4]), y: Number(matrixValues[5]) };
124
83
  return { x: 0, y: 0 };
125
84
  }
126
85
 
127
- /**
128
- * Starts observing configured events for shifting.
129
- */
130
- observe() {
131
- this.events.forEach((event) => {
132
- window.addEventListener(event, this.shift, true);
133
- });
134
- }
135
-
136
- /**
137
- * Stops observing events for shifting.
138
- */
139
- unobserve() {
140
- this.events.forEach((event) => {
141
- window.removeEventListener(event, this.shift, true);
142
- });
143
- }
144
-
145
86
  enhance() {
146
- const context = this;
147
- const superDisconnect = context.controller.disconnect.bind(context.controller);
148
- Object.assign(this.controller, {
149
- disconnect: () => {
150
- context.unobserve();
151
- superDisconnect();
152
- },
153
- shift: context.shift.bind(context),
154
- });
87
+ super.enhance();
88
+ this.controller.shift = this.shift;
155
89
  }
156
90
  }
157
91
 
158
- /**
159
- * Factory function to create and attach a Shifter plumber to a controller.
160
- * @param {Object} controller - Stimulus controller instance
161
- * @param {Object} [options] - Configuration options
162
- * @returns {Shifter} Shifter plumber instance
163
- */
164
92
  export const attachShifter = (controller, options) => new Shifter(controller, options);
@@ -1,5 +1,5 @@
1
1
  import Plumber from './plumber';
2
- import { visibilityConfig } from './plumber/support';
2
+ import { visibilityConfig } from './plumber/config';
3
3
 
4
4
  const defaultOptions = {
5
5
  visibility: 'visibility',
@@ -0,0 +1,24 @@
1
+ export class Requestor {
2
+ constructor() {
3
+ this._abortController = null;
4
+ this._timer = null;
5
+ }
6
+
7
+ schedule(fn, delay) {
8
+ clearTimeout(this._timer);
9
+ this._timer = setTimeout(fn, delay);
10
+ }
11
+
12
+ async request(url, options = {}) {
13
+ this._abortController?.abort();
14
+ this._abortController = new AbortController();
15
+ const res = await fetch(url, { ...options, signal: this._abortController.signal });
16
+ if (!res.ok) throw new Error(`${res.status}`);
17
+ return res;
18
+ }
19
+
20
+ cancel() {
21
+ clearTimeout(this._timer);
22
+ this._abortController?.abort();
23
+ }
24
+ }
@@ -0,0 +1,39 @@
1
+ export function fuzzyMatcher(needle, haystack) {
2
+ let ni = 0;
3
+ for (let i = 0; i < haystack.length && ni < needle.length; i++) {
4
+ if (haystack[i] === needle[ni]) ni++;
5
+ }
6
+ return ni === needle.length;
7
+ }
8
+
9
+ function matchContains(needle, haystack) {
10
+ return haystack.includes(needle);
11
+ }
12
+
13
+ function matchPrefix(needle, haystack) {
14
+ return haystack.startsWith(needle);
15
+ }
16
+
17
+ function getStrategy(strategy) {
18
+ if (strategy === 'contains') return matchContains;
19
+ if (strategy === 'prefix') return matchPrefix;
20
+ return fuzzyMatcher;
21
+ }
22
+
23
+ function extractDOMValue(el, field) {
24
+ if (field === 'textContent') return el.textContent?.trim().toLowerCase() ?? '';
25
+ return (el.getAttribute(field) ?? '').toLowerCase();
26
+ }
27
+
28
+ export function filterOptions(listbox, query, options = {}) {
29
+ const { strategy = 'fuzzy', matcher, fields = ['textContent'] } = options;
30
+ const matchFn = typeof matcher === 'function' ? matcher : getStrategy(strategy);
31
+ const needle = query.toLowerCase();
32
+ let visible = 0;
33
+ listbox.querySelectorAll('[role="option"]').forEach((opt) => {
34
+ const match = fields.some((field) => matchFn(needle, extractDOMValue(opt, field)));
35
+ opt.hidden = !match;
36
+ if (match) visible++;
37
+ });
38
+ return visible;
39
+ }
@@ -1,60 +0,0 @@
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);
@@ -1,90 +0,0 @@
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);
@@ -1,101 +0,0 @@
1
- export const visibilityConfig = {
2
- get visibleOnly() {
3
- return true;
4
- },
5
- hiddenClass: null,
6
- };
7
-
8
- /**
9
- * Maps each direction to its opposite direction.
10
- * Used for flipping and boundary calculations.
11
- */
12
- export const directionMap = {
13
- get top() {
14
- return 'bottom';
15
- },
16
- get bottom() {
17
- return 'top';
18
- },
19
- get left() {
20
- return 'right';
21
- },
22
- get right() {
23
- return 'left';
24
- },
25
- };
26
-
27
- /**
28
- * Creates a rect object with position and dimension properties.
29
- * @param {Object} params - Rectangle parameters
30
- * @param {number} params.x - X coordinate
31
- * @param {number} params.y - Y coordinate
32
- * @param {number} params.width - Width
33
- * @param {number} params.height - Height
34
- * @returns {Object} Rect object with x, y, width, height, left, right, top, bottom properties
35
- */
36
- export function defineRect({ x, y, width, height }) {
37
- return {
38
- x: x,
39
- y: y,
40
- width: width,
41
- height: height,
42
- left: x,
43
- right: x + width,
44
- top: y,
45
- bottom: y + height,
46
- };
47
- }
48
-
49
- /**
50
- * Returns the current viewport dimensions as a rect object.
51
- * @returns {Object} Viewport rect with dimensions and boundaries
52
- */
53
- export function viewportRect() {
54
- return defineRect({
55
- x: 0,
56
- y: 0,
57
- width: window.innerWidth || document.documentElement.clientWidth,
58
- height: window.innerHeight || document.documentElement.clientHeight,
59
- });
60
- }
61
-
62
- /**
63
- * Checks if an element is within the visible viewport.
64
- * @param {HTMLElement} target - Element to check
65
- * @returns {boolean} True if element is within viewport
66
- */
67
- export function isWithinViewport(target) {
68
- if (!(target instanceof HTMLElement)) return false;
69
-
70
- const outer = viewportRect();
71
- const inner = target.getBoundingClientRect();
72
- const vertical = inner.top <= outer.height && inner.top + inner.height > 0;
73
- const horizontal = inner.left <= outer.width && inner.left + inner.width > 0;
74
- return vertical && horizontal;
75
- }
76
-
77
- /**
78
- * Validates if a value is a valid Date object.
79
- * @param {*} value - Value to check
80
- * @returns {boolean} True if value is a valid Date
81
- */
82
- export function isValidDate(value) {
83
- return value instanceof Date && !isNaN(value);
84
- }
85
-
86
- /**
87
- * Attempts to parse values into a Date object.
88
- * @param {...*} values - Date values to parse
89
- * @returns {Date|undefined} Parsed Date object or undefined if invalid
90
- * @throws {string} If no values provided
91
- */
92
- export function tryParseDate(...values) {
93
- if (values.length === 0) throw 'Missing values to parse as date';
94
- if (values.length === 1) {
95
- const parsed = new Date(values[0]);
96
- if (values[0] && isValidDate(parsed)) return parsed;
97
- } else {
98
- const parsed = new Date(...values);
99
- if (isValidDate(parsed)) return parsed;
100
- }
101
- }
File without changes
File without changes
File without changes