phlex_kit 0.8.0 → 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 (148) 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_content.rb +11 -2
  5. data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
  6. data/app/components/phlex_kit/alert/alert.css +3 -3
  7. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
  9. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
  10. data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
  11. data/app/components/phlex_kit/attachment/attachment.css +1 -1
  12. data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
  13. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
  14. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
  15. data/app/components/phlex_kit/avatar/avatar.css +16 -5
  16. data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
  17. data/app/components/phlex_kit/badge/badge.css +4 -4
  18. data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
  19. data/app/components/phlex_kit/bubble/bubble.css +3 -3
  20. data/app/components/phlex_kit/bubble/bubble.rb +2 -0
  21. data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
  22. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
  23. data/app/components/phlex_kit/button/button.css +6 -6
  24. data/app/components/phlex_kit/button_group/button_group.css +2 -2
  25. data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
  26. data/app/components/phlex_kit/calendar/calendar.css +2 -2
  27. data/app/components/phlex_kit/calendar/calendar.rb +10 -3
  28. data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
  29. data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
  30. data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
  31. data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
  32. data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
  33. data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
  34. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
  35. data/app/components/phlex_kit/carousel/carousel.rb +11 -1
  36. data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
  37. data/app/components/phlex_kit/chart/chart_controller.js +37 -10
  38. data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
  39. data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
  40. data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
  41. data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
  42. data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
  43. data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
  44. data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
  45. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
  46. data/app/components/phlex_kit/combobox/combobox.css +10 -8
  47. data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
  48. data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
  49. data/app/components/phlex_kit/command/command.css +4 -4
  50. data/app/components/phlex_kit/command/command_controller.js +51 -10
  51. data/app/components/phlex_kit/command/command_dialog.rb +5 -6
  52. data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
  53. data/app/components/phlex_kit/command/command_input.rb +6 -2
  54. data/app/components/phlex_kit/command/command_item.rb +4 -1
  55. data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
  56. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
  57. data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
  58. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
  59. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
  60. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  61. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
  62. data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
  63. data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
  64. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
  65. data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
  66. data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
  67. data/app/components/phlex_kit/dialog/dialog.css +4 -4
  68. data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
  69. data/app/components/phlex_kit/drawer/drawer.css +3 -3
  70. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
  71. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  72. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
  73. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
  74. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
  75. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
  76. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
  77. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
  78. data/app/components/phlex_kit/field/field.css +1 -1
  79. data/app/components/phlex_kit/field/field_error.rb +5 -6
  80. data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
  81. data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
  82. data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
  83. data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
  84. data/app/components/phlex_kit/input/input.css +1 -1
  85. data/app/components/phlex_kit/input_group/input_group.css +9 -7
  86. data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
  87. data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
  88. data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
  89. data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
  90. data/app/components/phlex_kit/item/item.css +1 -1
  91. data/app/components/phlex_kit/item/item_group.rb +4 -2
  92. data/app/components/phlex_kit/item/item_separator.rb +3 -1
  93. data/app/components/phlex_kit/marker/marker.css +1 -1
  94. data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
  95. data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
  96. data/app/components/phlex_kit/menubar/menubar.css +10 -9
  97. data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
  98. data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
  99. data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
  100. data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
  101. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
  102. data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
  103. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
  104. data/app/components/phlex_kit/native_select/native_select.css +1 -1
  105. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
  106. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
  107. data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
  108. data/app/components/phlex_kit/pagination/pagination.css +2 -2
  109. data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
  110. data/app/components/phlex_kit/popover/popover.css +1 -1
  111. data/app/components/phlex_kit/resizable/resizable.css +3 -1
  112. data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
  113. data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
  114. data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
  115. data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
  116. data/app/components/phlex_kit/select/select.css +3 -3
  117. data/app/components/phlex_kit/select/select.rb +11 -7
  118. data/app/components/phlex_kit/select/select_controller.js +35 -4
  119. data/app/components/phlex_kit/select/select_item.rb +5 -2
  120. data/app/components/phlex_kit/sheet/sheet.css +3 -3
  121. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
  122. data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
  123. data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
  124. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
  125. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
  126. data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
  127. data/app/components/phlex_kit/slider/slider.css +3 -3
  128. data/app/components/phlex_kit/slider/slider.rb +3 -1
  129. data/app/components/phlex_kit/switch/switch.rb +6 -1
  130. data/app/components/phlex_kit/table/table.css +5 -3
  131. data/app/components/phlex_kit/tabs/tabs.css +4 -4
  132. data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
  133. data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
  134. data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
  135. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
  136. data/app/components/phlex_kit/toast/toast.css +32 -14
  137. data/app/components/phlex_kit/toast/toast_controller.js +24 -4
  138. data/app/components/phlex_kit/toast/toast_region.rb +19 -5
  139. data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
  140. data/app/components/phlex_kit/toggle/toggle.css +4 -4
  141. data/app/components/phlex_kit/toggle/toggle.rb +9 -3
  142. data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
  143. data/app/components/phlex_kit/typography/heading.rb +9 -3
  144. data/app/components/phlex_kit/typography/typography.css +4 -3
  145. data/app/javascript/phlex_kit/controllers/index.js +0 -2
  146. data/lib/phlex_kit/version.rb +1 -1
  147. metadata +1 -2
  148. data/app/components/phlex_kit/select/select_item_controller.js +0 -14
