@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,399 @@
|
|
|
1
|
+
import Plumber from './plumber';
|
|
2
|
+
import { isValidDate, tryParseDate } from './plumber/support';
|
|
3
|
+
|
|
4
|
+
const DAYS_OF_WEEK = 7;
|
|
5
|
+
|
|
6
|
+
const defaultOptions = {
|
|
7
|
+
locales: ['default'],
|
|
8
|
+
today: '',
|
|
9
|
+
day: null,
|
|
10
|
+
month: null,
|
|
11
|
+
year: null,
|
|
12
|
+
since: null,
|
|
13
|
+
till: null,
|
|
14
|
+
disabledDates: [],
|
|
15
|
+
disabledWeekdays: [],
|
|
16
|
+
disabledDays: [],
|
|
17
|
+
disabledMonths: [],
|
|
18
|
+
disabledYears: [],
|
|
19
|
+
firstDayOfWeek: 0,
|
|
20
|
+
onNavigated: 'navigated',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class Calendar extends Plumber {
|
|
24
|
+
/**
|
|
25
|
+
* Creates a new Calendar plumber instance with date navigation and validation.
|
|
26
|
+
* @param {Object} controller - Stimulus controller instance
|
|
27
|
+
* @param {Object} [options] - Configuration options
|
|
28
|
+
* @param {string[]} [options.locales=['default']] - Locale identifiers for date formatting
|
|
29
|
+
* @param {string|Date} [options.today=''] - Initial "today" date
|
|
30
|
+
* @param {number} [options.day] - Initial day
|
|
31
|
+
* @param {number} [options.month] - Initial month (0-11)
|
|
32
|
+
* @param {number} [options.year] - Initial year
|
|
33
|
+
* @param {string|Date} [options.since] - Minimum selectable date
|
|
34
|
+
* @param {string|Date} [options.till] - Maximum selectable date
|
|
35
|
+
* @param {Array<string|Date>} [options.disabledDates=[]] - Array of disabled dates
|
|
36
|
+
* @param {string[]|number[]} [options.disabledWeekdays=[]] - Array of disabled weekdays
|
|
37
|
+
* @param {string[]|number[]} [options.disabledDays=[]] - Array of disabled day numbers
|
|
38
|
+
* @param {string[]|number[]} [options.disabledMonths=[]] - Array of disabled months
|
|
39
|
+
* @param {string[]|number[]} [options.disabledYears=[]] - Array of disabled years
|
|
40
|
+
* @param {number} [options.firstDayOfWeek=0] - First day of week (0=Sunday, 1=Monday, etc.)
|
|
41
|
+
* @param {string} [options.onNavigated='navigated'] - Callback name when navigated
|
|
42
|
+
*/
|
|
43
|
+
constructor(controller, options = {}) {
|
|
44
|
+
super(controller, options);
|
|
45
|
+
|
|
46
|
+
const config = Object.assign({}, defaultOptions, options);
|
|
47
|
+
const { onNavigated, since, till, firstDayOfWeek } = config;
|
|
48
|
+
this.onNavigated = onNavigated;
|
|
49
|
+
this.since = tryParseDate(since);
|
|
50
|
+
this.till = tryParseDate(till);
|
|
51
|
+
this.firstDayOfWeek = 0 <= firstDayOfWeek && firstDayOfWeek < 7 ? firstDayOfWeek : defaultOptions.firstDayOfWeek;
|
|
52
|
+
|
|
53
|
+
const { disabledDates, disabledWeekdays, disabledDays, disabledMonths, disabledYears } = config;
|
|
54
|
+
this.disabledDates = Array.isArray(disabledDates) ? disabledDates : [];
|
|
55
|
+
this.disabledWeekdays = Array.isArray(disabledWeekdays) ? disabledWeekdays : [];
|
|
56
|
+
this.disabledDays = Array.isArray(disabledDays) ? disabledDays : [];
|
|
57
|
+
this.disabledMonths = Array.isArray(disabledMonths) ? disabledMonths : [];
|
|
58
|
+
this.disabledYears = Array.isArray(disabledYears) ? disabledYears : [];
|
|
59
|
+
|
|
60
|
+
const { today, day, month, year } = config;
|
|
61
|
+
this.now = tryParseDate(today) || new Date();
|
|
62
|
+
|
|
63
|
+
if (typeof year === 'number' && typeof month === 'number' && typeof day === 'number') {
|
|
64
|
+
this.current = tryParseDate(year, month, day);
|
|
65
|
+
} else {
|
|
66
|
+
this.current = this.now;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.build();
|
|
70
|
+
this.enhance();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds all calendar data structures (days of week, days of month, months of year).
|
|
75
|
+
*/
|
|
76
|
+
build() {
|
|
77
|
+
this.daysOfWeek = this.buildDaysOfWeek();
|
|
78
|
+
this.daysOfMonth = this.buildDaysOfMonth();
|
|
79
|
+
this.monthsOfYear = this.buildMonthsOfYear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds array of weekday objects with localized names.
|
|
84
|
+
* @returns {Array<Object>} Array of weekday objects
|
|
85
|
+
*/
|
|
86
|
+
buildDaysOfWeek() {
|
|
87
|
+
const longFormatter = new Intl.DateTimeFormat(this.localesValue, { weekday: 'long' });
|
|
88
|
+
const shortFormatter = new Intl.DateTimeFormat(this.localesValue, { weekday: 'short' });
|
|
89
|
+
|
|
90
|
+
const sunday = new Date('2024-10-06');
|
|
91
|
+
const daysOfWeek = [];
|
|
92
|
+
for (let i = this.firstDayOfWeek, n = i + 7; i < n; i++) {
|
|
93
|
+
const weekday = new Date(sunday);
|
|
94
|
+
weekday.setDate(sunday.getDate() + i);
|
|
95
|
+
daysOfWeek.push({
|
|
96
|
+
date: weekday,
|
|
97
|
+
value: weekday.getDay(),
|
|
98
|
+
long: longFormatter.format(weekday),
|
|
99
|
+
short: shortFormatter.format(weekday),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return daysOfWeek;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Builds array of day objects for the current month view, including overflow from adjacent months.
|
|
107
|
+
* @returns {Array<Object>} Array of day objects with metadata
|
|
108
|
+
*/
|
|
109
|
+
buildDaysOfMonth() {
|
|
110
|
+
const currentMonth = this.month;
|
|
111
|
+
const currentYear = this.year;
|
|
112
|
+
const daysOfMonth = [];
|
|
113
|
+
const parseDate = (date) => ({
|
|
114
|
+
current: this.month === date.getMonth() && this.year === date.getFullYear(),
|
|
115
|
+
date: date,
|
|
116
|
+
value: date.getDate(),
|
|
117
|
+
month: date.getMonth(),
|
|
118
|
+
year: date.getFullYear(),
|
|
119
|
+
iso: date.toISOString(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const currentWeekday = new Date(currentYear, currentMonth).getDay();
|
|
123
|
+
const weekdayBeforeCurrent = this.firstDayOfWeek - currentWeekday;
|
|
124
|
+
for (let i = weekdayBeforeCurrent > 0 ? weekdayBeforeCurrent - 7 : weekdayBeforeCurrent; i < 0; i++) {
|
|
125
|
+
const previous = new Date(currentYear, currentMonth, i + 1);
|
|
126
|
+
daysOfMonth.push(parseDate(previous));
|
|
127
|
+
}
|
|
128
|
+
const currentMonthTotalDays = new Date(currentYear, currentMonth + 1, 0).getDate();
|
|
129
|
+
for (let i = 1; i <= currentMonthTotalDays; i++) {
|
|
130
|
+
const current = new Date(currentYear, currentMonth, i);
|
|
131
|
+
daysOfMonth.push(parseDate(current));
|
|
132
|
+
}
|
|
133
|
+
const mod = daysOfMonth.length % DAYS_OF_WEEK;
|
|
134
|
+
const trailing = mod === 0 ? 0 : DAYS_OF_WEEK - mod;
|
|
135
|
+
for (let i = 1; i <= trailing; i++) {
|
|
136
|
+
const next = new Date(currentYear, currentMonth + 1, i);
|
|
137
|
+
daysOfMonth.push(parseDate(next));
|
|
138
|
+
}
|
|
139
|
+
return daysOfMonth;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Builds array of month objects with localized names for the current year.
|
|
144
|
+
* @returns {Array<Object>} Array of month objects
|
|
145
|
+
*/
|
|
146
|
+
buildMonthsOfYear() {
|
|
147
|
+
const longFormatter = new Intl.DateTimeFormat(this.localesValue, { month: 'long' });
|
|
148
|
+
const shortFormatter = new Intl.DateTimeFormat(this.localesValue, { month: 'short' });
|
|
149
|
+
const numericFormatter = new Intl.DateTimeFormat(this.localesValue, { month: 'numeric' });
|
|
150
|
+
|
|
151
|
+
const monthsOfYear = [];
|
|
152
|
+
for (let i = 0; i < 12; i++) {
|
|
153
|
+
const month = new Date(this.year, i);
|
|
154
|
+
monthsOfYear.push({
|
|
155
|
+
date: month,
|
|
156
|
+
value: month.getMonth(),
|
|
157
|
+
long: longFormatter.format(month),
|
|
158
|
+
short: shortFormatter.format(month),
|
|
159
|
+
numeric: numericFormatter.format(month),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return monthsOfYear;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Gets the current "today" reference date.
|
|
167
|
+
* @returns {Date} Today's date
|
|
168
|
+
*/
|
|
169
|
+
get today() {
|
|
170
|
+
return this.now;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sets the "today" reference date.
|
|
175
|
+
* @param {Date} value - New today date
|
|
176
|
+
*/
|
|
177
|
+
set today(value) {
|
|
178
|
+
if (!isValidDate(value)) return;
|
|
179
|
+
|
|
180
|
+
const month = this.month ? this.month : value.getMonth();
|
|
181
|
+
const year = this.year ? this.year : value.getFullYear();
|
|
182
|
+
const sameMonthYear = month == value.getMonth() && year == value.getFullYear();
|
|
183
|
+
const day = this.hasDayValue ? this.day : sameMonthYear ? value.getDate() : 1;
|
|
184
|
+
this.now = new Date(year, month, day).toISOString();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Gets the current selected date.
|
|
189
|
+
* @returns {Date|null} Current date or null if not fully specified
|
|
190
|
+
*/
|
|
191
|
+
get current() {
|
|
192
|
+
if (typeof this.year === 'number' && typeof this.month === 'number' && typeof this.day === 'number') {
|
|
193
|
+
return tryParseDate(this.year, this.month, this.day);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sets the current selected date.
|
|
200
|
+
* @param {Date} value - New current date
|
|
201
|
+
*/
|
|
202
|
+
set current(value) {
|
|
203
|
+
if (!isValidDate(value)) return;
|
|
204
|
+
|
|
205
|
+
this.day = value.getDate();
|
|
206
|
+
this.month = value.getMonth();
|
|
207
|
+
this.year = value.getFullYear();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Navigates to a specific date, dispatching events and rebuilding calendar.
|
|
212
|
+
* @param {Date} to - Target date to navigate to
|
|
213
|
+
* @returns {Promise<void>}
|
|
214
|
+
*/
|
|
215
|
+
navigate = async (to) => {
|
|
216
|
+
if (!isValidDate(to)) return;
|
|
217
|
+
|
|
218
|
+
const from = this.current;
|
|
219
|
+
const toIso = to.toISOString();
|
|
220
|
+
const fromIso = from.toISOString();
|
|
221
|
+
|
|
222
|
+
this.dispatch('navigate', { detail: { from: fromIso, to: toIso } });
|
|
223
|
+
|
|
224
|
+
this.current = to;
|
|
225
|
+
this.build();
|
|
226
|
+
await this.awaitCallback(this.onNavigated, { from: fromIso, to: toIso });
|
|
227
|
+
|
|
228
|
+
this.dispatch('navigated', { detail: { from: fromIso, to: toIso } });
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Steps the calendar by a given amount in a specific unit (year, month, or day).
|
|
233
|
+
* @param {string} type - Type of step ('year', 'month', 'day')
|
|
234
|
+
* @param {number} value - Number of units to step (positive or negative)
|
|
235
|
+
* @returns {Promise<void>}
|
|
236
|
+
*/
|
|
237
|
+
step = async (type, value) => {
|
|
238
|
+
if (value === 0) return;
|
|
239
|
+
|
|
240
|
+
const target = this.current;
|
|
241
|
+
switch (type) {
|
|
242
|
+
case 'year': {
|
|
243
|
+
target.setFullYear(target.getFullYear() + value);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'month': {
|
|
247
|
+
target.setMonth(target.getMonth() + value);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'day': {
|
|
251
|
+
target.setDate(target.getDate() + value);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
await this.navigate(target);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Checks if a date is disabled based on configured rules.
|
|
262
|
+
* @param {Date} date - Date to check
|
|
263
|
+
* @returns {boolean} True if date is disabled
|
|
264
|
+
*/
|
|
265
|
+
isDisabled = (date) => {
|
|
266
|
+
if (!isValidDate(date)) return false;
|
|
267
|
+
|
|
268
|
+
if (this.disabledDates.length) {
|
|
269
|
+
const epoch = date.getTime();
|
|
270
|
+
for (const str of this.disabledDates) {
|
|
271
|
+
if (epoch === new Date(str).getTime()) return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (this.disabledWeekdays.length) {
|
|
276
|
+
const target = date.getDay();
|
|
277
|
+
const weekdays = this.daysOfWeek;
|
|
278
|
+
const index = weekdays.findIndex((w) => w.value === target);
|
|
279
|
+
if (index >= 0) {
|
|
280
|
+
const weekday = weekdays[index];
|
|
281
|
+
for (const str of this.disabledWeekdays) {
|
|
282
|
+
if (weekday.value == str || weekday.short === str || weekday.long === str) return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (this.disabledDays.length) {
|
|
288
|
+
const target = date.getDate();
|
|
289
|
+
for (const str of this.disabledDays) {
|
|
290
|
+
if (target == str) return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (this.disabledMonths.length) {
|
|
295
|
+
const target = date.getMonth();
|
|
296
|
+
const months = this.monthsOfYear;
|
|
297
|
+
const index = months.findIndex((m) => m.value === target);
|
|
298
|
+
if (index >= 0) {
|
|
299
|
+
const month = months[index];
|
|
300
|
+
for (const str of this.disabledMonths) {
|
|
301
|
+
if (month.value == str || month.short === str || month.long === str) return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (this.disabledYears.length) {
|
|
307
|
+
const target = date.getFullYear();
|
|
308
|
+
for (const str of this.disabledYears) {
|
|
309
|
+
if (target == str) return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return false;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Checks if a date is within the allowed range (since/till).
|
|
318
|
+
* @param {Date} date - Date to check
|
|
319
|
+
* @returns {boolean} True if date is within range
|
|
320
|
+
*/
|
|
321
|
+
isWithinRange = (date) => {
|
|
322
|
+
if (!isValidDate(date)) return false;
|
|
323
|
+
|
|
324
|
+
let within = true;
|
|
325
|
+
if (this.since) within = within && date >= this.since;
|
|
326
|
+
if (this.till) within = within && date <= this.till;
|
|
327
|
+
return within;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
enhance() {
|
|
331
|
+
const context = this;
|
|
332
|
+
Object.assign(this.controller, {
|
|
333
|
+
get calendar() {
|
|
334
|
+
return {
|
|
335
|
+
get today() {
|
|
336
|
+
return context.today;
|
|
337
|
+
},
|
|
338
|
+
get current() {
|
|
339
|
+
return context.current;
|
|
340
|
+
},
|
|
341
|
+
get day() {
|
|
342
|
+
return context.day;
|
|
343
|
+
},
|
|
344
|
+
get month() {
|
|
345
|
+
return context.month;
|
|
346
|
+
},
|
|
347
|
+
get year() {
|
|
348
|
+
return context.year;
|
|
349
|
+
},
|
|
350
|
+
get since() {
|
|
351
|
+
return context.since;
|
|
352
|
+
},
|
|
353
|
+
get till() {
|
|
354
|
+
return context.till;
|
|
355
|
+
},
|
|
356
|
+
get firstDayOfWeek() {
|
|
357
|
+
return context.firstDayOfWeek;
|
|
358
|
+
},
|
|
359
|
+
get disabledDates() {
|
|
360
|
+
return context.disabledDates;
|
|
361
|
+
},
|
|
362
|
+
get disabledWeekdays() {
|
|
363
|
+
return context.disabledWeekdays;
|
|
364
|
+
},
|
|
365
|
+
get disabledDays() {
|
|
366
|
+
return context.disabledDays;
|
|
367
|
+
},
|
|
368
|
+
get disabledMonths() {
|
|
369
|
+
return context.disabledMonths;
|
|
370
|
+
},
|
|
371
|
+
get disabledYears() {
|
|
372
|
+
return context.disabledYears;
|
|
373
|
+
},
|
|
374
|
+
get daysOfWeek() {
|
|
375
|
+
return context.daysOfWeek;
|
|
376
|
+
},
|
|
377
|
+
get daysOfMonth() {
|
|
378
|
+
return context.daysOfMonth;
|
|
379
|
+
},
|
|
380
|
+
get monthsOfYear() {
|
|
381
|
+
return context.monthsOfYear;
|
|
382
|
+
},
|
|
383
|
+
navigate: async (to) => await context.navigate(to),
|
|
384
|
+
step: async (type, value) => await context.step(type, value),
|
|
385
|
+
isDisabled: (date) => context.isDisabled(date),
|
|
386
|
+
isWithinRange: (date) => context.isWithinRange(date),
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Factory function to create and attach a Calendar plumber to a controller.
|
|
395
|
+
* @param {Object} controller - Stimulus controller instance
|
|
396
|
+
* @param {Object} [options] - Configuration options
|
|
397
|
+
* @returns {Calendar} Calendar plumber instance
|
|
398
|
+
*/
|
|
399
|
+
export const initCalendar = (controller, options) => new Calendar(controller, options);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import Plumber from './plumber';
|
|
2
|
+
import { tryParseDate } from './plumber/support';
|
|
3
|
+
|
|
4
|
+
const defaultOptions = {
|
|
5
|
+
content: null,
|
|
6
|
+
url: '',
|
|
7
|
+
reload: 'never',
|
|
8
|
+
stale: 3600,
|
|
9
|
+
onLoad: 'contentLoad',
|
|
10
|
+
onLoading: 'contentLoading',
|
|
11
|
+
onLoaded: 'contentLoaded',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ContentLoader extends Plumber {
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new ContentLoader plumber instance for async content loading.
|
|
17
|
+
* @param {Object} controller - Stimulus controller instance
|
|
18
|
+
* @param {Object} [options] - Configuration options
|
|
19
|
+
* @param {*} [options.content] - Initial content value
|
|
20
|
+
* @param {string} [options.url=''] - URL to fetch content from
|
|
21
|
+
* @param {string} [options.reload='never'] - Reload strategy ('never', 'always', or 'stale')
|
|
22
|
+
* @param {number} [options.stale=3600] - Seconds before content becomes stale
|
|
23
|
+
* @param {string} [options.onLoad='contentLoad'] - Callback name to check if loadable
|
|
24
|
+
* @param {string} [options.onLoading='contentLoading'] - Callback name to load content
|
|
25
|
+
* @param {string} [options.onLoaded='contentLoaded'] - Callback name after loading
|
|
26
|
+
*/
|
|
27
|
+
constructor(controller, options = {}) {
|
|
28
|
+
super(controller, options);
|
|
29
|
+
|
|
30
|
+
const config = Object.assign({}, defaultOptions, options);
|
|
31
|
+
const { content, url, reload, stale } = config;
|
|
32
|
+
this.content = content;
|
|
33
|
+
this.url = url;
|
|
34
|
+
this.reload = typeof reload === 'string' ? reload : defaultOptions.reload;
|
|
35
|
+
this.stale = typeof stale === 'number' ? stale : defaultOptions.stale;
|
|
36
|
+
|
|
37
|
+
const { onLoad, onLoading, onLoaded } = config;
|
|
38
|
+
this.onLoad = onLoad;
|
|
39
|
+
this.onLoading = onLoading;
|
|
40
|
+
this.onLoaded = onLoaded;
|
|
41
|
+
|
|
42
|
+
this.enhance();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checks if content should be reloaded based on reload strategy.
|
|
47
|
+
* @returns {boolean} True if content should be reloaded
|
|
48
|
+
*/
|
|
49
|
+
get reloadable() {
|
|
50
|
+
switch (this.reload) {
|
|
51
|
+
case 'never':
|
|
52
|
+
return false;
|
|
53
|
+
case 'always':
|
|
54
|
+
return true;
|
|
55
|
+
default: {
|
|
56
|
+
const loadedAt = tryParseDate(this.loadedAt);
|
|
57
|
+
return loadedAt && new Date() - loadedAt > this.stale * 1000;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Checks if content should be loaded based on URL presence.
|
|
64
|
+
* Override this method to provide custom loading conditions.
|
|
65
|
+
* @param {Object} params - Load parameters
|
|
66
|
+
* @param {string} params.url - URL to load from
|
|
67
|
+
* @returns {Promise<boolean>} True if content should be loaded
|
|
68
|
+
*/
|
|
69
|
+
contentLoadable = ({ url }) => !!url;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Loads content from remote or local source.
|
|
73
|
+
* Override this method to provide custom loading logic.
|
|
74
|
+
* @param {Object} params - Load parameters
|
|
75
|
+
* @param {string} params.url - URL to load from
|
|
76
|
+
* @returns {Promise<string>} Loaded content
|
|
77
|
+
*/
|
|
78
|
+
contentLoading = async ({ url }) => {
|
|
79
|
+
return url ? await this.remoteContentLoader(url) : await this.contentLoader();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Provides local/static content when no URL is available.
|
|
84
|
+
* Override this method to provide static content.
|
|
85
|
+
* @returns {Promise<string>} Local content
|
|
86
|
+
*/
|
|
87
|
+
contentLoader = async () => '';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetches content from a remote URL.
|
|
91
|
+
* Override this method to customize remote loading.
|
|
92
|
+
* @param {string} url - URL to fetch from
|
|
93
|
+
* @returns {Promise<string>} Fetched content
|
|
94
|
+
*/
|
|
95
|
+
remoteContentLoader = async (url) => (await fetch(url)).text();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Loads content from remote or local source with lifecycle events.
|
|
99
|
+
* Checks if loadable via onLoad, fetches content via onLoading,
|
|
100
|
+
* and notifies via onLoaded callback.
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
load = async () => {
|
|
104
|
+
if (this.loadedAt && !this.reloadable) return;
|
|
105
|
+
|
|
106
|
+
const loadableCallback = this.findCallback(this.onLoad);
|
|
107
|
+
const loadable = await this.awaitCallback(loadableCallback || this.contentLoadable, { url: this.url });
|
|
108
|
+
this.dispatch('load', { detail: { url: this.url } });
|
|
109
|
+
if (!loadable) return;
|
|
110
|
+
|
|
111
|
+
const content = this.url ? await this.remoteContentLoader(this.url) : await this.contentLoader();
|
|
112
|
+
this.dispatch('loading', { detail: { url: this.url } });
|
|
113
|
+
if (!content) return;
|
|
114
|
+
|
|
115
|
+
await this.awaitCallback(this.onLoaded, { url: this.url, content });
|
|
116
|
+
this.loadedAt = new Date().getTime();
|
|
117
|
+
this.dispatch('loaded', { detail: { url: this.url, content } });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
enhance() {
|
|
121
|
+
const context = this;
|
|
122
|
+
Object.assign(this.controller, {
|
|
123
|
+
load: context.load.bind(context),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Factory function to create and attach a ContentLoader plumber to a controller.
|
|
130
|
+
* @param {Object} controller - Stimulus controller instance
|
|
131
|
+
* @param {Object} [options] - Configuration options
|
|
132
|
+
* @returns {ContentLoader} ContentLoader plumber instance
|
|
133
|
+
*/
|
|
134
|
+
export const attachContentLoader = (controller, options) => new ContentLoader(controller, options);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Plumber from './plumber';
|
|
2
|
+
|
|
3
|
+
const defaultOptions = {
|
|
4
|
+
trigger: null,
|
|
5
|
+
events: ['click'],
|
|
6
|
+
onDismissed: 'dismissed',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class Dismisser extends Plumber {
|
|
10
|
+
/**
|
|
11
|
+
* Creates a new Dismisser plumber instance for handling outside-click dismissals.
|
|
12
|
+
* @param {Object} controller - Stimulus controller instance
|
|
13
|
+
* @param {Object} [options] - Configuration options
|
|
14
|
+
* @param {HTMLElement} [options.trigger] - Trigger element (defaults to controller element)
|
|
15
|
+
* @param {string[]} [options.events=['click']] - Events to listen for dismissal
|
|
16
|
+
* @param {string} [options.onDismissed='dismissed'] - Callback name when dismissed
|
|
17
|
+
*/
|
|
18
|
+
constructor(controller, options = {}) {
|
|
19
|
+
super(controller, options);
|
|
20
|
+
|
|
21
|
+
const { trigger, events, onDismissed } = Object.assign({}, defaultOptions, options);
|
|
22
|
+
this.onDismissed = onDismissed;
|
|
23
|
+
this.trigger = trigger || this.element;
|
|
24
|
+
this.events = events;
|
|
25
|
+
|
|
26
|
+
this.enhance();
|
|
27
|
+
this.observe();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handles dismissal when clicking outside the element.
|
|
32
|
+
* @param {Event} event - DOM event
|
|
33
|
+
* @returns {Promise<void>}
|
|
34
|
+
*/
|
|
35
|
+
dismiss = async (event) => {
|
|
36
|
+
const { target } = event;
|
|
37
|
+
if (!(target instanceof HTMLElement)) return;
|
|
38
|
+
if (this.element.contains(target)) return;
|
|
39
|
+
if (!this.visible) return;
|
|
40
|
+
|
|
41
|
+
this.dispatch('dismiss');
|
|
42
|
+
await this.awaitCallback(this.onDismissed, { target: this.trigger });
|
|
43
|
+
this.dispatch('dismissed');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Starts observing configured events for dismissal.
|
|
48
|
+
*/
|
|
49
|
+
observe() {
|
|
50
|
+
this.events.forEach((event) => {
|
|
51
|
+
window.addEventListener(event, this.dismiss, true);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stops observing events for dismissal.
|
|
57
|
+
*/
|
|
58
|
+
unobserve() {
|
|
59
|
+
this.events.forEach((event) => {
|
|
60
|
+
window.removeEventListener(event, this.dismiss, true);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
enhance() {
|
|
65
|
+
const context = this;
|
|
66
|
+
const superDisconnect = context.controller.disconnect.bind(context.controller);
|
|
67
|
+
Object.assign(this.controller, {
|
|
68
|
+
disconnect: () => {
|
|
69
|
+
context.unobserve();
|
|
70
|
+
superDisconnect();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Factory function to create and attach a Dismisser plumber to a controller.
|
|
78
|
+
* @param {Object} controller - Stimulus controller instance
|
|
79
|
+
* @param {Object} [options] - Configuration options
|
|
80
|
+
* @returns {Dismisser} Dismisser plumber instance
|
|
81
|
+
*/
|
|
82
|
+
export const attachDismisser = (controller, options) => new Dismisser(controller, options);
|