phlex_kit 0.4.0 → 0.5.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +16 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/alert/alert.css +60 -22
  5. data/app/components/phlex_kit/alert/alert.rb +11 -7
  6. data/app/components/phlex_kit/alert/alert_action.rb +14 -0
  7. data/app/components/phlex_kit/alert/alert_title.rb +3 -2
  8. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +5 -2
  9. data/app/components/phlex_kit/avatar/avatar.css +80 -9
  10. data/app/components/phlex_kit/avatar/avatar.rb +6 -4
  11. data/app/components/phlex_kit/avatar/avatar_badge.rb +15 -0
  12. data/app/components/phlex_kit/avatar/avatar_group_count.rb +14 -0
  13. data/app/components/phlex_kit/badge/badge.css +61 -39
  14. data/app/components/phlex_kit/badge/badge.rb +24 -7
  15. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +2 -2
  16. data/app/components/phlex_kit/bubble/bubble.css +49 -8
  17. data/app/components/phlex_kit/button/button.css +71 -26
  18. data/app/components/phlex_kit/button/button.rb +20 -13
  19. data/app/components/phlex_kit/button_group/button_group.css +59 -8
  20. data/app/components/phlex_kit/button_group/button_group.rb +11 -5
  21. data/app/components/phlex_kit/button_group/button_group_separator.rb +14 -0
  22. data/app/components/phlex_kit/button_group/button_group_text.rb +14 -0
  23. data/app/components/phlex_kit/calendar/calendar.css +66 -15
  24. data/app/components/phlex_kit/calendar/calendar.rb +87 -19
  25. data/app/components/phlex_kit/calendar/calendar_controller.js +226 -75
  26. data/app/components/phlex_kit/calendar/calendar_days.rb +7 -6
  27. data/app/components/phlex_kit/calendar/calendar_weekdays.rb +3 -1
  28. data/app/components/phlex_kit/checkbox/checkbox.css +52 -7
  29. data/app/components/phlex_kit/collapsible/collapsible.css +10 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +9 -2
  31. data/app/components/phlex_kit/combobox/combobox.css +37 -3
  32. data/app/components/phlex_kit/combobox/combobox.rb +3 -1
  33. data/app/components/phlex_kit/combobox/combobox_controller.js +9 -1
  34. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +7 -2
  35. data/app/components/phlex_kit/command/command.css +58 -22
  36. data/app/components/phlex_kit/command/command_controller.js +3 -1
  37. data/app/components/phlex_kit/command/command_input.rb +29 -25
  38. data/app/components/phlex_kit/command/command_separator.rb +18 -0
  39. data/app/components/phlex_kit/command/command_shortcut.rb +13 -0
  40. data/app/components/phlex_kit/context_menu/context_menu.css +78 -6
  41. data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +27 -0
  42. data/app/components/phlex_kit/context_menu/context_menu_group.rb +10 -0
  43. data/app/components/phlex_kit/context_menu/context_menu_item.rb +13 -3
  44. data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +10 -0
  45. data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +30 -0
  46. data/app/components/phlex_kit/context_menu/context_menu_shortcut.rb +11 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +11 -0
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +12 -0
  49. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +17 -0
  50. data/app/components/phlex_kit/dialog/dialog.css +34 -16
  51. data/app/components/phlex_kit/dialog/dialog_close.rb +13 -0
  52. data/app/components/phlex_kit/dialog/dialog_content.rb +13 -5
  53. data/app/components/phlex_kit/drawer/drawer.css +56 -17
  54. data/app/components/phlex_kit/drawer/drawer_content.rb +8 -4
  55. data/app/components/phlex_kit/empty/empty.css +13 -8
  56. data/app/components/phlex_kit/field/field.css +152 -0
  57. data/app/components/phlex_kit/field/field.rb +35 -0
  58. data/app/components/phlex_kit/field/field_content.rb +11 -0
  59. data/app/components/phlex_kit/field/field_description.rb +10 -0
  60. data/app/components/phlex_kit/field/field_error.rb +29 -0
  61. data/app/components/phlex_kit/field/field_group.rb +12 -0
  62. data/app/components/phlex_kit/field/field_label.rb +12 -0
  63. data/app/components/phlex_kit/field/field_legend.rb +17 -0
  64. data/app/components/phlex_kit/field/field_separator.rb +15 -0
  65. data/app/components/phlex_kit/field/field_set.rb +10 -0
  66. data/app/components/phlex_kit/field/field_title.rb +11 -0
  67. data/app/components/phlex_kit/hover_card/hover_card.css +12 -4
  68. data/app/components/phlex_kit/hover_card/hover_card_content.rb +11 -2
  69. data/app/components/phlex_kit/input/input.css +45 -11
  70. data/app/components/phlex_kit/input_group/input_group.css +88 -12
  71. data/app/components/phlex_kit/input_group/input_group_addon.rb +11 -4
  72. data/app/components/phlex_kit/input_group/input_group_button.rb +18 -0
  73. data/app/components/phlex_kit/input_otp/input_otp.css +29 -5
  74. data/app/components/phlex_kit/item/item.css +56 -8
  75. data/app/components/phlex_kit/item/item.rb +13 -3
  76. data/app/components/phlex_kit/item/item_footer.rb +8 -0
  77. data/app/components/phlex_kit/item/item_header.rb +8 -0
  78. data/app/components/phlex_kit/item/item_media.rb +13 -3
  79. data/app/components/phlex_kit/item/item_separator.rb +10 -0
  80. data/app/components/phlex_kit/kbd/kbd.css +10 -0
  81. data/app/components/phlex_kit/marker/marker.css +29 -9
  82. data/app/components/phlex_kit/marker/marker.rb +13 -3
  83. data/app/components/phlex_kit/menubar/menubar.css +88 -17
  84. data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +26 -0
  85. data/app/components/phlex_kit/menubar/menubar_group.rb +10 -0
  86. data/app/components/phlex_kit/menubar/menubar_item.rb +9 -4
  87. data/app/components/phlex_kit/menubar/menubar_label.rb +10 -0
  88. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +10 -0
  89. data/app/components/phlex_kit/menubar/menubar_radio_item.rb +29 -0
  90. data/app/components/phlex_kit/menubar/menubar_shortcut.rb +10 -0
  91. data/app/components/phlex_kit/menubar/menubar_sub.rb +10 -0
  92. data/app/components/phlex_kit/menubar/menubar_sub_content.rb +11 -0
  93. data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +17 -0
  94. data/app/components/phlex_kit/message/message.css +8 -1
  95. data/app/components/phlex_kit/message_scroller/message_scroller.css +18 -0
  96. data/app/components/phlex_kit/native_select/native_select.css +25 -5
  97. data/app/components/phlex_kit/navigation_menu/navigation_menu.css +9 -6
  98. data/app/components/phlex_kit/pagination/pagination.css +10 -3
  99. data/app/components/phlex_kit/pagination/pagination_link.rb +21 -0
  100. data/app/components/phlex_kit/pagination/pagination_next.rb +20 -0
  101. data/app/components/phlex_kit/pagination/pagination_previous.rb +21 -0
  102. data/app/components/phlex_kit/popover/popover.css +15 -4
  103. data/app/components/phlex_kit/popover/popover_content.rb +11 -2
  104. data/app/components/phlex_kit/popover/popover_description.rb +10 -0
  105. data/app/components/phlex_kit/popover/popover_header.rb +10 -0
  106. data/app/components/phlex_kit/popover/popover_title.rb +10 -0
  107. data/app/components/phlex_kit/progress/progress.css +5 -5
  108. data/app/components/phlex_kit/radio_button/radio_button.css +46 -2
  109. data/app/components/phlex_kit/radio_group/radio_group.css +2 -2
  110. data/app/components/phlex_kit/resizable/resizable.css +17 -0
  111. data/app/components/phlex_kit/resizable/resizable_handle.rb +7 -3
  112. data/app/components/phlex_kit/select/select.css +46 -10
  113. data/app/components/phlex_kit/select/select_separator.rb +10 -0
  114. data/app/components/phlex_kit/select/select_trigger.rb +5 -2
  115. data/app/components/phlex_kit/sheet/sheet.css +18 -9
  116. data/app/components/phlex_kit/sheet/sheet_close.rb +10 -0
  117. data/app/components/phlex_kit/sheet/sheet_content.rb +8 -4
  118. data/app/components/phlex_kit/sidebar/sidebar.css +76 -9
  119. data/app/components/phlex_kit/sidebar/sidebar_controller.js +22 -7
  120. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +8 -3
  121. data/app/components/phlex_kit/sidebar/sidebar_rail.rb +21 -0
  122. data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +25 -8
  123. data/app/components/phlex_kit/skeleton/skeleton.css +1 -1
  124. data/app/components/phlex_kit/slider/slider.css +33 -12
  125. data/app/components/phlex_kit/switch/switch.css +50 -13
  126. data/app/components/phlex_kit/switch/switch.rb +5 -2
  127. data/app/components/phlex_kit/table/table.css +8 -4
  128. data/app/components/phlex_kit/tabs/tabs.css +72 -8
  129. data/app/components/phlex_kit/tabs/tabs.rb +7 -2
  130. data/app/components/phlex_kit/tabs/tabs_list.rb +11 -2
  131. data/app/components/phlex_kit/textarea/textarea.css +33 -13
  132. data/app/components/phlex_kit/toast/toast.css +1 -1
  133. data/app/components/phlex_kit/toggle/toggle.css +25 -10
  134. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -6
  135. data/app/components/phlex_kit/tooltip/tooltip.css +37 -10
  136. data/app/components/phlex_kit/tooltip/tooltip_content.rb +9 -4
  137. data/lib/phlex_kit/version.rb +1 -1
  138. metadata +50 -1