@@ -37,8 +37,8 @@
37
37
  /* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
38
38
  .pk-button svg { flex-shrink: 0; pointer-events: none; }
39
39
  .pk-button svg:not([width]) { width: 1rem; height: 1rem; }
40
- .pk-button:has(> [data-icon="inline-start"]) { padding-left: .5rem; }
41
- .pk-button:has(> [data-icon="inline-end"]) { padding-right: .5rem; }
40
+ .pk-button:has(> [data-icon="inline-start"]) { padding-inline-start: .5rem; }
41
+ .pk-button:has(> [data-icon="inline-end"]) { padding-inline-end: .5rem; }
42
42
 
43
43
  /* Sizes (md is the base above). */
44
44
  .pk-button.xs {
@@ -46,15 +46,15 @@
46
46
  border-radius: min(calc(var(--pk-radius) - 2px), 10px);
47
47
  }
48
48
  .pk-button.xs svg:not([width]) { width: .75rem; height: .75rem; }
49
- .pk-button.xs:has(> [data-icon="inline-start"]) { padding-left: .375rem; }
50
- .pk-button.xs:has(> [data-icon="inline-end"]) { padding-right: .375rem; }
49
+ .pk-button.xs:has(> [data-icon="inline-start"]) { padding-inline-start: .375rem; }
50
+ .pk-button.xs:has(> [data-icon="inline-end"]) { padding-inline-end: .375rem; }
51
51
  .pk-button.sm {
52
52
  height: 1.75rem; gap: .25rem; padding: .25rem .625rem; font-size: .8rem;
53
53
  border-radius: min(calc(var(--pk-radius) - 2px), 12px);
54
54
  }
55
55
  .pk-button.sm svg:not([width]) { width: .875rem; height: .875rem; }
56
- .pk-button.sm:has(> [data-icon="inline-start"]) { padding-left: .375rem; }
57
- .pk-button.sm:has(> [data-icon="inline-end"]) { padding-right: .375rem; }
56
+ .pk-button.sm:has(> [data-icon="inline-start"]) { padding-inline-start: .375rem; }
57
+ .pk-button.sm:has(> [data-icon="inline-end"]) { padding-inline-end: .375rem; }
58
58
  .pk-button.lg { height: 2.25rem; padding: .375rem .625rem; }
59
59
  .pk-button.xl { height: 3rem; padding: .625rem 1.5rem; font-size: 1rem; }
60
60
 
@@ -14,7 +14,7 @@
14
14
  .pk-button-group:not(.vertical) > :not(:first-child) {
15
15
  border-top-left-radius: 0;
16
16
  border-bottom-left-radius: 0;
17
- border-left: 0;
17
+ border-inline-start: 0;
18
18
  }
