phlex_kit 0.8.1 → 0.9.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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +11 -0
  3. data/app/components/phlex_kit/accordion/accordion.css +2 -2
  4. data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
  5. data/app/components/phlex_kit/alert/alert.css +3 -3
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
  9. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
  10. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  11. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  12. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  13. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  14. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  15. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  16. data/app/components/phlex_kit/badge/badge.css +4 -4
  17. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  18. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  19. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  20. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  21. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  22. data/app/components/phlex_kit/button/button.css +6 -6
  23. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  24. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  25. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  26. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  27. data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
  28. data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
  29. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  30. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  31. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  32. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  34. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  35. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  36. data/app/components/phlex_kit/chart/chart_controller.js +32 -9
  37. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  38. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  39. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  40. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  41. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  42. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  43. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  44. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  45. data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
  46. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  47. data/app/components/phlex_kit/command/command.css +4 -4
  48. data/app/components/phlex_kit/command/command_controller.js +51 -10
  49. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  50. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  51. data/app/components/phlex_kit/command/command_input.rb +6 -2
  52. data/app/components/phlex_kit/command/command_item.rb +4 -1
  53. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  54. data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
  55. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  56. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  57. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  58. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  59. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  60. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  61. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  62. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  63. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  64. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  65. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
  66. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  67. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
  68. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  69. data/app/components/phlex_kit/field/field.css +1 -1
  70. data/app/components/phlex_kit/field/field_error.rb +5 -6
  71. data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
  72. data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
  73. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  74. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  75. data/app/components/phlex_kit/input/input.css +1 -1
  76. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  77. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  78. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  79. data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
  80. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  81. data/app/components/phlex_kit/item/item.css +1 -1
  82. data/app/components/phlex_kit/item/item_group.rb +4 -2
  83. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  84. data/app/components/phlex_kit/marker/marker.css +1 -1
  85. data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
  86. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  87. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  88. data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
  89. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  90. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  91. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
  92. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  93. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  94. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  95. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
  96. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  97. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  98. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  99. data/app/components/phlex_kit/popover/popover.css +1 -1
  100. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  101. data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
  102. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  103. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  104. data/app/components/phlex_kit/select/select.css +3 -3
  105. data/app/components/phlex_kit/select/select.rb +4 -1
  106. data/app/components/phlex_kit/select/select_controller.js +25 -0
  107. data/app/components/phlex_kit/select/select_item.rb +5 -1
  108. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  109. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  110. data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
  111. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  112. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  113. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  114. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  115. data/app/components/phlex_kit/slider/slider.css +1 -1
  116. data/app/components/phlex_kit/slider/slider.rb +3 -1
  117. data/app/components/phlex_kit/switch/switch.rb +6 -1
  118. data/app/components/phlex_kit/table/table.css +5 -3
  119. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  120. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  121. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  122. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
  123. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
  124. data/app/components/phlex_kit/toast/toast.css +32 -14
  125. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  126. data/app/components/phlex_kit/toast/toast_region.rb +13 -3
  127. data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
  128. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  129. data/app/components/phlex_kit/toggle/toggle.rb +6 -2
  130. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  131. data/app/components/phlex_kit/typography/heading.rb +9 -3
  132. data/app/components/phlex_kit/typography/typography.css +4 -3
  133. data/lib/phlex_kit/version.rb +1 -1
  134. metadata +1 -1
@@ -9,6 +9,9 @@ module PhlexKit
9
9
  def view_template