@@ -1,16 +1,44 @@
1
1
  module PhlexKit
2
2
  # Calendar (month grid with day selection), ported from ruby_ui's
3
- # RubyUI::Calendar. Renders the header (title + prev/next), the <table> the
4
- # phlex-kit--calendar controller fills, and the <template>s it clones per day
5
- # state (upstream's Mustache dependency is replaced with a tiny {{key}}
6
- # interpolator in the controller). Pass `input_id:` a CSS selector (e.g.
7
- # "#due-date") to push the picked date into a phlex-kit--calendar-input outlet —
8
- # that's how PhlexKit::DatePicker binds it to an Input. Tailwind → vanilla
9
- # `.pk-calendar*` (calendar.css).
3
+ # RubyUI::Calendar and extended to shadcn/ui's current calendar surface.
4
+ # Renders the header (title or month/year dropdown caption + prev/next),
5
+ # the <table> the phlex-kit--calendar controller fills, and the <template>s
6
+ # it clones per day state.
7
+ #
8
+ # `mode:` :single (default), :range (pick start then end;
9
+ # range-start/in-range/range-end day states), or :multiple (toggle days).
10
+ # `selected_date:` / `range_start:`+`range_end:` / `selected_dates:` seed
11
+ # the selection per mode. `min_date:`/`max_date:` clamp; `disabled_dates:`
12
+ # (array of ISO strings or Dates) are unpickable and struck through
13
+ # ("booked"). `week_numbers: true` prepends an ISO week column.
14
+ # `caption_layout: :dropdown` swaps the title for native month/year
15
+ # <select>s (their captionLayout="dropdown"; `from_year:`/`to_year:` bound
16
+ # the year list). Pass `input_id:` a CSS selector (e.g. "#due-date") to
17
+ # push the picked date (or "start – end" range) into a
18
+ # phlex-kit--calendar-input outlet — that's how PhlexKit::DatePicker binds
19
+ # an Input. Sizing rides --pk-cell-size/--pk-cell-radius (calendar.css).
10
20
  class Calendar < BaseComponent
