@stimulus-plumbers/controllers 0.4.0 → 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.
@@ -1,29 +1,59 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
  import { initCalendar } from '../plumbers';
3
+ import { attachCalendarDaySelector } from '../plumbers/calendar-selector';
3
4
  import { tryParseDate } from '../plumbers/plumber/date';
4
5
 
5
6
  export default class extends Controller {
6
7
  static targets = ['daysOfWeek', 'daysOfMonth'];
7
- static classes = ['dayOfWeek', 'dayOfMonth', 'row'];
8
+ static classes = ['dayOfWeek', 'dayOfMonth', 'dayOfOtherMonth', 'row'];
8
9
  static values = {
10
+ year: Number,
11
+ month: Number,
12
+ today: { type: String, default: '' },
13
+ selected: { type: String, default: '' },
14
+ since: { type: String, default: '' },
15
+ till: { type: String, default: '' },
9
16
  locales: { type: Array, default: ['default'] },
10
17
  weekdayFormat: { type: String, default: 'short' },
11
18
  dayFormat: { type: String, default: 'numeric' },
12
19
  daysOfOtherMonth: { type: Boolean, default: false },
13
- today: { type: String, default: '' },
14
- selected: { type: String, default: '' },
15
20
  };
16
21
 
17
22
  initialize() {
18
- initCalendar(this, { today: this.todayValue });
23
+ this.selector = attachCalendarDaySelector(this, { onSelect: 'select' });
24
+ initCalendar(this, {
25
+ today: this.todayValue,
26
+ since: this.sinceValue || null,
27
+ till: this.tillValue || null,
28
+ });
19
29
  }
20
30
 
21
31
  connect() {
22
- this.draw();
32
+ this.selector.attach();
33
+ this.navigated();
23
34
  }
24
35
 
25
- navigated() {
26
- this.draw();
36
+ disconnect() {
37
+ this.selector.detach();
38
+ }
39
+
40
+ yearValueChanged() {
41
+ if (!this.calendar || !this.hasYearValue) return;
42
+ this._scheduleNavigate();
43
+ }
44
+ monthValueChanged() {
45
+ if (!this.calendar || !this.hasYearValue) return;
46
+ this._scheduleNavigate();
47
+ }
48
+
49
+ _scheduleNavigate() {
50
+ if (this._navigatePending) return;
51
+ this._navigatePending = true;
52
+ queueMicrotask(async () => {
53
+ this._navigatePending = false;
54
+ await this.calendar.navigate(this.currentDate);
55
+ this.navigated();
56
+ });
27
57
  }
28
58
 
29
59
  selectedValueChanged() {
@@ -39,20 +69,38 @@ export default class extends Controller {
39
69
  if (!parsed) return;
40
70
 
41
71
  const time = this.daysOfMonthTarget.querySelector(`time[datetime="${parsed.toISOString()}"]`);
42
- if (time) time.closest('[aria-selected]').setAttribute('aria-selected', 'true');
72
+ if (time) time.closest('[aria-selected]')?.setAttribute('aria-selected', 'true');
73
+ }
74
+
75
+ navigate(date) {
76
+ this.yearValue = date.getFullYear();
77
+ this.monthValue = date.getMonth();
78
+ }
79
+
80
+ step(unit, dir) {
81
+ return this.calendar.step(unit, dir);
43
82
  }
44
83
 
45
- onSelect(event) {
46
- const iso = event.detail?.iso;
47
- if (iso) this.selectedValue = iso;
84
+ select(iso) {
85
+ const date = tryParseDate(iso);
86
+ if (!date) return;
87
+ this.selectedValue = iso;
88
+ if (date.getMonth() !== this.calendar.month || date.getFullYear() !== this.calendar.year) {
89
+ this.calendar.navigate(date);
90
+ }
91
+ this.dispatch('selected', { detail: { epoch: date.getTime(), iso } });
48
92
  }
49
93
 
50
- draw() {
94
+ navigated() {
51
95
  this.drawDaysOfWeek();
52
96
  this.drawDaysOfMonth();
53
97
  this.selectedValueChanged();
54
98
  }
55
99
 
100
+ get currentDate() {
101
+ return new Date(this.yearValue, this.monthValue, 1);
102
+ }
103
+
56
104
  createDayElement(day, { selectable = false, disabled = false } = {}) {
57
105
  const element = document.createElement(selectable ? 'button' : 'div');
58
106
  element.tabIndex = -1;
@@ -93,17 +141,21 @@ export default class extends Controller {
93
141
  const today = new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime();
94
142
  const daysOfMonth = [];
95
143
  for (const date of this.calendar.daysOfMonth) {
96
- const dayDisabled =
97
- !date.current || this.calendar.isDisabled(date.date) || !this.calendar.isWithinRange(date.date);
144
+ const dayRuleDisabled = this.calendar.isDisabled(date.date) || !this.calendar.isWithinRange(date.date);
145
+ const dayOutsideNavigable = !date.current && this.daysOfOtherMonthValue && !dayRuleDisabled;
98
146
  const dayText = date.current || this.daysOfOtherMonthValue ? date.value : '';
99
147
  const dayElement = this.createDayElement(dayText, {
100
- selectable: date.current,
101
- disabled: dayDisabled,
148
+ selectable: date.current || dayOutsideNavigable,
149
+ disabled: date.current ? dayRuleDisabled : !dayOutsideNavigable,
102
150
  });
103
151
 
104
152
  if (today === date.date.getTime()) dayElement.setAttribute('aria-current', 'date');
105
153
  if (date.current || this.daysOfOtherMonthValue) dayElement.setAttribute('aria-selected', '');
106
- if (this.hasDayOfMonthClass) dayElement.classList.add(...this.dayOfMonthClasses);
154
+ if (!date.current && this.daysOfOtherMonthValue && this.hasDayOfOtherMonthClass) {
155
+ dayElement.classList.add(...this.dayOfOtherMonthClasses);
156
+ } else if (this.hasDayOfMonthClass) {
157
+ dayElement.classList.add(...this.dayOfMonthClasses);
158
+ }
107
159
 
108
160
  const time = document.createElement('time');
109
161
  time.dateTime = date.iso;
@@ -0,0 +1,14 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { attachCalendarDaySelector } from '../plumbers/calendar-selector';
3
+
4
+ export default class extends Controller {
5
+ initialize() {
6
+ this.selector = attachCalendarDaySelector(this);
7
+ }
8
+ connect() {
9
+ this.selector.attach();
10
+ }
11
+ disconnect() {
12
+ this.selector.detach();
13
+ }
14
+ }
@@ -0,0 +1,104 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { initCalendar } from '../plumbers';
3
+ import { attachCalendarMonthSelector } from '../plumbers/calendar-selector';
4
+ import { tryParseDate } from '../plumbers/plumber/date';
5
+
6
+ const MONTHS_PER_ROW = 4;
7
+
8
+ export default class extends Controller {
9
+ static targets = ['grid'];
10
+ static values = {
11
+ current: Number,
12
+ today: { type: String, default: '' },
13
+ selected: { type: String, default: '' },
14
+ since: { type: String, default: '' },
15
+ till: { type: String, default: '' },
16
+ locales: { type: Array, default: ['default'] },
17
+ monthFormat: { type: String, default: 'short' },
18
+ };
19
+
20
+ initialize() {
21
+ this.selector = attachCalendarMonthSelector(this);
22
+ initCalendar(this, {
23
+ today: this.todayValue,
24
+ year: this.currentValue || undefined,
25
+ since: this.sinceValue || null,
26
+ till: this.tillValue || null,
27
+ });
28
+ }
29
+
30
+ connect() {
31
+ this.selector.attach();
32
+ this.navigated();
33
+ }
34
+
35
+ disconnect() {
36
+ this.selector.detach();
37
+ }
38
+
39
+ currentValueChanged() {
40
+ if (!this.calendar || !this.hasCurrentValue) return;
41
+ this.calendar.navigate(this.currentDate);
42
+ }
43
+
44
+ selectedValueChanged() {
45
+ if (!this.hasGridTarget) return;
46
+ const selected = tryParseDate(this.selectedValue);
47
+ this.gridTarget.querySelectorAll('button[data-month]').forEach((btn) => {
48
+ const month = parseInt(btn.dataset.month, 10) - 1; // 0-indexed
49
+ btn.setAttribute('aria-selected', selected && selected.getMonth() === month ? 'true' : 'false');
50
+ });
51
+ }
52
+
53
+ navigate(date) {
54
+ this.currentValue = date.getFullYear();
55
+ }
56
+
57
+ step(unit, dir) {
58
+ return this.calendar.step(unit, dir);
59
+ }
60
+
61
+ navigated() {
62
+ this.drawGrid();
63
+ }
64
+
65
+ get currentDate() {
66
+ return new Date(this.currentValue, 0, 1);
67
+ }
68
+
69
+ drawGrid() {
70
+ if (!this.hasGridTarget) return;
71
+
72
+ const { year, monthsOfYear } = this.calendar;
73
+ const today = this.calendar.today;
74
+ const selectedDate = tryParseDate(this.selectedValue);
75
+ const cells = [];
76
+
77
+ const formatter = new Intl.DateTimeFormat(this.localesValue, { month: this.monthFormatValue });
78
+
79
+ for (const m of monthsOfYear) {
80
+ const btn = document.createElement('button');
81
+ btn.type = 'button';
82
+ btn.textContent = formatter.format(m.date);
83
+ btn.dataset.month = m.value + 1; // 1-indexed
84
+ btn.setAttribute('role', 'gridcell');
85
+ const isSelected = selectedDate && selectedDate.getMonth() === m.value;
86
+ btn.setAttribute('aria-selected', isSelected ? 'true' : 'false');
87
+ if (m.value === today.getMonth() && year === today.getFullYear()) {
88
+ btn.setAttribute('aria-current', 'month');
89
+ }
90
+ if (m.disabled) btn.setAttribute('aria-disabled', 'true');
91
+ cells.push(btn);
92
+ }
93
+
94
+ const rowgroup = document.createElement('div');
95
+ rowgroup.setAttribute('role', 'rowgroup');
96
+ for (let i = 0; i < cells.length; i += MONTHS_PER_ROW) {
97
+ const row = document.createElement('div');
98
+ row.setAttribute('role', 'row');
99
+ for (const cell of cells.slice(i, i + MONTHS_PER_ROW)) row.appendChild(cell);
100
+ rowgroup.appendChild(row);
101
+ }
102
+ this.gridTarget.replaceChildren(rowgroup);
103
+ }
104
+ }
@@ -0,0 +1,14 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { attachCalendarMonthSelector } from '../plumbers/calendar-selector';
3
+
4
+ export default class extends Controller {
5
+ initialize() {
6
+ this.selector = attachCalendarMonthSelector(this);
7
+ }
8
+ connect() {
9
+ this.selector.attach();
10
+ }
11
+ disconnect() {
12
+ this.selector.detach();
13
+ }
14
+ }
@@ -4,8 +4,8 @@ import { tryParseDate } from '../plumbers/plumber/date';
4
4
  const VIEWS = ['month', 'year', 'decade'];
5
5
 
6
6
  export default class extends Controller {
7
- static targets = ['previous', 'next', 'day', 'month', 'year', 'viewTitle', 'monthView', 'yearView', 'decadeView'];
8
- static outlets = ['calendar-month'];
7
+ static targets = ['previous', 'next', 'day', 'month', 'year', 'viewTitle'];
8
+ static outlets = ['calendar-month', 'calendar-year', 'calendar-decade'];
9
9
  static values = {
10
10
  date: String,
11
11
  view: { type: String, default: 'month' },
@@ -18,8 +18,6 @@ export default class extends Controller {
18
18
  initialize() {
19
19
  this.previous = this.previous.bind(this);
20
20
  this.next = this.next.bind(this);
21
- this.selectMonth = this.selectMonth.bind(this);
22
- this.selectYear = this.selectYear.bind(this);
23
21
  }
24
22
 
25
23
  async calendarMonthOutletConnected() {
@@ -30,7 +28,7 @@ export default class extends Controller {
30
28
  this.draw();
31
29
  }
32
30
 
33
- onSelect(event) {
31
+ onDaySelect(event) {
34
32
  this.dateValue = event.detail.iso;
35
33
  this.draw();
36
34
  this.dispatch('selected', { detail: { value: event.detail.iso }, bubbles: true });
@@ -44,22 +42,23 @@ export default class extends Controller {
44
42
  }
45
43
  }
46
44
 
47
- async selectMonth(event) {
48
- const btn = event.target.closest('button[data-month]');
49
- if (!btn) return;
50
- const month = parseInt(btn.dataset.month, 10) - 1; // data-month is 1-indexed
45
+ async onMonthSelect(event) {
46
+ const { month } = event.detail; // 1-indexed
47
+ if (!this.hasCalendarMonthOutlet) return;
51
48
  const { year } = this.calendarMonthOutlet.calendar;
52
- await this.calendarMonthOutlet.calendar.navigate(new Date(year, month, 1));
49
+ await this.calendarMonthOutlet.calendar.navigate(new Date(year, month - 1, 1));
53
50
  this.viewValue = 'month';
54
51
  this.draw();
55
52
  }
56
53
 
57
- async selectYear(event) {
58
- const btn = event.target.closest('button[data-year]');
59
- if (!btn || btn.getAttribute('aria-disabled') === 'true') return;
60
- const year = parseInt(btn.dataset.year, 10);
54
+ async onYearSelect(event) {
55
+ const { year } = event.detail;
56
+ if (!this.hasCalendarMonthOutlet) return;
61
57
  const { month } = this.calendarMonthOutlet.calendar;
62
58
  await this.calendarMonthOutlet.calendar.navigate(new Date(year, month, 1));
59
+ if (this.hasCalendarYearOutlet) {
60
+ this.calendarYearOutlet.navigate(this.calendarMonthOutlet.calendar.current);
61
+ }
63
62
  this.viewValue = 'year';
64
63
  this.draw();
65
64
  }
@@ -74,6 +73,7 @@ export default class extends Controller {
74
73
 
75
74
  async previous() {
76
75
  await this.calendarMonthOutlet.calendar.step(...this.stepArgs(-1));
76
+ this.syncOutletValues();
77
77
  this.draw();
78
78
  }
79
79
 
@@ -87,32 +87,15 @@ export default class extends Controller {
87
87
 
88
88
  async next() {
89
89
  await this.calendarMonthOutlet.calendar.step(...this.stepArgs(1));
90
+ this.syncOutletValues();
90
91
  this.draw();
91
92
  }
92
93
 
93
- yearViewTargetConnected(target) {
94
- target.addEventListener('click', this.selectMonth);
95
- }
96
-
97
- yearViewTargetDisconnected(target) {
98
- target.removeEventListener('click', this.selectMonth);
99
- }
100
-
101
- decadeViewTargetConnected(target) {
102
- target.addEventListener('click', this.selectYear);
103
- }
104
-
105
- decadeViewTargetDisconnected(target) {
106
- target.removeEventListener('click', this.selectYear);
107
- }
108
-
109
94
  draw() {
110
95
  this.drawDay();
111
96
  this.drawMonth();
112
97
  this.drawYear();
113
98
  this.drawViewTitle();
114
- this.drawYearView();
115
- this.drawDecadeView();
116
99
  this.drawView();
117
100
  }
118
101
 
@@ -146,49 +129,18 @@ export default class extends Controller {
146
129
  this.viewTitleTarget.textContent = this.viewTitleLabel(year, month);
147
130
  }
148
131
 
149
- drawYearView() {
150
- if (!this.hasYearViewTarget || !this.hasCalendarMonthOutlet) return;
151
-
152
- const { year, month, monthsOfYear } = this.calendarMonthOutlet.calendar;
153
- const today = this.calendarMonthOutlet.calendar.today;
154
- const cells = [];
155
-
156
- for (const m of monthsOfYear) {
157
- const btn = document.createElement('button');
158
- btn.type = 'button';
159
- btn.textContent = m.short;
160
- btn.dataset.month = m.value + 1; // 1-indexed to match SSR convention
161
- btn.setAttribute('role', 'gridcell');
162
- btn.setAttribute('aria-selected', m.value === month ? 'true' : 'false');
163
- if (m.value === today.getMonth() && year === today.getFullYear()) {
164
- btn.setAttribute('aria-current', 'month');
165
- }
166
- cells.push(btn);
167
- }
168
-
169
- this.yearViewTarget.replaceChildren(...cells);
132
+ syncOutletValues() {
133
+ if (!this.hasCalendarMonthOutlet) return;
134
+ const { current } = this.calendarMonthOutlet.calendar;
135
+ if (!current) return;
136
+ if (this.hasCalendarYearOutlet) this.calendarYearOutlet.navigate(current);
137
+ if (this.hasCalendarDecadeOutlet) this.calendarDecadeOutlet.navigate(current);
170
138
  }
171
139
 
172
- drawDecadeView() {
173
- if (!this.hasDecadeViewTarget || !this.hasCalendarMonthOutlet) return;
174
-
175
- const { year, yearsOfDecade } = this.calendarMonthOutlet.calendar;
176
- const todayYear = this.calendarMonthOutlet.calendar.today.getFullYear();
177
- const cells = [];
178
-
179
- for (const y of yearsOfDecade) {
180
- const btn = document.createElement('button');
181
- btn.type = 'button';
182
- btn.textContent = y.value;
183
- btn.dataset.year = y.value;
184
- btn.setAttribute('role', 'gridcell');
185
- btn.setAttribute('aria-selected', y.value === year ? 'true' : 'false');
186
- if (y.value === todayYear) btn.setAttribute('aria-current', 'year');
187
- if (y.outside) btn.setAttribute('aria-disabled', 'true');
188
- cells.push(btn);
189
- }
190
-
191
- this.decadeViewTarget.replaceChildren(...cells);
140
+ stepArgs(direction) {
141
+ if (this.viewValue === 'year') return ['year', direction];
142
+ if (this.viewValue === 'decade') return ['year', direction * 10];
143
+ return ['month', direction];
192
144
  }
193
145
 
194
146
  viewTitleLabel(year, month) {
@@ -200,22 +152,21 @@ export default class extends Controller {
200
152
  return new Intl.DateTimeFormat(this.localesValue, { month: 'long', year: 'numeric' }).format(new Date(year, month));
201
153
  }
202
154
 
203
- stepArgs(direction) {
204
- if (this.viewValue === 'year') return ['year', direction];
205
- if (this.viewValue === 'decade') return ['year', direction * 10];
206
- return ['month', direction];
207
- }
208
-
209
155
  drawView() {
210
- if (this.hasMonthViewTarget) this.monthViewTarget.hidden = this.viewValue !== 'month';
211
- if (this.hasYearViewTarget) this.yearViewTarget.hidden = this.viewValue !== 'year';
212
- if (this.hasDecadeViewTarget) this.decadeViewTarget.hidden = this.viewValue !== 'decade';
156
+ const inMonthView = this.viewValue === 'month';
157
+ const inYearView = this.viewValue === 'year';
158
+ const inDecadeView = this.viewValue === 'decade';
213
159
 
214
160
  if (this.hasCalendarMonthOutlet) {
215
161
  const outlet = this.calendarMonthOutlet;
216
- const monthView = this.viewValue === 'month';
217
- if (outlet.hasDaysOfWeekTarget) outlet.daysOfWeekTarget.hidden = !monthView;
218
- if (outlet.hasDaysOfMonthTarget) outlet.daysOfMonthTarget.hidden = !monthView;
162
+ if (outlet.hasDaysOfWeekTarget) outlet.daysOfWeekTarget.hidden = !inMonthView;
163
+ if (outlet.hasDaysOfMonthTarget) outlet.daysOfMonthTarget.hidden = !inMonthView;
164
+ }
165
+ if (this.hasCalendarYearOutlet) {
166
+ this.calendarYearOutletElement.hidden = !inYearView;
167
+ }
168
+ if (this.hasCalendarDecadeOutlet) {
169
+ this.calendarDecadeOutletElement.hidden = !inDecadeView;
219
170
  }
220
171
  }
221
172
  }
@@ -1,6 +1,7 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
  import { Requestor } from '../requestor';
3
3
  import { filterOptions } from '../researcher';
4
+ import { ListboxNavigation } from '../accessibility/keyboard';
4
5
 
5
6
  export default class extends Controller {
6
7
  static targets = ['listbox', 'loading', 'empty'];
@@ -14,6 +15,16 @@ export default class extends Controller {
14
15
  this._requestor = new Requestor();
15
16
  }
16
17
 
18
+ connect() {
19
+ if (this.hasListboxTarget) {
20
+ this.listboxNav = new ListboxNavigation(this.listboxTarget);
21
+ }
22
+ }
23
+
24
+ disconnect() {
25
+ this._requestor.cancel();
26
+ }
27
+
17
28
  onSelect(event) {
18
29
  const option = event.target.closest('[role="option"]');
19
30
  if (!option || option.getAttribute('aria-disabled') === 'true') return;
@@ -29,27 +40,7 @@ export default class extends Controller {
29
40
  }
30
41
 
31
42
  onNavigate(event) {
32
- if (!['ArrowUp', 'ArrowDown', 'Enter', ' '].includes(event.key)) return;
33
- event.preventDefault();
34
- if (event.key === 'Enter' || event.key === ' ') {
35
- this.listboxTarget.querySelector('[aria-selected="true"]')?.click();
36
- return;
37
- }
38
- this.step(event.key === 'ArrowDown' ? 1 : -1);
39
- }
40
-
41
- step(delta) {
42
- const options = [
43
- ...this.listboxTarget.querySelectorAll('[role="option"]:not([aria-disabled="true"]):not([hidden])'),
44
- ];
45
- if (!options.length) return;
46
- const current = this.listboxTarget.querySelector('[aria-selected="true"]');
47
- const idx = options.indexOf(current);
48
- const next = delta > 0 ? options[Math.min(idx + 1, options.length - 1)] : options[Math.max(idx - 1, 0)];
49
- if (!next || next === current) return;
50
- options.forEach((o) => o.setAttribute('aria-selected', 'false'));
51
- next.setAttribute('aria-selected', 'true');
52
- next.scrollIntoView({ block: 'nearest' });
43
+ this.listboxNav?.handleKeyDown(event);
53
44
  }
54
45
 
55
46
  filter(query) {
@@ -90,8 +81,4 @@ export default class extends Controller {
90
81
  setEmpty(on) {
91
82
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !on;
92
83
  }
93
-
94
- disconnect() {
95
- this._requestor.cancel();
96
- }
97
84
  }
@@ -8,7 +8,6 @@ export default class extends Controller {
8
8
  };
9
9
  static outlets = ['combobox-dropdown'];
10
10
 
11
- // Commits the value only; popover#closeOnSelect handles dismissal.
12
11
  onSelect(event) {
13
12
  if (event.detail?.value !== undefined) this.valueValue = event.detail.value;
14
13
  }
@@ -22,7 +22,10 @@ export default class extends Controller {
22
22
  modal.addEventListener('cancel', this.onCancel);
23
23
  modal.addEventListener('click', this.onBackdropClick);
24
24
  } else {
25
- this.focusTrap = new FocusTrap(modal, { escapeDeactivates: true });
25
+ this.focusTrap = new FocusTrap(modal, {
26
+ escapeDeactivates: true,
27
+ onDeactivate: () => this.close(),
28
+ });
26
29
  attachDismisser(this, { element: modal });
27
30
  }
28
31
  }
@@ -1,5 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
  import { focusFirst } from '../accessibility/focus';
3
+ import { announce } from '../accessibility/aria';
3
4
  import { attachContentLoader, attachDismisser, attachVisibility } from '../plumbers';
4
5
 
5
6
  export default class extends Controller {
@@ -11,6 +12,8 @@ export default class extends Controller {
11
12
  reload: { type: String, default: 'never' },
12
13
  staleAfter: { type: Number, default: 3600 },
13
14
  closeOnSelect: { type: Boolean, default: true },
15
+ announceOpen: { type: String, default: 'Panel opened' },
16
+ announceClose: { type: String, default: 'Panel closed' },
14
17
  };
15
18
 
16
19
  connect() {
@@ -55,10 +58,12 @@ export default class extends Controller {
55
58
  async shown() {
56
59
  await this.load();
57
60
  if (this.hasPanelTarget) focusFirst(this.panelTarget);
61
+ announce(this.announceOpenValue);
58
62
  }
59
63
 
60
64
  async hidden() {
61
65
  if (this.hasTriggerTarget) this.triggerTarget.focus();
66
+ announce(this.announceCloseValue);
62
67
  }
63
68
 
64
69
  canLoad() {
@@ -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 CalendarObserverController } from './controllers/calendar_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';