@stimulus-plumbers/controllers 0.2.9 → 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.
@@ -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);
@@ -3,7 +3,6 @@
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';
@@ -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);