11
- def initialize(selected_date: nil, min_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs)
21
+ MODES = { single: "single", range: "range", multiple: "multiple" }.freeze
22
+ CAPTION_LAYOUTS = %i[label dropdown].freeze
23
+ MONTHS = %w[January February March April May June July August September October November December].freeze
24
+
25
+ def initialize(mode: :single, selected_date: nil, selected_dates: [], range_start: nil, range_end: nil,
26
+ min_date: nil, max_date: nil, disabled_dates: [], week_numbers: false,
27
+ caption_layout: :label, from_year: nil, to_year: nil,
28
+ input_id: nil, date_format: "yyyy-MM-dd", **attrs)
29
+ @mode = MODES.fetch(mode.to_sym)
12
30
  @selected_date = selected_date
31
+ @selected_dates = Array(selected_dates).map(&:to_s)
32
+ @range_start = range_start
33
+ @range_end = range_end
13
34
  @min_date = min_date
35
+ @max_date = max_date
36
+ @disabled_dates = Array(disabled_dates).map(&:to_s)
37
+ @week_numbers = week_numbers
38
+ @caption_layout = caption_layout.to_sym
39
+ raise ArgumentError, "caption_layout must be :label or :dropdown" unless CAPTION_LAYOUTS.include?(@caption_layout)
40
+ @from_year = from_year
41
+ @to_year = to_year
14
42
  @input_id = input_id
15
43
  @date_format = date_format
16
44
  @attrs = attrs
@@ -19,29 +47,69 @@ module PhlexKit
19
47
  def view_template
20
48
  div(**mix(calendar_attrs, @attrs)) do
21
49
  render CalendarHeader.new do
22
- render CalendarTitle.new
50
+ if @caption_layout == :dropdown
51
+ render_dropdown_caption
52
+ else
53
+ render CalendarTitle.new
54
+ end
23
55
  render CalendarPrev.new
24
56
  render CalendarNext.new
25
57
  end
26
58
  render CalendarBody.new # Where the calendar is rendered (Weekdays and Days)
