@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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/stimulus-plumbers-controllers.es.js +1459 -0
- package/dist/stimulus-plumbers-controllers.umd.js +1 -0
- package/package.json +57 -0
- package/src/aria.js +173 -0
- package/src/controllers/auto_resize_controller.js +17 -0
- package/src/controllers/calendar_month_controller.js +100 -0
- package/src/controllers/calendar_month_observer_controller.js +24 -0
- package/src/controllers/datepicker_controller.js +101 -0
- package/src/controllers/dismisser_controller.js +10 -0
- package/src/controllers/flipper_controller.js +33 -0
- package/src/controllers/form-field_controller.js +77 -0
- package/src/controllers/modal_controller.js +104 -0
- package/src/controllers/panner_controller.js +10 -0
- package/src/controllers/password_reveal_controller.js +9 -0
- package/src/controllers/popover_controller.js +76 -0
- package/src/controllers/visibility_controller.js +32 -0
- package/src/focus.js +128 -0
- package/src/index.js +23 -0
- package/src/keyboard.js +92 -0
- package/src/plumbers/calendar.js +399 -0
- package/src/plumbers/content_loader.js +134 -0
- package/src/plumbers/dismisser.js +82 -0
- package/src/plumbers/flipper.js +272 -0
- package/src/plumbers/index.js +10 -0
- package/src/plumbers/plumber/index.js +110 -0
- package/src/plumbers/plumber/support.js +101 -0
- package/src/plumbers/shifter.js +164 -0
- package/src/plumbers/visibility.js +136 -0
|
@@ -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);
|