@stimulus-plumbers/controllers 0.3.3 → 0.4.1

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,71 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { DateFormatter } from '../plumbers/formatters/date';
3
+ import { RovingTabIndex } from '../accessibility/keyboard';
4
+ import { setExpanded, setHidden, announce } from '../accessibility/aria';
5
+
6
+ export default class extends Controller {
7
+ static targets = ['trigger', 'detail', 'time'];
8
+ static values = {
9
+ dateFormat: { type: Object, default: {} },
10
+ };
11
+
12
+ connect() {
13
+ this.rovingTabIndex = new RovingTabIndex(this.triggerTargets, { orientation: 'vertical' });
14
+ this.rovingTabIndex.activate();
15
+ }
16
+
17
+ disconnect() {
18
+ this.rovingTabIndex?.deactivate();
19
+ this.rovingTabIndex = null;
20
+ }
21
+
22
+ toggle(event) {
23
+ const trigger = event.currentTarget;
24
+ trigger.getAttribute('aria-expanded') === 'true' ? this.collapseItem(trigger) : this.expandItem(trigger);
25
+ }
26
+
27
+ expand(event) {
28
+ this.expandItem(event.currentTarget);
29
+ }
30
+
31
+ collapse(event) {
32
+ this.collapseItem(event.currentTarget);
33
+ }
34
+
35
+ expandItem(trigger) {
36
+ const detail = this.detailTargets.find((d) => d.id === trigger.getAttribute('aria-controls')) ?? null;
37
+ this.dispatch('expand', { detail: { trigger, detail } });
38
+ setExpanded(trigger, true);
39
+ if (detail) {
40
+ setHidden(detail, false);
41
+ announce(`${trigger.textContent.trim()} expanded`);
42
+ }
43
+ this.dispatch('expanded', { detail: { trigger, detail } });
44
+ }
45
+
46
+ collapseItem(trigger) {
47
+ const detail = this.detailTargets.find((d) => d.id === trigger.getAttribute('aria-controls')) ?? null;
48
+ this.dispatch('collapse', { detail: { trigger, detail } });
49
+ setExpanded(trigger, false);
50
+ if (detail) {
51
+ setHidden(detail, true);
52
+ announce(`${trigger.textContent.trim()} collapsed`);
53
+ }
54
+ this.dispatch('collapsed', { detail: { trigger, detail } });
55
+ }
56
+
57
+ triggerTargetConnected(trigger) {
58
+ if (!trigger.hasAttribute('aria-expanded')) trigger.setAttribute('aria-expanded', 'false');
59
+ this.rovingTabIndex?.updateItems(this.triggerTargets);
60
+ }
61
+
62
+ triggerTargetDisconnected() {
63
+ this.rovingTabIndex?.updateItems(this.triggerTargets);
64
+ }
65
+
66
+ timeTargetConnected(el) {
67
+ if (!Object.keys(this.dateFormatValue).length || el.textContent.trim()) return;
68
+ const formatted = DateFormatter.format(el.getAttribute('datetime'), this.dateFormatValue);
69
+ if (formatted) el.textContent = formatted;
70
+ }
71
+ }
package/src/index.js CHANGED
@@ -17,8 +17,12 @@ export { fuzzyMatcher, filterOptions } from './researcher.js';
17
17
  export { Formatter, FORMATTER_TYPES } from './plumbers/formatter.js';
18
18
 
19
19
  // Export Stimulus controllers
20
+ export { default as CalendarDecadeController } from './controllers/calendar_decade_controller.js';
21
+ export { default as CalendarDecadeSelectorController } from './controllers/calendar_decade_selector_controller.js';
20
22
  export { default as CalendarMonthController } from './controllers/calendar_month_controller.js';
21
- export { default as CalendarMonthObserverController } from './controllers/calendar_month_observer_controller.js';
23
+ export { default as CalendarMonthSelectorController } from './controllers/calendar_month_selector_controller.js';
24
+ export { default as CalendarYearController } from './controllers/calendar_year_controller.js';
25
+ export { default as CalendarYearSelectorController } from './controllers/calendar_year_selector_controller.js';
22
26
  export { default as ClipboardController } from './controllers/clipboard_controller.js';