27
- render CalendarWeekdays.new # Template for the weekdays
59
+ render CalendarWeekdays.new(week_numbers: @week_numbers) # Template for the weekdays
28
60
  render CalendarDays.new # Templates for the day states
29
61
  end
30
62
  end
31
63
 
32
64
  private
33
65
 
66
+ def render_dropdown_caption
67
+ view_year = parse_year(@selected_date) || parse_year(@range_start) || Time.now.year
68
+ first_year = @from_year || parse_year(@min_date) || (view_year - 10)
69
+ last_year = @to_year || parse_year(@max_date) || (view_year + 10)
70
+
71
+ div(class: "pk-calendar-dropdowns") do
72
+ select(
73
+ class: "pk-native-select-field pk-calendar-dropdown",
74
+ aria: { label: "Month" },
75
+ data: { phlex_kit__calendar_target: "monthSelect", action: "change->phlex-kit--calendar#setMonth" }
76
+ ) do
77
+ MONTHS.each_with_index { |name, index| option(value: index.to_s) { name } }
78
+ end
79
+ select(
80
+ class: "pk-native-select-field pk-calendar-dropdown",
81
+ aria: { label: "Year" },
82
+ data: { phlex_kit__calendar_target: "yearSelect", action: "change->phlex-kit--calendar#setYear" }
83
+ ) do
84
+ (first_year..last_year).each { |year| option(value: year.to_s) { year.to_s } }
85
+ end
86
+ end
87
+ end
88
+
89
+ def parse_year(value)
90
+ value.to_s[/\A(\d{4})/, 1]&.to_i
91
+ end
92
+
34
93
  def calendar_attrs