10
10
  table(**mix({
11
11
  class: "pk-calendar-body",
12
+ # The day grid is a real ARIA grid: this table is role="grid", the
13
+ # controller-rendered rows are role="row", the <td>s gridcells.
14
+ role: "grid",
12
15
  data: {
13
16
  phlex_kit__calendar_target: "calendar",
14
17
  # Delegated arrow-key/Home/End navigation for the day grid (the
@@ -17,6 +17,8 @@ export default class extends Controller {
17
17
  "title",
18
18
  "monthSelect",
19
19
  "yearSelect",
20
+ "prevButton",
21
+ "nextButton",
20
22
  "weekdaysTemplate",
21
23
  "disabledDateTemplate",
22
24
  "selectedDateTemplate",
@@ -42,18 +44,18 @@ export default class extends Controller {
42
44
  };
43
45
  static outlets = ["phlex-kit--calendar-input"];
44
46
 
45
- initialize() {
46
- this.updateCalendar(); // Initial calendar render
47
- }
47
+ // No initialize() render: Stimulus fires every value's `…ValueChanged`
48
+ // callback once during value initialization (before connect()), so
49
+ // viewDateValueChanged already performs the initial updateCalendar().
48
50
 
49
51
  nextMonth(e) {
50
52
  e.preventDefault();
51
- this.viewDateValue = this.adjustMonth(1);
53
+ this.viewDateValue = this.clampViewIso(this.adjustMonth(1));
52
54
  }
53
55
 
54
56
  prevMonth(e) {
55
57
  e.preventDefault();
56
- this.viewDateValue = this.adjustMonth(-1);
58
+ this.viewDateValue = this.clampViewIso(this.adjustMonth(-1));
57
59
  }
58
60
 
59
61
  // month/year dropdown caption (native selects)
@@ -61,14 +63,62 @@ export default class extends Controller {
61
63
  const date = this.viewDate();
62
64
  date.setDate(2);
63
65
  date.setMonth(Number(e.target.value));
64
- this.viewDateValue = this.isoDate(date);
66
+ this.viewDateValue = this.clampViewIso(this.isoDate(date));
65
67
  }
66
68
 
67
69
  setYear(e) {
68
70
  const date = this.viewDate();
69
71
  date.setDate(2);
70
72
  date.setFullYear(Number(e.target.value));
71
- this.viewDateValue = this.isoDate(date);
73
+ this.viewDateValue = this.clampViewIso(this.isoDate(date));
74
+ }
75
+
76
+ // --- month-navigation bounds (min/max dates + the year dropdown's range) ---
77
+
78
+ // Earliest/latest navigable months. min/max dates bound directly; a year
79
+ // dropdown additionally bounds the view to its option range (from_year /
80
+ // to_year) so navigation can't drive the select out of range (blank).
81
+ monthBounds() {
82
+ let min = this.minDate();
83
+ let max = this.maxDate();
84
+ if (this.hasYearSelectTarget) {
85
+ const years = [...this.yearSelectTarget.options].map((o) => Number(o.value)).filter(Number.isFinite);
86
+ if (years.length) {
87
+ const yearMin = new Date(Math.min(...years), 0, 1);
88
+ const yearMax = new Date(Math.max(...years), 11, 31);
89
+ if (!min || yearMin > min) min = yearMin;
90
+ if (!max || yearMax < max) max = yearMax;
91
+ }
92
+ }
93
+ return { min, max };
94
+ }
95
+
96
+ monthIndexOf(date) {
97
+ return date.getFullYear() * 12 + date.getMonth();
98
+ }
99
+
100
+ clampViewIso(iso) {
101
+ const date = this.parseDate(iso);
102
+ if (!date) return iso;
103
+ const { min, max } = this.monthBounds();
104
+ if (min && this.monthIndexOf(date) < this.monthIndexOf(min)) {
105
+ return this.isoDate(new Date(min.getFullYear(), min.getMonth(), 2));
106
+ }
107
+ if (max && this.monthIndexOf(date) > this.monthIndexOf(max)) {
108
+ return this.isoDate(new Date(max.getFullYear(), max.getMonth(), 2));
109
+ }
110
+ return iso;
111
+ }
112
+
113
+ syncNavButtons() {
114
+ const { min, max } = this.monthBounds();
115
+ const view = this.monthIndexOf(this.viewDate());
116
+ if (this.hasPrevButtonTarget) {
117
+ this.prevButtonTarget.disabled = Boolean(min && view <= this.monthIndexOf(min));
118
+ }
119
+ if (this.hasNextButtonTarget) {
120
+ this.nextButtonTarget.disabled = Boolean(max && view >= this.monthIndexOf(max));
121
+ }
72
122
  }
73
123
 
74
124
  selectDay(e) {
@@ -162,6 +212,9 @@ export default class extends Controller {
162
212
  return end ? `${this.formatDate(start)} – ${this.formatDate(end)}` : this.formatDate(start);
163
213
  }
164
214
  if (this.modeValue === "multiple") {
215
+ // Deselecting the last date must clear the bound input: return ""
216
+ // (pushed to outlets), not null (skipped by pushToOutlets' guard).
217
+ if (!this.selectedDatesValue.length) return "";
165
218
  return this.selectedDatesValue.map((d) => this.formatDate(this.parseDate(d))).join(", ");
166
219
  }
167
220
  const selected = this.selectedDate();
@@ -193,6 +246,7 @@ export default class extends Controller {
193
246
  this.titleTarget.textContent = this.monthAndYear();
194
247
  }
195
248
  this.syncDropdowns();
249
+ this.syncNavButtons();
196
250
  // innerHTML replacement drops focus — remember the focused day so
197
251
  // keyboard selection/month hops don't strand focus on <body>.
198
252
  const focusedDay = this.calendarTarget.contains(document.activeElement)
@@ -221,9 +275,10 @@ export default class extends Controller {
221
275
  if (focused) stop.focus({ preventScroll: true });
222
276
  }
223
277
 
224
- // Arrow keys move by day/week, Home/End jump within the rendered week row;
225
- // crossing a month boundary re-renders the grid on the target month.
226
- // Enter/Space activate natively (the days are real <button>s).
278
+ // Arrow keys move by day/week, PageUp/PageDown by month (Shift: by year,
279
+ // per APG), Home/End jump within the rendered week row; crossing a month
280
+ // boundary re-renders the grid on the target month. Enter/Space activate
281
+ // natively (the days are real <button>s).
227
282
  onKeydown(e) {
228
283
  const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
229
284
  const day = e.target.closest?.("[data-day]");
@@ -233,6 +288,13 @@ export default class extends Controller {
233
288
  if (e.key in STEPS) {
234
289
  target = this.parseDate(day.dataset.day);
235
290
  target.setDate(target.getDate() + STEPS[e.key]);
291
+ } else if (e.key === "PageUp" || e.key === "PageDown") {
292
+ const base = this.parseDate(day.dataset.day);
293
+ const months = (e.key === "PageUp" ? -1 : 1) * (e.shiftKey ? 12 : 1);
294
+ target = new Date(base.getFullYear(), base.getMonth() + months, 1);
295
+ // land on the same day-of-month, clamped to the target month's length
296
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
297
+ target.setDate(Math.min(base.getDate(), lastDay));
236
298
  } else if (e.key === "Home" || e.key === "End") {
237
299
  const rowDays = [...day.closest("tr").querySelectorAll("[data-day]")];
238
300
  target = this.parseDate(rowDays[e.key === "Home" ? 0 : rowDays.length - 1].dataset.day);
@@ -260,7 +322,15 @@ export default class extends Controller {
260
322
  this.monthSelectTarget.value = String(this.viewDate().getMonth());
261
323
  }
262
324
  if (this.hasYearSelectTarget) {
263
- this.yearSelectTarget.value = String(this.viewDate().getFullYear());
325
+ const select = this.yearSelectTarget;
326
+ const year = this.viewDate().getFullYear();
327
+ select.value = String(year);
328
+ if (select.selectedIndex === -1 && select.options.length) {
329
+ // view year outside the option range — clamp to the nearest end
330
+ // instead of leaving the select blank
331
+ const years = [...select.options].map((o) => Number(o.value));
332
+ select.value = String(year < Math.min(...years) ? Math.min(...years) : Math.max(...years));
333
+ }
264
334
  }
265
335
  }
266
336
 
@@ -278,15 +348,22 @@ export default class extends Controller {
278
348
  let cells = week.map((day) => this.renderDay(day)).join("");
279
349
  if (this.weekNumbersValue) {
280
350
  const weekNumber = this.isoWeekNumber(week[0]);
281
- cells = `<td class="pk-calendar-weeknumber" role="presentation">${weekNumber}</td>` + cells;
351
+ cells = `<td class="pk-calendar-weeknumber" role="rowheader">${weekNumber}</td>` + cells;
282
352
  }
283
- return `<tr class="pk-calendar-week">${cells}</tr>`;
353
+ return `<tr class="pk-calendar-week" role="row">${cells}</tr>`;
284
354
  }
285
355
 
286
356
  renderDay(day) {
287
357
  const today = new Date();
288
358
  const iso = this.isoDate(day);
289
- const data = { day: iso, dayDate: day.getDate(), state: this.dayState(day) };
359
+ const data = {
360
+ day: iso,
361
+ dayDate: day.getDate(),
362
+ // full human-readable date for the button's accessible name — a bare
363
+ // day number ("14") is meaningless to a screen reader
364
+ dayLabel: day.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
365
+ state: this.dayState(day),
366
+ };
290
367
 
291
368
  if (this.isDateDisabled(day)) {
292
369
  return renderTemplate(this.disabledDateTemplateTarget.innerHTML, data);
@@ -1,8 +1,11 @@
1
1
  module PhlexKit
2
2
  # <template>s for the five day states (disabled / selected / today / current
3
3
  # month / other month). The controller picks one per day and interpolates
4
- # {{day}} / {{dayDate}} / {{state}} — state carries the extra range/booked
5
- # classes (range-start / range-end / in-range / booked). See calendar.rb.
4
+ # {{day}} / {{dayDate}} / {{dayLabel}} / {{state}} — state carries the extra
5
+ # range/booked classes (range-start / range-end / in-range / booked), and
6
+ # dayLabel is the full human-readable date used as the button's accessible
7
+ # name. Per the APG date-grid pattern the <td> is the gridcell (and carries
8
+ # aria-selected); the <button> inside is a plain button. See calendar.rb.
6
9
  class CalendarDays < BaseComponent
7
10
  def initialize(**attrs)
8
11
  @attrs = attrs
@@ -25,25 +28,24 @@ module PhlexKit
25
28
  name: "day",
26
29
  class: "pk-calendar-day disabled {{state}}",
27
30
  disabled: true,
28
- role: "gridcell",
29
31
  tabindex: "-1",
30
32
  type: "button",
31
- aria_disabled: "true"
33
+ aria_disabled: "true",
34
+ aria_label: "{{dayLabel}}"
32
35
  ) { "{{dayDate}}" }
33
36
  end
34
37
  end
35
38
 
36
39
  def render_selected_date_template
37
- date_template("selectedDateTemplate") do
40
+ date_template("selectedDateTemplate", aria_selected: "true") do
38
41
  button(
39
42
  data_day: "{{day}}",
40
43
  data_action: "click->phlex-kit--calendar#selectDay",
41
44
  name: "day",
42
45
  class: "pk-calendar-day selected {{state}}",
43
- role: "gridcell",
44
46
  tabindex: "0",
45
47
  type: "button",
46
- aria_selected: "true"
48
+ aria_label: "{{dayLabel}}"
47
49
  ) { "{{dayDate}}" }
48
50
  end
49
51
  end
@@ -55,9 +57,9 @@ module PhlexKit
55
57
  data_action: "click->phlex-kit--calendar#selectDay",
56
58
  name: "day",
57
59
  class: "pk-calendar-day today {{state}}",
58
- role: "gridcell",
59
60
  tabindex: "-1",
60
- type: "button"
61
+ type: "button",
62
+ aria_label: "{{dayLabel}}"
61
63
  ) { "{{dayDate}}" }
62
64
  end
63
65
  end
@@ -69,9 +71,9 @@ module PhlexKit
69
71
  data_action: "click->phlex-kit--calendar#selectDay",
70
72
  name: "day",
71
73
  class: "pk-calendar-day {{state}}",
72
- role: "gridcell",
73
74
  tabindex: "-1",
74
- type: "button"
75
+ type: "button",
76
+ aria_label: "{{dayLabel}}"
75
77
  ) { "{{dayDate}}" }
76
78
  end
77
79
  end
@@ -83,16 +85,16 @@ module PhlexKit
83
85
  data_action: "click->phlex-kit--calendar#selectDay",
84
86
  name: "day",
85
87
  class: "pk-calendar-day other {{state}}",
86
- role: "gridcell",
87
88
  tabindex: "-1",
88
- type: "button"
89
+ type: "button",
90
+ aria_label: "{{dayLabel}}"
89
91
  ) { "{{dayDate}}" }
90
92
  end
91
93
  end
92
94
 
93
- def date_template(target, &block)
95
+ def date_template(target, **td_attrs, &block)
94
96
  template(data: { phlex_kit__calendar_target: target }) do
95
- td(class: "pk-calendar-cell", role: "presentation", &block)
97
+ td(class: "pk-calendar-cell", role: "gridcell", **td_attrs, &block)
96
98
  end
97
99
  end
98
100
  end
@@ -5,6 +5,12 @@ import { Controller } from "@hotwired/stimulus"
5
5
  // Connects to data-controller="phlex-kit--calendar-input"
6
6
  export default class extends Controller {
7
7
  setValue(value) {
8
+ if (this.element.value === value) return
8
9
  this.element.value = value
10
+ // Programmatic .value assignment fires nothing — dispatch input/change so
11
+ // form frameworks, validators and Stimulus actions bound to the field see
12
+ // the calendar's write.
13
+ this.element.dispatchEvent(new Event("input", { bubbles: true }))
14
+ this.element.dispatchEvent(new Event("change", { bubbles: true }))
9
15
  }
10
16
  }
@@ -11,7 +11,11 @@ module PhlexKit
11
11
  name: "next-month",
12
12
  class: "pk-calendar-next",
13
13
  aria: { label: "Go to next month" },
14
- data: { action: "click->phlex-kit--calendar#nextMonth" }
14
+ data: {
15
+ action: "click->phlex-kit--calendar#nextMonth",
16
+ # target so the controller can disable it at the max-date/to-year bound
17
+ phlex_kit__calendar_target: "nextButton"
18
+ }
15
19
  }, @attrs)) do
16
20
  icon
17
21
  end
@@ -11,7 +11,11 @@ module PhlexKit
11
11
  name: "previous-month",
12
12
  class: "pk-calendar-prev",
13
13
  aria: { label: "Go to previous month" },
14
- data: { action: "click->phlex-kit--calendar#prevMonth" }
14
+ data: {
15
+ action: "click->phlex-kit--calendar#prevMonth",
16
+ # target so the controller can disable it at the min-date/from-year bound
17
+ phlex_kit__calendar_target: "prevButton"
18
+ }
15
19
  }, @attrs)) do