19
19
  .pk-button-group:not(.vertical) > :not(:last-child) {
20
20
  border-top-right-radius: 0;
@@ -47,7 +47,7 @@
47
47
  .pk-button-group:not(.vertical) > :not(:first-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
48
48
  border-top-left-radius: 0;
49
49
  border-bottom-left-radius: 0;
50
- border-left: 0;
50
+ border-inline-start: 0;
51
51
  }
52
52
  .pk-button-group:not(.vertical) > :not(:last-child) > :is(.pk-dropdown-menu-trigger, .pk-popover-trigger, .pk-hover-card-trigger, .pk-tooltip-trigger) > .pk-button,
53
53
  .pk-button-group:not(.vertical) > :not(:last-child) > :is(.pk-select-trigger, .pk-combobox-trigger) {
@@ -8,7 +8,9 @@ module PhlexKit
8
8
  end
9
9
 
10
10
  def view_template
11
- div(**mix({ class: "pk-button-group-separator", role: "separator", aria: { hidden: true } }, @attrs))
11
+ # Purely decorative — aria-hidden only (role="separator" would
12
+ # contradict aria-hidden and confuse AT).
13
+ div(**mix({ class: "pk-button-group-separator", aria: { hidden: true } }, @attrs))
12
14
  end
13
15
  end
14
16
  end
@@ -53,8 +53,8 @@
53
53
  opacity: .5;
54
54
  transition: background-color .15s ease, opacity .15s ease;
55
55
  }
56
- .pk-calendar-prev { left: .25rem; }
57
- .pk-calendar-next { right: .25rem; }
56
+ .pk-calendar-prev { inset-inline-start: .25rem; }
57
+ .pk-calendar-next { inset-inline-end: .25rem; }
58
58
  .pk-calendar-prev:hover, .pk-calendar-next:hover { background: var(--pk-accent); opacity: 1; }
59
59
  .pk-calendar-prev:focus-visible, .pk-calendar-next:focus-visible {
60
60
  outline: none;
@@ -64,7 +64,9 @@ module PhlexKit
64
64
  private
65
65
 
66
66
  def render_dropdown_caption
67
- view_year = parse_year(@selected_date) || parse_year(@range_start) || Time.now.year
67
+ view_seed = @selected_date || @range_start || @selected_dates.first
68
+ view_year = parse_year(view_seed) || Time.now.year
69
+ view_month = parse_month(view_seed) || Time.now.month # 1-12
68
70
  first_year = @from_year || parse_year(@min_date) || (view_year - 10)
69
71
  last_year = @to_year || parse_year(@max_date) || (view_year + 10)
70
72
 
@@ -74,14 +76,15 @@ module PhlexKit
74
76
  aria: { label: "Month" },
75
77
  data: { phlex_kit__calendar_target: "monthSelect", action: "change->phlex-kit--calendar#setMonth" }
76
78
  ) do
77
- MONTHS.each_with_index { |name, index| option(value: index.to_s) { name } }
79
+ # Mark the view month selected so the caption is right pre-JS.
80
+ MONTHS.each_with_index { |name, index| option(value: index.to_s, selected: index == view_month - 1) { name } }
78
81
  end
79
82
  select(
80
83
  class: "pk-native-select-field pk-calendar-dropdown",
81
84
  aria: { label: "Year" },
82
85
  data: { phlex_kit__calendar_target: "yearSelect", action: "change->phlex-kit--calendar#setYear" }
83
86
  ) do
84
- (first_year..last_year).each { |year| option(value: year.to_s) { year.to_s } }
87
+ (first_year..last_year).each { |year| option(value: year.to_s, selected: year == view_year) { year.to_s } }
85
88
  end
86
89
  end
87
90
  end
@@ -90,6 +93,10 @@ module PhlexKit
90
93
  value.to_s[/\A(\d{4})/, 1]&.to_i
91
94
  end
92
95
 
96
+ def parse_month(value)
97
+ value.to_s[/\A\d{4}-(\d{2})/, 1]&.to_i
98
+ end
99
+
93
100
  def calendar_attrs
94
101
  data = {
95
102
  controller: "phlex-kit--calendar",
@@ -7,7 +7,18 @@ module PhlexKit
7
7
  end
8
8
 
9
9
  def view_template
10
- table(**mix({ class: "pk-calendar-body", data: { phlex_kit__calendar_target: "calendar" } }, @attrs))
10
+ table(**mix({
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",
15
+ data: {
16
+ phlex_kit__calendar_target: "calendar",
17
+ # Delegated arrow-key/Home/End navigation for the day grid (the
18
+ # buttons are re-rendered wholesale, so the action lives here).
19
+ action: "keydown->phlex-kit--calendar#onKeydown"
20
+ }
21
+ }, @attrs))
11
22
  end
12
23
  end
13
24
  end
@@ -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) {
@@ -119,6 +169,7 @@ export default class extends Controller {
119
169
  }
120
170
  this.selectedDatesValue = current;
121
171
  this.updateCalendar();
172
+ this.pushToOutlets();
122
173
  this.dispatchChange();
123
174
  }
124
175
 
@@ -161,6 +212,9 @@ export default class extends Controller {
161
212
  return end ? `${this.formatDate(start)} – ${this.formatDate(end)}` : this.formatDate(start);
162
213
  }
163
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 "";
164
218
  return this.selectedDatesValue.map((d) => this.formatDate(this.parseDate(d))).join(", ");
165
219
  }
166
220
  const selected = this.selectedDate();
@@ -192,7 +246,75 @@ export default class extends Controller {
192
246
  this.titleTarget.textContent = this.monthAndYear();
193
247
  }
194
248
  this.syncDropdowns();
