@stimulus-plumbers/controllers 0.2.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.
@@ -0,0 +1,272 @@
1
+ import Plumber from './plumber';
2
+ import { defineRect, viewportRect, directionMap } from './plumber/support';
3
+ import { connectTriggerToTarget } from '../aria';
4
+
5
+ const defaultOptions = {
6
+ anchor: null,
7
+ events: ['click'],
8
+ placement: 'bottom',
9
+ alignment: 'start',
10
+ onFlipped: 'flipped',
11
+ ariaRole: null,
12
+ respectMotion: true,
13
+ };
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
+ */
28
+ constructor(controller, options = {}) {
29
+ super(controller, options);
30
+
31
+ const { anchor, events, placement, alignment, onFlipped, ariaRole, respectMotion } = Object.assign(
32
+ {},
33
+ defaultOptions,
34
+ options
35
+ );
36
+ this.anchor = anchor;
37
+ this.events = events;
38
+ this.placement = placement;
39
+ this.alignment = alignment;
40
+ this.onFlipped = onFlipped;
41
+ this.ariaRole = ariaRole;
42
+ this.respectMotion = respectMotion;
43
+ this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
44
+
45
+ if (this.anchor && this.element) {
46
+ connectTriggerToTarget({
47
+ trigger: this.anchor,
48
+ target: this.element,
49
+ role: this.ariaRole,
50
+ });
51
+ }
52
+
53
+ this.enhance();
54
+ this.observe();
55
+ }
56
+
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
+ flip = async () => {
63
+ if (!this.visible) return;
64
+
65
+ this.dispatch('flip');
66
+ const positionStyle = window.getComputedStyle(this.element);
67
+ if (positionStyle['position'] != 'absolute') this.element.style['position'] = 'absolute';
68
+
69
+ const placement = this.flippedRect(this.anchor.getBoundingClientRect(), this.element.getBoundingClientRect());
70
+
71
+ // Disable transitions for users who prefer reduced motion
72
+ this.element.style.transition = this.respectMotion && this.prefersReducedMotion ? 'none' : '';
73
+
74
+ for (const [key, value] of Object.entries(placement)) {
75
+ this.element.style[key] = value;
76
+ }
77
+
78
+ await this.awaitCallback(this.onFlipped, { target: this.element, placement });
79
+ this.dispatch('flipped', { detail: { placement } });
80
+ };
81
+
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
+ flippedRect(anchorRect, referenceRect) {
89
+ const candidateRects = this.quadrumRect(anchorRect, viewportRect());
90
+ const candidates = [this.placement, directionMap[this.placement]];
91
+ let flipped = {};
92
+ while (!Object.keys(flipped).length && candidates.length > 0) {
93
+ const candidate = candidates.shift();
94
+ if (!this.biggerRectThan(candidateRects[candidate], referenceRect)) continue;
95
+
96
+ const placementRect = this.quadrumPlacement(anchorRect, candidate, referenceRect);
97
+ const alignmentRect = this.quadrumAlignment(anchorRect, candidate, placementRect);
98
+ flipped['top'] = `${alignmentRect['top'] + window.scrollY}px`;
99
+ flipped['left'] = `${alignmentRect['left'] + window.scrollX}px`;
100
+ }
101
+ if (!Object.keys(flipped).length) {
102
+ flipped['top'] = '';
103
+ flipped['left'] = '';
104
+ }
105
+ return flipped;
106
+ }
107
+
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
+ quadrumRect(inner, outer) {
115
+ return {
116
+ left: defineRect({
117
+ x: outer.x,
118
+ y: outer.y,
119
+ width: inner.x - outer.x,
120
+ height: outer.height,
121
+ }),
122
+ right: defineRect({
123
+ x: inner.x + inner.width,
124
+ y: outer.y,
125
+ width: outer.width - (inner.x + inner.width),
126
+ height: outer.height,
127
+ }),
128
+ top: defineRect({
129
+ x: outer.x,
130
+ y: outer.y,
131
+ width: outer.width,
132
+ height: inner.y - outer.y,
133
+ }),
134
+ bottom: defineRect({
135
+ x: outer.x,
136
+ y: inner.y + inner.height,
137
+ width: outer.width,
138
+ height: outer.height - (inner.y + inner.height),
139
+ }),
140
+ };
141
+ }
142
+
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
+ quadrumPlacement(anchor, direction, reference) {
152
+ switch (direction) {
153
+ case 'top':
154
+ return defineRect({
155
+ x: reference.x,
156
+ y: anchor.y - reference.height,
157
+ width: reference.width,
158
+ height: reference.height,
159
+ });
160
+ case 'bottom':
161
+ return defineRect({
162
+ x: reference.x,
163
+ y: anchor.y + anchor.height,
164
+ width: reference.width,
165
+ height: reference.height,
166
+ });
167
+ case 'left':
168
+ return defineRect({
169
+ x: anchor.x - reference.width,
170
+ y: reference.y,
171
+ width: reference.width,
172
+ height: reference.height,
173
+ });
174
+ case 'right':
175
+ return defineRect({
176
+ x: anchor.x + anchor.width,
177
+ y: reference.y,
178
+ width: reference.width,
179
+ height: reference.height,
180
+ });
181
+ default:
182
+ throw `Unable place at the quadrum, ${direction}`;
183
+ }
184
+ }
185
+
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
+ quadrumAlignment(anchor, direction, reference) {
195
+ switch (direction) {
196
+ case 'top':
197
+ case 'bottom': {
198
+ let alignment = anchor.x;
199
+ if (this.alignment === 'center') alignment = anchor.x + anchor.width / 2 - reference.width / 2;
200
+ 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
+ });
207
+ }
208
+ case 'left':
209
+ case 'right': {
210
+ let alignment = anchor.y;
211
+ if (this.alignment === 'center') alignment = anchor.y + anchor.height / 2 - reference.height / 2;
212
+ 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
+ });
219
+ }
220
+ default:
221
+ throw `Unable align at the quadrum, ${direction}`;
222
+ }
223
+ }
224
+
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
+ biggerRectThan(big, small) {
232
+ return big.height >= small.height && big.width >= small.width;
233
+ }
234
+
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
+ 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
+ });
263
+ }
264
+ }
265
+
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
+ export const attachFlipper = (controller, options) => new Flipper(controller, options);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Plumbers - Core utilities for Stimulus controllers
3
+ */
4
+
5
+ export { initCalendar } from './calendar';
6
+ export { attachContentLoader } from './content_loader';
7
+ export { attachDismisser } from './dismisser';
8
+ export { attachFlipper } from './flipper';
9
+ export { attachShifter } from './shifter';
10
+ export { attachVisibility } from './visibility';
@@ -0,0 +1,110 @@
1
+ import { isWithinViewport, visibilityConfig } from './support';
2
+
3
+ const defaultOptions = {
4
+ element: null,
5
+ visible: null,
6
+ dispatch: true,
7
+ prefix: '',
8
+ };
9
+
10
+ export default class Plumber {
11
+ /**
12
+ * Creates a new Plumber instance.
13
+ * @param {Object} controller - Stimulus controller instance
14
+ * @param {Object} options - Configuration options
15
+ * @param {HTMLElement} [options.element] - Target element (defaults to controller.element)
16
+ * @param {boolean|string} [options.visible=true] - Visibility check configuration
17
+ * @param {boolean} [options.dispatch=true] - Enable event dispatching
18
+ * @param {string} [options.prefix] - Event prefix (defaults to controller.identifier)
19
+ */
20
+ constructor(controller, options = {}) {
21
+ this.controller = controller;
22
+
23
+ const config = Object.assign({}, defaultOptions, options);
24
+ const { element, visible, dispatch, prefix } = config;
25
+ this.element = element || controller.element;
26
+ this.visibleOnly = typeof visible === 'boolean' ? visible : visibilityConfig.visibleOnly;
27
+ this.visibleCallback = typeof visible === 'string' ? visible : null;
28
+ this.notify = !!dispatch;
29
+ this.prefix = typeof prefix === 'string' && prefix ? prefix : controller.identifier;
30
+ }
31
+
32
+ /**
33
+ * Checks if the element is visible in viewport.
34
+ * @returns {boolean} True if element is visible
35
+ */
36
+ get visible() {
37
+ if (!(this.element instanceof HTMLElement)) return false;
38
+ if (!this.visibleOnly) return true;
39
+
40
+ return isWithinViewport(this.element) && this.isVisible(this.element);
41
+ }
42
+
43
+ /**
44
+ * Determines if a target element is visible.
45
+ * @param {HTMLElement} target - Element to check
46
+ * @returns {boolean} True if element is visible
47
+ */
48
+ isVisible(target) {
49
+ if (this.visibleCallback) {
50
+ const callback = this.findCallback(this.visibleCallback);
51
+ if (typeof callback == 'function') return callback(target);
52
+ }
53
+
54
+ if (!(target instanceof HTMLElement)) return false;
55
+ return !target.hasAttribute('hidden');
56
+ }
57
+
58
+ /**
59
+ * Dispatches a custom event from the controller.
60
+ * @param {string} name - Event name
61
+ * @param {Object} [options] - Event options
62
+ * @param {HTMLElement} [options.target] - Event target element
63
+ * @param {string} [options.prefix] - Event prefix
64
+ * @param {*} [options.detail] - Event detail data
65
+ * @returns {boolean|undefined} Dispatch result
66
+ */
67
+ dispatch(name, { target = null, prefix = null, detail = null } = {}) {
68
+ if (!this.notify) return;
69
+
70
+ return this.controller.dispatch(name, {
71
+ target: target || this.element,
72
+ prefix: prefix || this.prefix,
73
+ detail: detail,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Finds and binds a callback function by name from controller or plumber.
79
+ * @param {string} name - Callback name or dot-notation path
80
+ * @returns {Function|undefined} Bound callback function
81
+ */
82
+ findCallback(name) {
83
+ if (typeof name !== 'string') return;
84
+
85
+ const context = this;
86
+ const controllerCallback = name.split('.').reduce((acc, key) => acc && acc[key], context.controller);
87
+ if (typeof controllerCallback === 'function') {
88
+ return controllerCallback.bind(context.controller);
89
+ }
90
+ const callback = name.split('.').reduce((acc, key) => acc && acc[key], context);
91
+ if (typeof callback === 'function') {
92
+ return callback.bind(context);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Executes a callback function and awaits if it returns a Promise.
98
+ * @param {string|Function} callback - Callback name or function
99
+ * @param {...*} args - Arguments to pass to callback
100
+ * @returns {Promise<*>} Result of callback execution
101
+ */
102
+ async awaitCallback(callback, ...args) {
103
+ if (typeof callback === 'string') callback = this.findCallback(callback);
104
+ if (typeof callback === 'function') {
105
+ const result = callback(...args);
106
+ if (result instanceof Promise) return await result;
107
+ else return result;
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,101 @@
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
+ }
@@ -0,0 +1,164 @@
1
+ import Plumber from './plumber';
2
+ import { viewportRect, directionMap, defineRect } from './plumber/support';
3
+
4
+ const defaultOptions = {
5
+ events: ['resize'],
6
+ boundaries: ['top', 'left', 'right'],
7
+ onShifted: 'shifted',
8
+ respectMotion: true,
9
+ };
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
+ */
21
+ constructor(controller, options = {}) {
22
+ super(controller, options);
23
+
24
+ const { onShifted, events, boundaries, respectMotion } = Object.assign({}, defaultOptions, options);
25
+ this.onShifted = onShifted;
26
+ this.events = events;
27
+ this.boundaries = boundaries;
28
+ this.respectMotion = respectMotion;
29
+ this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
30
+
31
+ this.enhance();
32
+ this.observe();
33
+ }
34
+
35
+ /**
36
+ * Calculates and applies transform to shift element within viewport boundaries.
37
+ * @returns {Promise<void>}
38
+ */
39
+ shift = async () => {
40
+ if (!this.visible) return;
41
+
42
+ this.dispatch('shift');
43
+ const overflow = this.overflowRect(this.element.getBoundingClientRect(), this.elementTranslations(this.element));
44
+ const translateX = overflow['left'] || overflow['right'] || 0;
45
+ const translateY = overflow['top'] || overflow['bottom'] || 0;
46
+
47
+ // Disable transitions for users who prefer reduced motion
48
+ this.element.style.transition = this.respectMotion && this.prefersReducedMotion ? 'none' : '';
49
+
50
+ this.element.style.transform = `translate(${translateX}px, ${translateY}px)`;
51
+
52
+ await this.awaitCallback(this.onShifted, overflow);
53
+ this.dispatch('shifted', { detail: overflow });
54
+ };
55
+
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
+ overflowRect(targetRect, translations) {
63
+ const overflow = {};
64
+ const viewport = viewportRect();
65
+ const currentRect = defineRect({
66
+ x: targetRect.x - translations.x,
67
+ y: targetRect.y - translations.y,
68
+ width: targetRect.width,
69
+ height: targetRect.height,
70
+ });
71
+ for (const direction of this.boundaries) {
72
+ const distance = this.directionDistance(currentRect, direction, viewport);
73
+ const opposite = directionMap[direction];
74
+ if (distance < 0) {
75
+ const sufficientSpace = currentRect[opposite] + distance >= viewport[opposite];
76
+ if (sufficientSpace && !overflow[opposite]) overflow[direction] = distance;
77
+ } else {
78
+ overflow[direction] = '';
79
+ }
80
+ }
81
+ return overflow;
82
+ }
83
+
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
+ directionDistance(inner, direction, outer) {
93
+ switch (direction) {
94
+ case 'top':
95
+ case 'left':
96
+ return inner[direction] - outer[direction];
97
+ case 'bottom':
98
+ case 'right':
99
+ return outer[direction] - inner[direction];
100
+ default:
101
+ throw `Invalid direction to calcuate distance, ${direction}`;
102
+ }
103
+ }
104
+
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
+ elementTranslations(target) {
111
+ const style = window.getComputedStyle(target);
112
+ const matrix = style['transform'] || style['webkitTransform'] || style['mozTransform'];
113
+
114
+ if (matrix === 'none' || typeof matrix === 'undefined') {
115
+ return { x: 0, y: 0 };
116
+ }
117
+
118
+ const matrixType = matrix.includes('3d') ? '3d' : '2d';
119
+ const matrixValues = matrix.match(/matrix.*\((.+)\)/)[1].split(', ');
120
+
121
+ if (matrixType === '2d') {
122
+ return { x: Number(matrixValues[4]), y: Number(matrixValues[5]) };
123
+ }
124
+ return { x: 0, y: 0 };
125
+ }
126
+
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
+ 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
+ });
155
+ }
156
+ }
157
+
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
+ export const attachShifter = (controller, options) => new Shifter(controller, options);