16
20
  icon
17
21
  end
@@ -12,7 +12,7 @@ module PhlexKit
12
12
  def view_template
13
13
  template(data: { phlex_kit__calendar_target: "weekdaysTemplate" }) do
14
14
  thead(**@attrs) do
15
- tr(class: "pk-calendar-weekdays-row") do
15
+ tr(class: "pk-calendar-weekdays-row", role: "row") do
16
16
  th(scope: "col", class: "pk-calendar-weekday pk-calendar-weeknumber-head", aria: { label: "Week number" }) { "#" } if @week_numbers
17
17
  DAYS.each { |day| render_day(day) }
18
18
  end
@@ -23,7 +23,7 @@ module PhlexKit
23
23
  data: {
24
24
  controller: "phlex-kit--carousel",
25
25
  phlex_kit__carousel_options_value: JSON.generate(default_options.merge(@options)),
26
- action: "keydown.right->phlex-kit--carousel#scrollNext:prevent keydown.left->phlex-kit--carousel#scrollPrev:prevent"
26
+ action: keyboard_action
27
27
  }
28
28
  }, @attrs), &)
29
29
  end
@@ -33,5 +33,15 @@ module PhlexKit
33
33
  def default_options
34
34
  { axis: (@orientation == :horizontal) ? "x" : "y" }