35
- {
36
- class: "pk-calendar",
37
- data: {
38
- controller: "phlex-kit--calendar",
39
- phlex_kit__calendar_selected_date_value: @selected_date&.to_s,
40
- phlex_kit__calendar_min_date_value: @min_date&.to_s,
41
- phlex_kit__calendar_format_value: @date_format,
42
- phlex_kit__calendar_phlex_kit__calendar_input_outlet: @input_id
43
- }
94
+ data = {
95
+ controller: "phlex-kit--calendar",
96
+ phlex_kit__calendar_mode_value: @mode,
97
+ phlex_kit__calendar_selected_date_value: @selected_date&.to_s,
98
+ phlex_kit__calendar_min_date_value: @min_date&.to_s,
99
+ phlex_kit__calendar_max_date_value: @max_date&.to_s,
100
+ phlex_kit__calendar_format_value: @date_format,
101
+ phlex_kit__calendar_phlex_kit__calendar_input_outlet: @input_id
44
102
  }
103
+ data[:phlex_kit__calendar_range_start_value] = @range_start.to_s if @range_start
104
+ data[:phlex_kit__calendar_range_end_value] = @range_end.to_s if @range_end
105
+ data[:phlex_kit__calendar_selected_dates_value] = JSON.generate(@selected_dates) if @selected_dates.any?
106
+ data[:phlex_kit__calendar_disabled_dates_value] = JSON.generate(@disabled_dates) if @disabled_dates.any?
107
+ data[:phlex_kit__calendar_week_numbers_value] = "true" if @week_numbers
108
+ # Seed the view on the selection so the grid opens on the right month.
109
+ view_seed = @selected_date || @range_start || @selected_dates.first
110
+ data[:phlex_kit__calendar_view_date_value] = view_seed.to_s if view_seed
111
+
112
+ { class: "pk-calendar", data: data }
45
113
  end
46
114
  end
47
115
  end
@@ -1,10 +1,12 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- // Ported from ruby_ui's calendar controller with the mustache dependency
4
- // replaced: the day templates only use {{day}} / {{dayDate}}, so this tiny
5
- // interpolator covers them and only @hotwired/stimulus is needed. Identifiers
6
- // renamed ruby-ui -> phlex-kit; the week-row markup uses .pk-calendar-week
7
- // (calendar.css) instead of Tailwind utilities.
3
+ // Ported from ruby_ui's calendar controller (mustache replaced by a tiny
4
+ // {{key}} interpolator) and extended to shadcn's current calendar surface:
5
+ // mode single (default) / range / multiple, min/max + explicit disabled
6
+ // dates (rendered struck-through as "booked"), ISO week numbers, and a
7
+ // month/year dropdown caption (native <select>s the controller keeps in
8
+ // sync). Day templates gain a {{state}} class hook so range-start /
9
+ // range-end / in-range / booked ride the same five templates.
8
10
  function renderTemplate(template, data) {
9
11
  return template.replace(/{{\s*(\w+)\s*}}/g, (_, key) => String(data[key] ?? ""));
10
12
  }
@@ -13,6 +15,8 @@ export default class extends Controller {
13
15
  static targets = [
14
16
  "calendar",
15
17
  "title",
18
+ "monthSelect",
19
+ "yearSelect",
16
20
  "weekdaysTemplate",
17
21
  "disabledDateTemplate",
18
22
  "selectedDateTemplate",
@@ -21,22 +25,20 @@ export default class extends Controller {
21
25
  "otherMonthDateTemplate",
22
26
  ];
23
27
  static values = {
24
- selectedDate: {
25
- type: String,
26
- default: null,
27
- },
28
- minDate: {
29
- type: String,
30
- default: null,
31
- },
28
+ mode: { type: String, default: "single" },
29
+ selectedDate: { type: String, default: null },
30
+ selectedDates: { type: Array, default: [] },
31
+ rangeStart: { type: String, default: null },
32
+ rangeEnd: { type: String, default: null },
33
+ minDate: { type: String, default: null },
34
+ maxDate: { type: String, default: null },
35
+ disabledDates: { type: Array, default: [] },
36
+ weekNumbers: { type: Boolean, default: false },
32
37
  viewDate: {
33
38
  type: String,
34
39
  default: new Date().toISOString().slice(0, 10),
35
40
  },
36
- format: {
37
- type: String,
38
- default: "yyyy-MM-dd", // Default format
39
- },
41
+ format: { type: String, default: "yyyy-MM-dd" },
40
42
  };
41
43
  static outlets = ["phlex-kit--calendar-input"];
42
44
 
@@ -54,12 +56,70 @@ export default class extends Controller {
54
56
  this.viewDateValue = this.adjustMonth(-1);
55
57
  }
56
58
 
59
+ // month/year dropdown caption (native selects)
60
+ setMonth(e) {
61
+ const date = this.viewDate();
62
+ date.setDate(2);
63
+ date.setMonth(Number(e.target.value));
64
+ this.viewDateValue = date.toISOString().slice(0, 10);
65
+ }
66
+
67
+ setYear(e) {
68
+ const date = this.viewDate();
69
+ date.setDate(2);
70
+ date.setFullYear(Number(e.target.value));
71
+ this.viewDateValue = date.toISOString().slice(0, 10);
72
+ }
73
+
57
74
  selectDay(e) {
58
75
  e.preventDefault();
59
- if (this.isDateDisabled(e.currentTarget.dataset.day)) return;
76
+ const iso = e.currentTarget.dataset.day;
77
+ if (this.isDateDisabled(iso)) return;
78
+
79
+ switch (this.modeValue) {
80
+ case "range":
81
+ this.selectRangeDay(iso);
82
+ break;
83
+ case "multiple":
84
+ this.toggleMultipleDay(iso);
85
+ break;
86
+ default:
87
+ this.selectedDateValue = iso;
88
+ }
89
+ }
90
+
91
+ selectRangeDay(iso) {
92
+ const candidate = this.parseDate(iso);
93
+ const start = this.parseDate(this.rangeStartValue);
94
+ const end = this.parseDate(this.rangeEndValue);
95
+
96
+ if (!start || (start && end)) {
97
+ // begin a fresh range
98
+ this.rangeStartValue = this.isoDate(candidate);
99
+ this.rangeEndValue = null;
100
+ } else if (candidate < start) {
101
+ this.rangeStartValue = this.isoDate(candidate);
102
+ } else {
103
+ this.rangeEndValue = this.isoDate(candidate);
104
+ }
105
+ this.updateCalendar();
106
+ this.pushToOutlets();
107
+ this.dispatchChange();
108
+ }
60
109
 
61
- // Set the selected date value
62
- this.selectedDateValue = e.currentTarget.dataset.day;
110
+ toggleMultipleDay(iso) {
111
+ const day = this.isoDate(this.parseDate(iso));
112
+ const current = [...this.selectedDatesValue];
113
+ const index = current.indexOf(day);
114
+ if (index >= 0) {
115
+ current.splice(index, 1);
116
+ } else {
117
+ current.push(day);
118
+ current.sort();
119
+ }
120
+ this.selectedDatesValue = current;
121
+ this.updateCalendar();
122
+ this.dispatchChange();
63
123
  }
64
124
 
65
125
  selectedDateValueChanged(value, prevValue) {
@@ -69,38 +129,77 @@ export default class extends Controller {
69
129
  return;
70
130
  }
71
131
 
72
- // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)
132
+ // update the viewDateValue to the selected date's month (this re-renders)
73
133
  const newViewDate = new Date(selectedDate);
74
- newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)
134
+ newViewDate.setDate(2); // avoid month-length/timezone edges
75
135
  this.viewDateValue = newViewDate.toISOString().slice(0, 10);
76
136
 
77
- // Re-render the calendar
78
137
  this.updateCalendar();
79
-
80
- // update the input value
81
- this.phlexKitCalendarInputOutlets.forEach((outlet) => {
82
- const formattedDate = this.formatDate(selectedDate);
83
- outlet.setValue(formattedDate);
84
- });
138
+ this.pushToOutlets();
139
+ this.dispatchChange();
85
140
  }
86
141
 
87
142
  viewDateValueChanged(value, prevValue) {
88
143
  this.updateCalendar();
89
144
  }
90
145
 
146
+ pushToOutlets() {
147
+ const value = this.formattedSelection();
148
+ if (value === null) return;
149
+ this.phlexKitCalendarInputOutlets.forEach((outlet) => outlet.setValue(value));
150
+ }
151
+
152
+ formattedSelection() {
153
+ if (this.modeValue === "range") {
154
+ const start = this.parseDate(this.rangeStartValue);
155
+ const end = this.parseDate(this.rangeEndValue);
156
+ if (!start) return null;
157
+ return end ? `${this.formatDate(start)} – ${this.formatDate(end)}` : this.formatDate(start);
158
+ }
159
+ if (this.modeValue === "multiple") {
160
+ return this.selectedDatesValue.map((d) => this.formatDate(this.parseDate(d))).join(", ");
161
+ }
162
+ const selected = this.selectedDate();
163
+ return selected ? this.formatDate(selected) : null;
164
+ }
165
+
166
+ dispatchChange() {
167
+ this.dispatch("change", {
168
+ detail: {
169
+ mode: this.modeValue,
170
+ selected: this.selectedDateValue,
171
+ selectedDates: this.selectedDatesValue,
172
+ rangeStart: this.rangeStartValue,
173
+ rangeEnd: this.rangeEndValue,
174
+ formatted: this.formattedSelection(),
175
+ },
176
+ });
177
+ }
178
+
91
179
  adjustMonth(adjustment) {
92
180
  const date = this.viewDate();
93
- date.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)
181
+ date.setDate(2); // avoid month-length/timezone edges
94
182
  date.setMonth(date.getMonth() + adjustment);
95
183
  return date.toISOString().slice(0, 10);
96
184
  }
97
185
 
98
186
  updateCalendar() {
99
- // Update the title with month and year
100
- this.titleTarget.textContent = this.monthAndYear();
187
+ if (this.hasTitleTarget) {
188
+ this.titleTarget.textContent = this.monthAndYear();
189
+ }
190
+ this.syncDropdowns();
101
191
  this.calendarTarget.innerHTML = this.calendarHTML();
102
192
  }
103
193
 
194
+ syncDropdowns() {
195
+ if (this.hasMonthSelectTarget) {
196
+ this.monthSelectTarget.value = String(this.viewDate().getMonth());
197
+ }
198
+ if (this.hasYearSelectTarget) {
199
+ this.yearSelectTarget.value = String(this.viewDate().getFullYear());
200
+ }
201
+ }
202
+
104
203
  calendarHTML() {
105
204
  return this.weekdaysTemplateTarget.innerHTML + this.calendarDays();
106
205
  }
@@ -112,53 +211,83 @@ export default class extends Controller {
112
211
  }
113
212
 
114
213
  renderWeek(week) {
115
- const days = week
116
- .map((day) => {
117
- return this.renderDay(day);
118
- })
119
- .join("");
120
- return `<tr class="pk-calendar-week">${days}</tr>`;
214
+ let cells = week.map((day) => this.renderDay(day)).join("");
215
+ if (this.weekNumbersValue) {
216
+ const weekNumber = this.isoWeekNumber(week[0]);
217
+ cells = `<td class="pk-calendar-weeknumber" role="presentation">${weekNumber}</td>` + cells;
218
+ }
219
+ return `<tr class="pk-calendar-week">${cells}</tr>`;
121
220
  }
122
221
 
123
222
  renderDay(day) {
124
223
  const today = new Date();
125
- const selectedDate = this.selectedDate();
126
- let dateHTML = "";
127
- const data = { day: day, dayDate: day.getDate() };
224
+ const iso = this.isoDate(day);
225
+ const data = { day: iso, dayDate: day.getDate(), state: this.dayState(day) };
128
226
 
129
227
  if (this.isDateDisabled(day)) {
130
- // disabledDate
131
- dateHTML = renderTemplate(
132
- this.disabledDateTemplateTarget.innerHTML,
133
- data,
134
- );
135
- } else if (
136
- selectedDate &&
137
- day.toDateString() === selectedDate.toDateString()
138
- ) {
139
- // selectedDate
140
- // Render the selected date template
141
- dateHTML = renderTemplate(
142
- this.selectedDateTemplateTarget.innerHTML,
143
- data,
144
- );
145
- } else if (day.toDateString() === today.toDateString()) {
146
- // todayDate
147
- dateHTML = renderTemplate(this.todayDateTemplateTarget.innerHTML, data);
148
- } else if (day.getMonth() === this.viewDate().getMonth()) {
149
- // currentMonthDate
150
- dateHTML = renderTemplate(
151
- this.currentMonthDateTemplateTarget.innerHTML,
152
- data,
153
- );
154
- } else {
155
- // otherMonthDate
156
- dateHTML = renderTemplate(
157
- this.otherMonthDateTemplateTarget.innerHTML,
158
- data,
228
+ return renderTemplate(this.disabledDateTemplateTarget.innerHTML, data);
229
+ }
230
+ if (this.isDaySelected(day)) {
231
+ return renderTemplate(this.selectedDateTemplateTarget.innerHTML, data);
232
+ }
233
+ if (this.isDayInRange(day)) {
234
+ // middle of a range: current-month look + the in-range state class
235
+ return renderTemplate(this.currentMonthDateTemplateTarget.innerHTML, data);
236
+ }
237
+ if (day.toDateString() === today.toDateString()) {
238
+ return renderTemplate(this.todayDateTemplateTarget.innerHTML, data);
239
+ }
240
+ if (day.getMonth() === this.viewDate().getMonth()) {
241
+ return renderTemplate(this.currentMonthDateTemplateTarget.innerHTML, data);
242
+ }
243
+ return renderTemplate(this.otherMonthDateTemplateTarget.innerHTML, data);
244
+ }
245
+
246
+ // Extra state classes interpolated into the day templates ({{state}}).
247
+ dayState(day) {
248
+ const states = [];
249
+ if (this.isBooked(day)) states.push("booked");
250
+ if (this.modeValue === "range") {
251
+ const start = this.parseDate(this.rangeStartValue);
252
+ const end = this.parseDate(this.rangeEndValue);
253
+ const isStart = start && day.toDateString() === start.toDateString();
254
+ const isEnd = end && day.toDateString() === end.toDateString();
255
+ if (isStart) states.push("range-start");
256
+ if (isEnd) states.push("range-end");
257
+ // caps only band with the accent once the range is complete
258
+ if (start && end && (isStart || isEnd)) states.push("range-cap");
259
+ if (this.isDayInRange(day)) states.push("in-range");
260
+ }
261
+ return states.join(" ");
262
+ }
263
+
264
+ isDaySelected(day) {
265
+ if (this.modeValue === "range") {
266
+ const start = this.parseDate(this.rangeStartValue);
267
+ const end = this.parseDate(this.rangeEndValue);
268
+ return (
269
+ (start && day.toDateString() === start.toDateString()) ||
270
+ (end && day.toDateString() === end.toDateString())
159
271
  );
160
272
  }
161
- return dateHTML;
273
+ if (this.modeValue === "multiple") {
274
+ return this.selectedDatesValue.includes(this.isoDate(day));
275
+ }
276
+ const selectedDate = this.selectedDate();
277
+ return selectedDate && day.toDateString() === selectedDate.toDateString();
278
+ }
279
+
280
+ isDayInRange(day) {
281
+ if (this.modeValue !== "range") return false;
282
+ const start = this.parseDate(this.rangeStartValue);
283
+ const end = this.parseDate(this.rangeEndValue);
284
+ if (!start || !end) return false;
285
+ const candidate = this.startOfDay(day);
286
+ return candidate > this.startOfDay(start) && candidate < this.startOfDay(end);
287
+ }
288
+
289
+ isBooked(day) {
290
+ return this.disabledDatesValue.includes(this.isoDate(day));
162
291
  }
163
292
 
164
293
  monthAndYear() {
@@ -222,7 +351,6 @@ export default class extends Controller {
222
351
  break;
223
352
  }
224
353
  }
225
- // *** the magic starts here
226
354
  return weeks.map(({ start, end }, index) => {
227
355
  const sub = +(start > end && index === 0);
228
356
  return Array.from({ length: 7 }, (_, index) => {
@@ -232,6 +360,21 @@ export default class extends Controller {
232
360
  });
233
361
  }
234
362
 
363
+ isoWeekNumber(date) {
364
+ const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
365
+ const dayNumber = target.getUTCDay() || 7;
366
+ target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
367
+ const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
368
+ return Math.ceil(((target - yearStart) / 86400000 + 1) / 7);
369
+ }
370
+
371
+ isoDate(date) {
372
+ const year = date.getFullYear();
373
+ const month = ("0" + (date.getMonth() + 1)).slice(-2);
374
+ const day = ("0" + date.getDate()).slice(-2);
375
+ return `${year}-${month}-${day}`;
376
+ }
377
+
235
378
  formatDate(date) {
236
379
  const format = this.formatValue;
237
380
  const day = date.getDate();
@@ -282,13 +425,21 @@ export default class extends Controller {
282
425
  return this.parseDate(this.minDateValue);
283
426
  }
284
427
 
428
+ maxDate() {
429
+ return this.parseDate(this.maxDateValue);
430
+ }
431
+
285
432
  isDateDisabled(date) {
286
- const minDate = this.minDate();
287
433
  const candidate = this.parseDate(date);
434
+ if (!candidate) return false;
435
+
436
+ const minDate = this.minDate();
437
+ if (minDate && this.startOfDay(candidate) < this.startOfDay(minDate)) return true;
288
438
 
289
- if (!minDate || !candidate) return false;
439
+ const maxDate = this.maxDate();
440
+ if (maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate)) return true;
290
441
 
291
- return this.startOfDay(candidate) < this.startOfDay(minDate);
442
+ return this.disabledDatesValue.includes(this.isoDate(candidate));
292
443
  }
293
444
 
294
445
  parseDate(value) {
@@ -1,7 +1,8 @@
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}}. See calendar.rb.
4
+ # {{day}} / {{dayDate}} / {{state}} — state carries the extra range/booked
5
+ # classes (range-start / range-end / in-range / booked). See calendar.rb.
5
6
  class CalendarDays < BaseComponent
6
7
  def initialize(**attrs)
7
8
  @attrs = attrs
@@ -22,7 +23,7 @@ module PhlexKit
22
23
  button(
23
24
  data_day: "{{day}}",
24
25
  name: "day",
25
- class: "pk-calendar-day disabled",
26
+ class: "pk-calendar-day disabled {{state}}",
26
27
  disabled: true,
27
28
  role: "gridcell",
28
29
  tabindex: "-1",
@@ -38,7 +39,7 @@ module PhlexKit
38
39
  data_day: "{{day}}",
39
40
  data_action: "click->phlex-kit--calendar#selectDay",
40
41
  name: "day",
41
- class: "pk-calendar-day selected",
42
+ class: "pk-calendar-day selected {{state}}",
42
43
  role: "gridcell",
43
44
  tabindex: "0",
44
45
  type: "button",
@@ -53,7 +54,7 @@ module PhlexKit
53
54
  data_day: "{{day}}",
54
55
  data_action: "click->phlex-kit--calendar#selectDay",
55
56
  name: "day",
56
- class: "pk-calendar-day today",
57
+ class: "pk-calendar-day today {{state}}",
57
58
  role: "gridcell",
58
59
  tabindex: "-1",
59
60
  type: "button"
@@ -67,7 +68,7 @@ module PhlexKit
67
68
  data_day: "{{day}}",
68
69
  data_action: "click->phlex-kit--calendar#selectDay",
69
70
  name: "day",
70
- class: "pk-calendar-day",
71
+ class: "pk-calendar-day {{state}}",
71
72
  role: "gridcell",
72
73
  tabindex: "-1",
73
74
  type: "button"
@@ -81,7 +82,7 @@ module PhlexKit
81
82
  data_day: "{{day}}",
82
83
  data_action: "click->phlex-kit--calendar#selectDay",
83
84
  name: "day",
84
- class: "pk-calendar-day other",
85
+ class: "pk-calendar-day other {{state}}",
85
86
  role: "gridcell",
86
87
  tabindex: "-1",
87
88
  type: "button"
@@ -4,7 +4,8 @@ module PhlexKit
4
4
  class CalendarWeekdays < BaseComponent
5
5
  DAYS = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
6
6
 
7
- def initialize(**attrs)
7
+ def initialize(week_numbers: false, **attrs)
8
+ @week_numbers = week_numbers
8
9
  @attrs = attrs
9
10
  end
10
11
 
@@ -12,6 +13,7 @@ module PhlexKit
12
13
  template(data: { phlex_kit__calendar_target: "weekdaysTemplate" }) do
13
14
  thead(**@attrs) do
14
15
  tr(class: "pk-calendar-weekdays-row") do
16
+ th(scope: "col", class: "pk-calendar-weekday pk-calendar-weeknumber-head", aria: { label: "Week number" }) { "#" } if @week_numbers
15
17
  DAYS.each { |day| render_day(day) }
16
18
  end
17
19
  end