23
27
  export { default as ComboboxDateController } from './controllers/combobox_date_controller.js';
24
28
  export { default as ComboboxDropdownController } from './controllers/combobox_dropdown_controller.js';
@@ -31,3 +35,4 @@ export { default as InputClearableController } from './controllers/input_clearab
31
35
  export { default as ModalController } from './controllers/modal_controller.js';
32
36
  export { default as PannerController } from './controllers/panner_controller.js';
33
37
  export { default as PopoverController } from './controllers/popover_controller.js';
38
+ export { default as TimelineController } from './controllers/timeline_controller.js';
@@ -0,0 +1,85 @@
1
+ import Plumber from './plumber';
2
+ import { tryParseDate } from './plumber/date';
3
+
4
+ export class CalendarDaySelector extends Plumber {
5
+ constructor(controller, options = {}) {
6
+ super(controller, options);
7
+ this.handle = this.handle.bind(this);
8
+ this.onSelect = options.onSelect || null;
9
+ }
10
+
11
+ attach() {
12
+ this.element.addEventListener('click', this.handle);
13
+ }
14
+ detach() {
15
+ this.element.removeEventListener('click', this.handle);
16
+ }
17
+
18
+ handle(event) {
19
+ if (!(event.target instanceof HTMLElement)) return;
20
+ event.preventDefault();
21
+ const input = event.target instanceof HTMLTimeElement ? event.target.parentElement : event.target;
22
+ if (input.disabled || input.getAttribute('aria-disabled') === 'true') return;
23
+ const time = event.target instanceof HTMLTimeElement ? event.target : event.target.querySelector('time');
24
+ if (!time) return;
25
+ const date = tryParseDate(time.dateTime);
26
+ if (!date) return;
27
+ this.dispatch('selecting', { target: input });
28
+ const iso = date.toISOString();
29
+ if (this.onSelect) {
30
+ this.awaitCallback(this.onSelect, iso);
31
+ } else {
32
+ this.dispatch('selected', { detail: { epoch: date.getTime(), iso } });
33
+ }
34
+ }
35
+ }
36
+
37
+ export class CalendarMonthSelector extends Plumber {
38
+ constructor(controller, options = {}) {
39
+ super(controller, options);
40
+ this.handle = this.handle.bind(this);
41
+ }
42
+
43
+ attach() {
44
+ this.element.addEventListener('click', this.handle);
45
+ }
46
+ detach() {
47
+ this.element.removeEventListener('click', this.handle);
48
+ }
49
+
50
+ handle(event) {
51
+ const btn = event.target.closest('button[data-month]');
52
+ if (!btn || btn.disabled || btn.getAttribute('aria-disabled') === 'true') return;
53
+ event.preventDefault();
54
+ const month = parseInt(btn.dataset.month, 10);
55
+ if (isNaN(month)) return;
56
+ this.dispatch('selected', { detail: { month } });
57
+ }
58
+ }
59
+
60
+ export class CalendarYearSelector extends Plumber {
61
+ constructor(controller, options = {}) {
62
+ super(controller, options);
63
+ this.handle = this.handle.bind(this);
64
+ }
65
+
66
+ attach() {
67
+ this.element.addEventListener('click', this.handle);
68
+ }
69
+ detach() {
70
+ this.element.removeEventListener('click', this.handle);
71
+ }
72
+
73
+ handle(event) {
74
+ const btn = event.target.closest('button[data-year]');
75
+ if (!btn || btn.disabled || btn.getAttribute('aria-disabled') === 'true') return;
76
+ event.preventDefault();
77
+ const year = parseInt(btn.dataset.year, 10);
78
+ if (isNaN(year)) return;
79
+ this.dispatch('selected', { detail: { year } });
80
+ }
81
+ }
82
+
83
+ export const attachCalendarDaySelector = (controller, options) => new CalendarDaySelector(controller, options);
84
+ export const attachCalendarMonthSelector = (controller, options) => new CalendarMonthSelector(controller, options);
85
+ export const attachCalendarYearSelector = (controller, options) => new CalendarYearSelector(controller, options);
@@ -1,7 +1,9 @@
1
1
  import Plumber from './plumber';