35
35
  end
36
+
37
+ # Arrow keys follow the scroll axis: left/right for horizontal, up/down
38
+ # for vertical.
39
+ def keyboard_action
40
+ if @orientation == :vertical
41
+ "keydown.down->phlex-kit--carousel#scrollNext:prevent keydown.up->phlex-kit--carousel#scrollPrev:prevent"
42
+ else
43
+ "keydown.right->phlex-kit--carousel#scrollNext:prevent keydown.left->phlex-kit--carousel#scrollPrev:prevent"
44
+ end
45
+ end
36
46
  end
37
47
  end
@@ -37,6 +37,12 @@ export default class extends Controller {
37
37
  this._onPointerUp = this._onPointerUp.bind(this);
38
38
  this._onClickCapture = this._onClickCapture.bind(this);
39
39
  window.addEventListener("resize", this._onResize);
40
+ // window resize misses container-only resizes (flex/grid reflow,
41
+ // sidebar toggles) — observe the viewport itself.
42
+ if (typeof ResizeObserver !== "undefined") {
43
+ this._resizeObserver = new ResizeObserver(() => this._applyTransform());
44
+ this._resizeObserver.observe(this.viewportTarget);
45
+ }
40
46
  this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
41
47
  this.viewportTarget.addEventListener("click", this._onClickCapture, true);
42
48
  this._update();
@@ -44,8 +50,19 @@ export default class extends Controller {
44
50
 
45
51
  disconnect() {
46
52
  window.removeEventListener("resize", this._onResize);
53
+ this._resizeObserver?.disconnect();
54
+ this._resizeObserver = null;
47
55
  this.viewportTarget.removeEventListener("pointerdown", this._onPointerDown);
48
56
  this.viewportTarget.removeEventListener("click", this._onClickCapture, true);
57
+ // a disconnect mid-drag must drop the move/up listeners and drag state
58
+ this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
59
+ this.viewportTarget.removeEventListener("pointerup", this._onPointerUp);
60
+ this.viewportTarget.removeEventListener("pointercancel", this._onPointerUp);
61
+ if (this.drag) {
62
+ this.drag = null;
63
+ if (this.track) this.track.style.transition = "";
64
+ this.viewportTarget.classList.remove("dragging");
65
+ }
49
66
  }
50
67
 
51
68
  scrollNext() {
@@ -170,9 +187,19 @@ export default class extends Controller {
170
187
  const slide = this.slides[index];
171
188
  const first = this.slides[0];
172
189
  if (!slide || !first) return 0;
173
- return this.options.axis === "y"
190
+ const raw = this.options.axis === "y"
174
191
  ? slide.offsetTop - first.offsetTop
175
192
  : slide.offsetLeft - first.offsetLeft;
193
+ // Never scroll past the content edge: with multi-up layouts the last
194
+ // slides' offsets overshoot the max scrollable offset (track − viewport).
195
+ return Math.min(Math.max(0, raw), this._maxOffset());
196
+ }
197
+
198
+ _maxOffset() {
199
+ const vertical = this.options.axis === "y";
200
+ const trackSize = vertical ? this.track.scrollHeight : this.track.scrollWidth;
201
+ const viewportSize = vertical ? this.viewportTarget.clientHeight : this.viewportTarget.clientWidth;
202
+ return Math.max(0, trackSize - viewportSize);
176
203
  }
177
204
 
178
205
  _slideSize() {
@@ -80,15 +80,22 @@ export default class extends Controller {
80
80
 
81
81
  setDefaultColorsForChart() {
82
82
  const Chart = this.chartLibrary()
83
- Chart.defaults.color = this.getThemeColor("pk-muted") // font color
84
- Chart.defaults.borderColor = this.getThemeColor("pk-border") // border color
85
- Chart.defaults.backgroundColor = this.getThemeColor("pk-bg") // background color
83
+ // One computed-style resolve for all nine tokens (this runs on every
84
+ // connect; assigning identical values to Chart.defaults is free charts
85
+ // read defaults at construction so the style resolves are the only cost
86
+ // worth trimming).
87
+ const styles = getComputedStyle(document.documentElement)
88
+ const token = (name) => styles.getPropertyValue(`--${name}`).trim()
89
+
90
+ Chart.defaults.color = token("pk-muted") // font color
91
+ Chart.defaults.borderColor = token("pk-border") // border color
92
+ Chart.defaults.backgroundColor = token("pk-bg") // background color
86
93
 
87
94
  // tooltip colors
88
- Chart.defaults.plugins.tooltip.backgroundColor = this.getThemeColor("pk-surface")
89
- Chart.defaults.plugins.tooltip.borderColor = this.getThemeColor("pk-border")
90
- Chart.defaults.plugins.tooltip.titleColor = this.getThemeColor("pk-text")
91
- Chart.defaults.plugins.tooltip.bodyColor = this.getThemeColor("pk-muted")
95
+ Chart.defaults.plugins.tooltip.backgroundColor = token("pk-surface")
96
+ Chart.defaults.plugins.tooltip.borderColor = token("pk-border")
97
+ Chart.defaults.plugins.tooltip.titleColor = token("pk-text")
98
+ Chart.defaults.plugins.tooltip.bodyColor = token("pk-muted")
92
99
  Chart.defaults.plugins.tooltip.borderWidth = 1
93
100
 
94
101
  // legend
@@ -99,7 +106,7 @@ export default class extends Controller {
99
106
  Chart.defaults.plugins.legend.labels.borderRadius = 2
100
107
 
101
108
  // shadcn look: hairline grid, no axis borders, muted ticks
102
- Chart.defaults.scale.grid = { ...Chart.defaults.scale.grid, color: this.getThemeColor("pk-border"), drawTicks: false }
109
+ Chart.defaults.scale.grid = { ...Chart.defaults.scale.grid, color: token("pk-border"), drawTicks: false }
103
110
  Chart.defaults.scale.border = { ...Chart.defaults.scale.border, display: false }
104
111
  }
105
112
 
@@ -108,14 +115,30 @@ export default class extends Controller {
108
115
  this.initChart()
109
116
  }
110
117
 
111
- // The kit themes via <html data-theme="...">, not a class — watch that.
118
+ // The kit themes via <html data-theme="...">, not a class — but a class can
119
+ // re-theme too (.pk-dark, custom theme classes), so watch both. ANY root
120
+ // class mutation fires the observer though, so only rebuild when the
121
+ // resolved token values actually changed — rebuilding every chart on an
122
+ // unrelated class flip is the expensive path.
112
123
  initThemeObserver() {
124
+ this._themeKey = this.currentThemeKey()
113
125
  this.themeObserver = new MutationObserver(() => {
126
+ const key = this.currentThemeKey()
127
+ if (key === this._themeKey) return
128
+ this._themeKey = key
114
129
  this.refreshChart()
115
130
  })
116
131
  this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
117
132
  }
118
133
 
134
+ // Cheap fingerprint of the resolved theme: two tokens that differ between
135
+ // every light/dark pair. One style resolve per mutation vs a full chart
136
+ // rebuild.
137
+ currentThemeKey() {
138
+ const styles = getComputedStyle(document.documentElement)
139
+ return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
140
+ }
141
+
119
142
  mergeOptionsWithDefaults() {
120
143
  return {
121
144
  ...this.optionsValue,
@@ -16,7 +16,8 @@
16
16
  height: 1rem;
17
17
  margin: 0;
18
18
  border: 1px solid var(--pk-input);
19
- border-radius: 4px;
19
+ /* Tracks the radius token — 4px at the default --pk-radius (0.625rem). */
20
+ border-radius: calc(var(--pk-radius) - 6px);
20
21
  background: color-mix(in oklab, var(--pk-input) 30%, transparent);
21
22
  cursor: pointer;
22
23
  transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
@@ -4,5 +4,5 @@
4
4
  .pk-clipboard-popover { position: absolute; bottom: calc(100% + .5rem); left: 50%; transform: translateX(-50%); z-index: 50; width: max-content; }
5
5
  .pk-clipboard-popover-inner {
6
6
  border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
7
- color: var(--pk-text); padding: .125rem .5rem; font-size: .875rem; box-shadow: 0 8px 24px rgb(0 0 0 / .3);
7
+ color: var(--pk-text); padding: .125rem .5rem; font-size: .875rem; box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
8
8
  }
@@ -5,6 +5,13 @@ import { Controller } from "@hotwired/stimulus"
5
5
  export default class extends Controller {
6
6
  static targets = ["trigger", "source", "successPopover", "errorPopover"]
7
7
 
8
+ disconnect() {
9
+ // Don't leave the 1.5s hide timer running into a dead/recycled element
10
+ // (Turbo can detach and re-attach this subtree mid-flash).
11
+ clearTimeout(this.timer)
12
+ this.hideAll()
13
+ }
14
+
8
15
  copy() {
9
16
  const el = this.sourceTarget.children[0]
10
17
  if (!el) { this.show(this.errorPopoverTarget); return }
@@ -9,7 +9,10 @@ module PhlexKit
9
9
  @attrs = attrs
10
10
  end
11
11
  def view_template(&block)
12
- div(**mix({ class: "pk-codeblock", data: { syntax: @syntax } }, @attrs)) do
12
+ # tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
13
+ # be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
14
+ # `aria: { label: ... }` to name the region for AT.
15
+ div(**mix({ class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }, @attrs)) do
13
16
  pre do
14
17
  if block
15
18
  code(&block)
@@ -1,8 +1,21 @@
1
1
  module PhlexKit
2
+ # Collapsed region of a Collapsible. Pass `open:` matching the parent
3
+ # Collapsible's so a closed region is hidden before JS (no flash-open, and
4
+ # no stuck-open region without JS); the controller toggles `.pk-hidden`
5
+ # from there. The default id lets the trigger's aria-controls point here.
2
6
  class CollapsibleContent < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
7
+ def initialize(open: false, id: nil, **attrs)
8
+ @open = open
9
+ @id = id || "pk-collapsible-content-#{SecureRandom.hex(4)}"
10
+ @attrs = attrs
11
+ end
12
+
4
13
  def view_template(&)
5
- div(**mix({ class: "pk-collapsible-content", data: { phlex_kit__collapsible_target: "content" } }, @attrs), &)
14
+ div(**mix({
15
+ class: @open ? "pk-collapsible-content" : "pk-collapsible-content pk-hidden",
16
+ id: @id,
17
+ data: { phlex_kit__collapsible_target: "content" }
18
+ }, @attrs), &)
6
19
  end
7
20
  end
8
21
  end
@@ -2,20 +2,29 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="phlex-kit--collapsible"
4
4
  export default class extends Controller {
5
- static targets = ["content"]
5
+ static targets = ["trigger", "content"]
6
6
  static values = { open: { type: Boolean, default: false } }
7
7
 
8
- connect() { this.openValue ? this.open() : this.close() }
8
+ connect() {
9
+ // The trigger renders aria-expanded from its own `open:` kwarg but can't
10
+ // know the content's id at render time — wire aria-controls here.
11
+ if (this.hasTriggerTarget && this.hasContentTarget && this.contentTarget.id) {
12
+ this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id)
13
+ }
14
+ this.openValue ? this.open() : this.close()
15
+ }
9
16
  toggle() { this.openValue = !this.openValue }
10
17
  openValueChanged(isOpen) { isOpen ? this.open() : this.close() }
11
18
 
12
19
  open() {
13
20
  if (this.hasContentTarget) { this.contentTarget.classList.remove("pk-hidden"); this.openValue = true }
21
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true")
14
22
  this.element.dataset.state = "open";
15
23
  }
16
24
 
17
25
  close() {
18
26
  if (this.hasContentTarget) { this.contentTarget.classList.add("pk-hidden"); this.openValue = false }
27
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false")
19
28
  this.element.dataset.state = "closed";
20
29
  }
21
30
  }
@@ -1,8 +1,23 @@
1
1
  module PhlexKit
2
+ # Toggle for a Collapsible. Renders a plain wrapper carrying the click
3
+ # action — it deliberately has no role of its own, so nest a real focusable
4
+ # control (a <button>, e.g. PhlexKit::Button) inside it for keyboard
5
+ # operability and semantics, the same composition pattern as
6
+ # AttachmentTrigger. Pass `open:` matching the parent Collapsible's so
7
+ # aria-expanded is correct before JS; the controller keeps it in sync and
8
+ # wires aria-controls to the content's id in connect().
2
9
  class CollapsibleTrigger < BaseComponent
3
- def initialize(**attrs) = (@attrs = attrs)
10
+ def initialize(open: false, **attrs)
11
+ @open = open
12
+ @attrs = attrs
13
+ end
14
+
4
15
  def view_template(&)
5
- div(**mix({ class: "pk-collapsible-trigger", data: { action: "click->phlex-kit--collapsible#toggle" } }, @attrs), &)
16
+ div(**mix({
17
+ class: "pk-collapsible-trigger",
18
+ aria_expanded: @open ? "true" : "false",
19
+ data: { phlex_kit__collapsible_target: "trigger", action: "click->phlex-kit--collapsible#toggle" }
20
+ }, @attrs), &)
6
21
  end
7
22
  end
8
23
  end