249
+ this.syncNavButtons();
250
+ // innerHTML replacement drops focus — remember the focused day so
251
+ // keyboard selection/month hops don't strand focus on <body>.
252
+ const focusedDay = this.calendarTarget.contains(document.activeElement)
253
+ ? document.activeElement.dataset?.day
254
+ : null;
195
255
  this.calendarTarget.innerHTML = this.calendarHTML();
256
+ this.ensureGridTabStop(focusedDay);
257
+ }
258
+
259
+ // Roving tabindex: exactly one day button is tabbable — the previously
260
+ // focused day (refocused across re-renders), else the selected day (its
261
+ // template ships tabindex="0"), else today, else the first enabled day of
262
+ // the current month. Without this, a calendar with no selection has no
263
+ // keyboard entry point at all.
264
+ ensureGridTabStop(focusedDay) {
265
+ const days = [...this.calendarTarget.querySelectorAll(".pk-calendar-day:not([disabled])")];
266
+ if (!days.length) return;
267
+ const focused = focusedDay ? days.find((d) => d.dataset.day === focusedDay) : null;
268
+ const stop =
269
+ focused ||
270
+ days.find((d) => d.getAttribute("tabindex") === "0") ||
271
+ days.find((d) => d.classList.contains("today")) ||
272
+ days.find((d) => !d.classList.contains("other")) ||
273
+ days[0];
274
+ days.forEach((d) => d.setAttribute("tabindex", d === stop ? "0" : "-1"));
275
+ if (focused) stop.focus({ preventScroll: true });
276
+ }
277
+
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).
282
+ onKeydown(e) {
283
+ const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
284
+ const day = e.target.closest?.("[data-day]");
285
+ if (!day) return;
286
+
287
+ let target;
288
+ if (e.key in STEPS) {
289
+ target = this.parseDate(day.dataset.day);
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));
298
+ } else if (e.key === "Home" || e.key === "End") {
299
+ const rowDays = [...day.closest("tr").querySelectorAll("[data-day]")];
300
+ target = this.parseDate(rowDays[e.key === "Home" ? 0 : rowDays.length - 1].dataset.day);
301
+ } else {
302
+ return;
303
+ }
304
+ e.preventDefault();
305
+
306
+ const iso = this.isoDate(target);
307
+ if (this.isDateDisabled(iso)) return; // min/max/booked boundary: stay put
308
+
309
+ if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
310
+ this.viewDateValue = iso; // re-renders the grid on the target month
311
+ }
312
+ const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
313
+ if (!next) return;
314
+ this.calendarTarget.querySelectorAll(".pk-calendar-day").forEach((d) => {
315
+ d.setAttribute("tabindex", d === next ? "0" : "-1");
316
+ });
317
+ next.focus({ preventScroll: true });
196
318
  }
197
319
 
198
320
  syncDropdowns() {
@@ -200,7 +322,15 @@ export default class extends Controller {
200
322
  this.monthSelectTarget.value = String(this.viewDate().getMonth());
201
323
  }
202
324
  if (this.hasYearSelectTarget) {
203
- 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
+ }
204
334
  }
205
335
  }
206
336
 
@@ -218,15 +348,22 @@ export default class extends Controller {
218
348
  let cells = week.map((day) => this.renderDay(day)).join("");
219
349
  if (this.weekNumbersValue) {
220
350
  const weekNumber = this.isoWeekNumber(week[0]);
221
- cells = `<td class="pk-calendar-weeknumber" role="presentation">${weekNumber}</td>` + cells;
351
+ cells = `<td class="pk-calendar-weeknumber" role="rowheader">${weekNumber}</td>` + cells;
222
352
  }
223
- return `<tr class="pk-calendar-week">${cells}</tr>`;
353
+ return `<tr class="pk-calendar-week" role="row">${cells}</tr>`;
224
354
  }
225
355
 
226
356
  renderDay(day) {
227
357
  const today = new Date();
228
358
  const iso = this.isoDate(day);
229
- 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
+ };
230
367
 
231
368
  if (this.isDateDisabled(day)) {
232
369
  return renderTemplate(this.disabledDateTemplateTarget.innerHTML, data);
@@ -404,8 +541,10 @@ export default class extends Controller {
404
541
  PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`,
405
542
  };
406
543
 
544
+ // Longest tokens first: MMMM must precede MM or the alternation eats
545
+ // "MMMM" as two "MM"s and renders the month number twice.
407
546
  const formattedDate = format.replace(
408
- /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g,
547
+ /yyyy|MMMM|MM|dd|HH|mm|ss|EEEE|PPPP|do/g,
409
548
  (matched) => map[matched],
410
549
  );
411
550
  return formattedDate;
@@ -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() {