2
2
  import { isValidDate, tryParseDate } from './plumber/date';
3
3
 
4
- const DAYS_OF_WEEK = 7;
4
+ const DAYS_PER_ROW = 7;
5
+ const YEAR_SIZE = 12;
6
+ const DECADE_SIZE = 10;
5
7
 
6
8
  const defaultOptions = {
7
9
  locales: ['default'],
@@ -77,6 +79,7 @@ export class Calendar extends Plumber {
77
79
  this.daysOfWeek = this.buildDaysOfWeek();
78
80
  this.daysOfMonth = this.buildDaysOfMonth();
79
81
  this.monthsOfYear = this.buildMonthsOfYear();
82
+ this.yearsOfDecade = this.buildYearsOfDecade();
80
83
  }
81
84
 
82
85
  /**
@@ -130,8 +133,8 @@ export class Calendar extends Plumber {
130
133
  const current = new Date(currentYear, currentMonth, i);
131
134
  daysOfMonth.push(parseDate(current));
132
135
  }
133
- const mod = daysOfMonth.length % DAYS_OF_WEEK;
134
- const trailing = mod === 0 ? 0 : DAYS_OF_WEEK - mod;
136
+ const mod = daysOfMonth.length % DAYS_PER_ROW;
137
+ const trailing = mod === 0 ? 0 : DAYS_PER_ROW - mod;
135
138
  for (let i = 1; i <= trailing; i++) {
136
139
  const next = new Date(currentYear, currentMonth + 1, i);
137
140
  daysOfMonth.push(parseDate(next));
@@ -149,19 +152,47 @@ export class Calendar extends Plumber {
149
152
  const numericFormatter = new Intl.DateTimeFormat(this.localesValue, { month: 'numeric' });
150
153
 
151
154
  const monthsOfYear = [];
152
- for (let i = 0; i < 12; i++) {
153
- const month = new Date(this.year, i);
155
+ for (let i = 0; i < YEAR_SIZE; i++) {
156
+ const date = new Date(this.year, i);
157
+ const monthStart = new Date(this.year, i, 1);
158
+ const monthEnd = new Date(this.year, i + 1, 0);
159
+ const shortName = shortFormatter.format(date);
160
+ const longName = longFormatter.format(date);
161
+ const rangeDisabled = (this.since && monthEnd < this.since) || (this.till && monthStart > this.till);
162
+ const listDisabled = this.disabledMonths.some((str) => i == str || shortName === str || longName === str);
154
163
  monthsOfYear.push({
155
- date: month,
156
- value: month.getMonth(),
157
- long: longFormatter.format(month),
158
- short: shortFormatter.format(month),
159
- numeric: numericFormatter.format(month),
164
+ date: date,
165
+ value: date.getMonth(),
166
+ long: longName,
167
+ short: shortName,
168
+ numeric: numericFormatter.format(date),
169
+ disabled: rangeDisabled || listDisabled,
160
170
  });
161
171
  }
162
172
  return monthsOfYear;
163
173
  }
164
174
 
175
+ /**
176
+ * Builds array of year objects spanning the decade containing the current year.
177
+ * Returns 12 entries: decade start - 1 (buffer) through decade start + 10 (buffer).
178
+ * @returns {Array<Object>} Array of year objects
179
+ */
180
+ buildYearsOfDecade() {
181
+ const decadeStart = Math.floor(this.year / DECADE_SIZE) * DECADE_SIZE;
182
+ const yearsOfDecade = [];
183
+ for (let i = decadeStart - 1; i <= decadeStart + DECADE_SIZE; i++) {
184
+ const bufferYear = i < decadeStart || i > decadeStart + DECADE_SIZE - 1;
185
+ const rangeDisabled = (this.since && i < this.since.getFullYear()) || (this.till && i > this.till.getFullYear());
186
+ const listDisabled = this.disabledYears.some((str) => i == str);
187
+ yearsOfDecade.push({
188
+ value: i,
189
+ current: i === this.year,
190
+ disabled: bufferYear || rangeDisabled || listDisabled,
191
+ });
192
+ }
193
+ return yearsOfDecade;
194
+ }
195
+
165
196
  /**
166
197
  * Gets the current "today" reference date.
167
198
  * @returns {Date} Today's date
@@ -380,6 +411,9 @@ export class Calendar extends Plumber {
380
411
  get monthsOfYear() {
381
412
  return context.monthsOfYear;
382
413
  },
414
+ get yearsOfDecade() {
415
+ return context.yearsOfDecade;
416
+ },
383
417
  navigate: async (to) => await context.navigate(to),
384
418
  step: async (type, value) => await context.step(type, value),
385
419
  isDisabled: (date) => context.isDisabled(date),
@@ -3,6 +3,11 @@
3
3
  */
4
4
 
5
5
  export { initCalendar } from './calendar';
6
+ export {
7
+ attachCalendarDaySelector,
8
+ attachCalendarMonthSelector,
9
+ attachCalendarYearSelector,
10
+ } from './calendar-selector';
6
11
  export { attachContentLoader } from './content_loader';
7
12
  export { attachDismisser } from './dismisser';
8
13
  export { attachFlipper } from './flipper';
@@ -1,5 +1,6 @@
1
1
  import Plumber from './plumber';
2
2
  import { visibilityConfig } from './plumber/config';
3
+ import { setExpanded, setHidden } from '../accessibility/aria';
3
4
 
4
5
  const defaultOptions = {
5
6
  visibility: 'visibility',
@@ -62,8 +63,7 @@ export class Visibility extends Plumber {
62
63
  if (visible) target.classList.remove(hiddenClass);
63
64
  else target.classList.add(hiddenClass);
64
65
  } else {
65
- if (visible) target.removeAttribute('hidden');
66
- else target.setAttribute('hidden', true);
66
+ setHidden(target, !visible);
67
67
  }
68
68
  }
69
69
 
@@ -72,7 +72,7 @@ export class Visibility extends Plumber {
72
72
  * @param {boolean} isExpanded - True to mark as expanded, false to mark as collapsed
73
73
  */
74
74
  activate(isExpanded) {
75
- if (this.activator) this.activator.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
75
+ if (this.activator) setExpanded(this.activator, isExpanded);
76
76
  }
77
77
 
78
78
  /**
@@ -1,27 +0,0 @@
1
- import { Controller } from '@hotwired/stimulus';
2
- import { tryParseDate } from '../plumbers/plumber/date';
3
-
4
- export default class extends Controller {
5
- onSelect(event) {
6
- if (!(event.target instanceof HTMLElement)) return;
7
-
8
- event.preventDefault();
9
- const input = event.target instanceof HTMLTimeElement ? event.target.parentElement : event.target;
10
- if (input.disabled || input.getAttribute('aria-disabled') === 'true') return;
11
-
12
- this.dispatch('selecting', { target: input });
13
- const time = event.target instanceof HTMLTimeElement ? event.target : event.target.querySelector('time');
14
- if (!time) return console.error(`unable to locate time element within ${input}`);
15
-
16
- const date = tryParseDate(time.dateTime);
17
- if (!date) return console.error(`unable to parse ${time.dateTime} found within the time element`);
18
-
19
- this.select(date.toISOString());
20
- }
21
-
22
- select(iso) {
23
- const date = tryParseDate(iso);
24
- if (!date) return;
25
- this.dispatch('selected', { detail: { epoch: date.getTime(), iso } });
26
- }
27
- }