@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,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);