maquina-components 0.7.4 → 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f12b51321ecf3ce0e3c1372bab8dec0770be3fc69f66ac68384f8980994d1018
4
- data.tar.gz: 6b24d430d5014890dc45a78cf2b13e5ef1d4b8991732b59b0002c40d8f7f598d
3
+ metadata.gz: 95885780d4ac7c947e4561e821d4c3cb2f985a3a363738f7cdc7fc01a858fc11
4
+ data.tar.gz: 35100ad72833ef5be786a40444f5af15c37866d0dc8dce4dee34232f04bfe0a5
5
5
  SHA512:
6
- metadata.gz: c64b09b67ffc2f10d85e7870edfa538c11171e210abf0381b1ac8f7e481771e83f24327317bb2208795fdd675dd8133ae58557c7eba17913f5d6f7fcd5cd176c
7
- data.tar.gz: 2d66b6f096c3cfc5854e4fff1daea50f4f7f2dfe9b640db6f849cda507ae449f6ccf7a37b9b0f2a2724d3a72e5f8859575072a4ce35284a3f93a82c5f13b7fea
6
+ metadata.gz: b1e7d220b352af1cd2e9fabf11902165c2d7d958608ea504f0b6a896fef336c3c8cd1e556090ab2db7dfa54c8812249fc5611c1a19820701c37c015c15b82a10
7
+ data.tar.gz: 347641b6a36dac6f72f9344e8e6af0a81f0c80bf29dd1ce022e20db9a295db2141d540eb16d41cea92fc19f4a59345438712f9b958c802b3cc1f109bd2d5abbb
@@ -7,15 +7,24 @@
7
7
  * Structure:
8
8
  * - calendar (root container)
9
9
  * - header (navigation)
10
+ * - caption, or with caption_layout: :dropdown
11
+ * dropdowns > month-select, year-select (native [data-component="select"])
10
12
  * - weekdays (day name headers)
11
13
  * - grid (day buttons grid)
12
14
  * - week (row)
13
15
  * - day (button)
16
+ * - footer (optional, the partial's block)
17
+ *
18
+ * Inside a popover the controller sets data-side="top|bottom" on the root.
19
+ * Flipped above its trigger, a popover is pinned at its bottom edge while
20
+ * months change height (4-6 weeks), so [data-side="top"] moves the header
21
+ * last — next to the trigger, where the arrows stay put.
14
22
  *
15
23
  * Cascade order in this file (full contract documented in form.css):
16
24
  * 1. base [data-component="calendar"], [data-calendar-part="…"]
17
25
  * 2. sizes — none (cell metrics come from --cell-size)
18
- * 3. variants [data-calendar-part="day"]:where([data-today], [data-state="…"], [data-outside])
26
+ * 3. variants [data-calendar-part="day"]:where([data-today], [data-state="…"], [data-outside]),
27
+ * [data-component="calendar"]:where([data-side="top"])
19
28
  * 4. states […]:where(:hover, :focus-visible, :disabled)
20
29
  * 5. dark — none; dark-mode differences are tokens (see tokens.css)
21
30
  * Every rule is flattened to 0,1,0 with :where() and lives in
@@ -94,6 +103,26 @@
94
103
  color: var(--foreground);
95
104
  }
96
105
 
106
+ /* ===== Caption dropdowns =====
107
+ Month and year are native selects styled by form.css (colors, radius,
108
+ focus, chevron, dark mode). Here they only shrink to fit beside the
109
+ arrows in a 7-cell header: calendar.css loads after form.css, so these
110
+ 0,1,0 rules win on source order. */
111
+ [data-calendar-part="dropdowns"] {
112
+ display: flex;
113
+ align-items: center;
114
+ @apply gap-1;
115
+ }
116
+
117
+ :where([data-calendar-part="dropdowns"]) [data-component="select"] {
118
+ width: auto;
119
+ height: var(--cell-size);
120
+ @apply pl-2 pr-6 py-0 text-sm;
121
+ font-weight: var(--label-weight, 500);
122
+ background-position: right 0.25rem center;
123
+ background-size: 1rem 1rem;
124
+ }
125
+
97
126
  /* ===== Weekday Headers ===== */
98
127
  [data-calendar-part="weekdays"] {
99
128
  display: grid;
@@ -123,6 +152,11 @@
123
152
  grid-template-columns: repeat(7, var(--cell-size));
124
153
  }
125
154
 
155
+ /* ===== Footer ===== */
156
+ [data-calendar-part="footer"] {
157
+ @apply mt-4;
158
+ }
159
+
126
160
  /* ===== Day Button Base ===== */
127
161
  [data-calendar-part="day"] {
128
162
  display: inline-flex;
@@ -205,6 +239,19 @@
205
239
  @apply opacity-30;
206
240
  }
207
241
 
242
+ /* ===== Popover side ===== */
243
+
244
+ /* Opened above the trigger: weekdays, grid, footer, then the header */
245
+ [data-component="calendar"]:where([data-side="top"]) {
246
+ display: flex;
247
+ flex-direction: column;
248
+ }
249
+
250
+ :where([data-component="calendar"][data-side="top"]) [data-calendar-part="header"] {
251
+ order: 1;
252
+ @apply mt-4 mb-0;
253
+ }
254
+
208
255
  /* ===== Day States ===== */
209
256
 
210
257
  /* Hover — only for cells with no selection state of their own */
@@ -14,7 +14,8 @@
14
14
  * 2. sizes :where([data-size="sm"|"lg"]) [data-date-picker-part="trigger"]
15
15
  * 3. variants :where([data-full-width]) …
16
16
  * 4. states […]:where(:hover, :focus-visible, :disabled, [aria-expanded],
17
- * :has(input:invalid))
17
+ * :has(input:invalid)); popover open/closed, and
18
+ * :has(calendar[data-side="top"]) for the animation
18
19
  * 5. dark — none; dark-mode differences are tokens (see tokens.css)
19
20
  * Every rule is flattened to 0,1,0 with :where() and lives in
20
21
  * @layer components, so a caller's utility class wins and source order is
@@ -160,6 +161,17 @@
160
161
  }
161
162
  }
162
163
 
164
+ /* Opened above the trigger (the calendar's data-side): grow upward out of it */
165
+ [data-date-picker-part="popover"]:where(:not(:popover-open):has([data-component="calendar"][data-side="top"])) {
166
+ transform: translateY(0.5rem) scale(0.95);
167
+ }
168
+
169
+ @starting-style {
170
+ [data-date-picker-part="popover"]:where(:popover-open:has([data-component="calendar"][data-side="top"])) {
171
+ transform: translateY(0.5rem) scale(0.95);
172
+ }
173
+ }
174
+
163
175
  /* Backdrop */
164
176
  [data-date-picker-part="popover"]::backdrop {
165
177
  background-color: transparent;
@@ -72,6 +72,43 @@ module MaquinaComponents
72
72
  end
73
73
  end
74
74
 
75
+ # Years offered by the caption's year select (caption_layout: :dropdown)
76
+ #
77
+ # min_date/max_date set the bounds when present; otherwise years applies,
78
+ # 100 years back to 10 ahead by default. Given both, the narrower bound
79
+ # wins. The displayed year is always included.
80
+ #
81
+ # @param display_year [Integer] Year the calendar shows
82
+ # @param min_date [Date, nil] Minimum selectable date
83
+ # @param max_date [Date, nil] Maximum selectable date
84
+ # @param years [Range, nil] Explicit span of years
85
+ # @return [Range]
86
+ def calendar_year_range(display_year, min_date: nil, max_date: nil, years: nil)
87
+ first, last = (years || ((Date.current.year - 100)..(Date.current.year + 10))).minmax
88
+ first = min_date.year if min_date && (years.nil? || min_date.year > first)
89
+ last = max_date.year if max_date && (years.nil? || max_date.year < last)
90
+
91
+ [first, display_year].min..[last, display_year].max
92
+ end
93
+
94
+ # Options for the caption's month select: abbreviated localized names
95
+ # (the full ones do not fit beside the year in a 7-cell header), with the
96
+ # months wholly outside min_date/max_date disabled for that year
97
+ #
98
+ # @param year [Integer] Year the months belong to
99
+ # @param min_date [Date, nil] Minimum selectable date
100
+ # @param max_date [Date, nil] Maximum selectable date
101
+ # @return [Array<Array(String, Integer, Boolean)>] [name, month, disabled]
102
+ def calendar_month_options(year, min_date: nil, max_date: nil)
103
+ I18n.t("date.abbr_month_names").drop(1).each_with_index.map do |name, index|
104
+ first_of_month = Date.new(year, index + 1, 1)
105
+ disabled = (min_date && first_of_month.end_of_month < min_date) ||
106
+ (max_date && first_of_month > max_date)
107
+
108
+ [name, index + 1, !!disabled]
109
+ end
110
+ end
111
+
75
112
  # Check if a date falls within a range
76
113
  #
77
114
  # @param date [Date] The date to check
@@ -1,7 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["day", "input", "inputEnd", "prevButton", "nextButton", "grid", "caption"]
4
+ static targets = ["day", "input", "inputEnd", "prevButton", "nextButton", "grid", "caption", "monthSelect", "yearSelect"]
5
5
 
6
6
  static values = {
7
7
  month: Number,
@@ -13,12 +13,43 @@ export default class extends Controller {
13
13
  mode: { type: String, default: "single" },
14
14
  weekStartsOn: { type: String, default: "sunday" },
15
15
  disabledDates: Array,
16
- showOutsideDays: { type: Boolean, default: true }
16
+ showOutsideDays: { type: Boolean, default: true },
17
+ // Bounds of the caption's year select (caption_layout: :dropdown only)
18
+ startYear: Number,
19
+ endYear: Number
17
20
  }
18
21
 
19
22
  connect() {
20
23
  this.updateNavigationState()
21
24
  this.updateDayStates()
25
+
26
+ // Inside a popover (date picker or a caller's own), track which side of
27
+ // the trigger it opened on, so the header can sit next to the trigger
28
+ this.popover = this.element.closest("[popover]")
29
+ if (this.popover) {
30
+ this.boundUpdateSide = this.updateSide.bind(this)
31
+ this.popover.addEventListener("toggle", this.boundUpdateSide)
32
+ }
33
+ }
34
+
35
+ disconnect() {
36
+ this.popover?.removeEventListener("toggle", this.boundUpdateSide)
37
+ }
38
+
39
+ /**
40
+ * Sets data-side="top|bottom" when the popover is open. A popover flipped
41
+ * above its trigger is pinned at its bottom edge, so CSS moves the header
42
+ * there: month heights differ, and the arrows must not move under the cursor.
43
+ */
44
+ updateSide() {
45
+ if (!this.popover?.matches(":popover-open")) return
46
+
47
+ const trigger = this.popover.id &&
48
+ document.querySelector(`[popovertarget="${CSS.escape(this.popover.id)}"]`)
49
+ if (!trigger) return
50
+
51
+ const above = this.popover.getBoundingClientRect().top < trigger.getBoundingClientRect().top
52
+ this.element.dataset.side = above ? "top" : "bottom"
22
53
  }
23
54
 
24
55
  previousMonth() {
@@ -45,12 +76,38 @@ export default class extends Controller {
45
76
  this.navigateToMonth(newMonth, newYear)
46
77
  }
47
78
 
79
+ // Caption dropdowns (caption_layout: :dropdown)
80
+ selectMonth() {
81
+ this.navigateToMonth(Number(this.monthSelectTarget.value), this.yearValue)
82
+ }
83
+
84
+ selectYear() {
85
+ const year = Number(this.yearSelectTarget.value)
86
+ this.navigateToMonth(this.clampMonth(this.monthValue, year), year)
87
+ }
88
+
89
+ // A year change keeps the month, unless min/max date rule it out that year
90
+ clampMonth(month, year) {
91
+ const [minYear, minMonth] = (this.minDateValue || "").split("-").map(Number)
92
+ const [maxYear, maxMonth] = (this.maxDateValue || "").split("-").map(Number)
93
+
94
+ if (year === minYear && month < minMonth) return minMonth
95
+ if (year === maxYear && month > maxMonth) return maxMonth
96
+ return month
97
+ }
98
+
48
99
  navigateToMonth(month, year) {
100
+ // The year select lists only startYear..endYear; never show a month it can't
101
+ if ((this.hasStartYearValue && year < this.startYearValue) ||
102
+ (this.hasEndYearValue && year > this.endYearValue)) return
103
+
49
104
  this.monthValue = month
50
105
  this.yearValue = year
51
106
 
52
107
  this.rebuildCalendar()
108
+ this.syncDropdowns()
53
109
  this.updateNavigationState()
110
+ this.updateSide()
54
111
 
55
112
  this.dispatch("navigate", {
56
113
  detail: {
@@ -337,14 +394,40 @@ export default class extends Controller {
337
394
  }
338
395
 
339
396
  updateNavigationState() {
340
- if (this.minDateValue && this.hasPrevButtonTarget) {
341
- const firstOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue - 1, 1))
342
- this.prevButtonTarget.disabled = firstOfMonth <= this.minDateValue
397
+ const firstOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue - 1, 1))
398
+ const lastOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue, 0))
399
+
400
+ if (this.hasPrevButtonTarget) {
401
+ this.prevButtonTarget.disabled = Boolean(
402
+ (this.minDateValue && firstOfMonth <= this.minDateValue) ||
403
+ (this.hasStartYearValue && this.monthValue === 1 && this.yearValue <= this.startYearValue)
404
+ )
405
+ }
406
+
407
+ if (this.hasNextButtonTarget) {
408
+ this.nextButtonTarget.disabled = Boolean(
409
+ (this.maxDateValue && lastOfMonth >= this.maxDateValue) ||
410
+ (this.hasEndYearValue && this.monthValue === 12 && this.yearValue >= this.endYearValue)
411
+ )
412
+ }
413
+ }
414
+
415
+ // Mirrors calendar_month_options: a month wholly outside min/max is disabled
416
+ syncDropdowns() {
417
+ if (this.hasMonthSelectTarget) {
418
+ for (const option of this.monthSelectTarget.options) {
419
+ const month = Number(option.value)
420
+ const first = this.formatDate(new Date(this.yearValue, month - 1, 1))
421
+ const last = this.formatDate(new Date(this.yearValue, month, 0))
422
+
423
+ option.disabled = Boolean((this.minDateValue && last < this.minDateValue) ||
424
+ (this.maxDateValue && first > this.maxDateValue))
425
+ }
426
+ this.monthSelectTarget.value = String(this.monthValue)
343
427
  }
344
428
 
345
- if (this.maxDateValue && this.hasNextButtonTarget) {
346
- const lastOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue, 0))
347
- this.nextButtonTarget.disabled = lastOfMonth >= this.maxDateValue
429
+ if (this.hasYearSelectTarget) {
430
+ this.yearSelectTarget.value = String(this.yearValue)
348
431
  }
349
432
  }
350
433
 
@@ -1,4 +1,4 @@
1
- <%# locals: (selected: nil, selected_end: nil, month: nil, year: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, cell_size: nil, input_name: nil, input_name_end: nil, css_classes: "", **html_options) %>
1
+ <%# locals: (selected: nil, selected_end: nil, month: nil, year: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, caption_layout: :label, years: nil, cell_size: nil, input_name: nil, input_name_end: nil, css_classes: "", **html_options) %>
2
2
  <%
3
3
  # Dates may arrive as Date, Time or ISO strings
4
4
  selected_date = calendar_to_date(selected)
@@ -36,6 +36,18 @@
36
36
  abbr_day_names.rotate(1) :
37
37
  abbr_day_names
38
38
 
39
+ # caption_layout: :dropdown swaps the caption for month and year selects.
40
+ # Their locals reach the header only then, so an app's own calendar/header
41
+ # override written for the caption keeps working.
42
+ dropdown = caption_layout.to_sym == :dropdown
43
+ year_range = calendar_year_range(display_year, min_date: min_date_parsed, max_date: max_date_parsed, years: years) if dropdown
44
+ header_locals = if dropdown
45
+ {caption_layout: :dropdown, years: year_range,
46
+ month_options: calendar_month_options(display_year, min_date: min_date_parsed, max_date: max_date_parsed)}
47
+ else
48
+ {}
49
+ end
50
+
39
51
  merged_data = merge_component_data(html_options,
40
52
  controller: "calendar",
41
53
  component: "calendar",
@@ -48,7 +60,9 @@
48
60
  "calendar-max-date-value": max_date_parsed&.iso8601,
49
61
  "calendar-week-starts-on-value": week_starts_on,
50
62
  "calendar-disabled-dates-value": disabled_dates_parsed.map(&:iso8601).presence,
51
- "calendar-show-outside-days-value": show_outside_days
63
+ "calendar-show-outside-days-value": show_outside_days,
64
+ "calendar-start-year-value": year_range&.first,
65
+ "calendar-end-year-value": year_range&.last
52
66
  )
53
67
 
54
68
  # cell_size and a caller's style: both write the style attribute, so join them
@@ -80,7 +94,8 @@
80
94
  <%= render "components/calendar/header",
81
95
  month: display_month,
82
96
  year: display_year,
83
- month_name: I18n.l(first_of_month, format: "%B %Y") %>
97
+ month_name: I18n.l(first_of_month, format: "%B %Y"),
98
+ **header_locals %>
84
99
 
85
100
  <%# Weekday headers %>
86
101
  <div data-calendar-part="weekdays">
@@ -104,4 +119,13 @@
104
119
  show_outside_days: show_outside_days %>
105
120
  <% end %>
106
121
  </div>
122
+
123
+ <%# Optional footer (actions such as Clear/Done); stays between the grid and
124
+ a header moved to the bottom when the calendar's popover opens above.
125
+ Without a caller block a partial's yield returns content_for(:layout),
126
+ which is the whole page when rendered from a layout, so that is no footer. %>
127
+ <% footer = capture { yield }.presence %>
128
+ <% if footer && footer != content_for(:layout) %>
129
+ <div data-calendar-part="footer"><%= footer %></div>
130
+ <% end %>
107
131
  <% end %>
@@ -1,4 +1,4 @@
1
- <%# locals: (selected: nil, selected_end: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, placeholder: nil, input_name: nil, input_name_end: nil, id: nil, disabled: false, required: false, size: :default, full_width: false, css_classes: "", **html_options) %>
1
+ <%# locals: (selected: nil, selected_end: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, caption_layout: :label, years: nil, placeholder: nil, input_name: nil, input_name_end: nil, id: nil, disabled: false, required: false, size: :default, full_width: false, css_classes: "", **html_options) %>
2
2
  <%
3
3
  # Deterministic ID for popover targeting: random ids break idiomorph
4
4
  # matching across Turbo morphs
@@ -81,6 +81,8 @@
81
81
  disabled_dates: disabled_dates,
82
82
  show_outside_days: show_outside_days,
83
83
  week_starts_on: week_starts_on,
84
+ caption_layout: caption_layout,
85
+ years: years,
84
86
  data: {
85
87
  "date-picker-target": "calendar",
86
88
  action: "calendar:change->date-picker#handleChange calendar:navigate->date-picker#handleNavigate"
@@ -1,4 +1,4 @@
1
- <%# locals: (month:, year:, month_name:, css_classes: "", **html_options) %>
1
+ <%# locals: (month:, year:, month_name:, caption_layout: :label, years: nil, month_options: nil, css_classes: "", **html_options) %>
2
2
  <% merged_data = merge_component_data(html_options, "calendar-part": "header") %>
3
3
 
4
4
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
@@ -9,9 +9,33 @@
9
9
  <%= builtin_icon_for :chevron_left, class: "size-4" %>
10
10
  </button>
11
11
 
12
- <div data-calendar-part="caption">
13
- <%= month_name %>
14
- </div>
12
+ <% if caption_layout.to_sym == :dropdown %>
13
+ <div data-calendar-part="dropdowns">
14
+ <select data-component="select"
15
+ data-calendar-part="month-select"
16
+ data-calendar-target="monthSelect"
17
+ data-action="change->calendar#selectMonth"
18
+ aria-label="<%= t("maquina_components.calendar.month", default: "Month") %>">
19
+ <% month_options.each do |name, value, disabled| %>
20
+ <%= tag.option name, value: value, selected: value == month, disabled: disabled %>
21
+ <% end %>
22
+ </select>
23
+
24
+ <select data-component="select"
25
+ data-calendar-part="year-select"
26
+ data-calendar-target="yearSelect"
27
+ data-action="change->calendar#selectYear"
28
+ aria-label="<%= t("maquina_components.calendar.year", default: "Year") %>">
29
+ <% years.each do |value| %>
30
+ <%= tag.option value, value: value, selected: value == year %>
31
+ <% end %>
32
+ </select>
33
+ </div>
34
+ <% else %>
35
+ <div data-calendar-part="caption">
36
+ <%= month_name %>
37
+ </div>
38
+ <% end %>
15
39
 
16
40
  <button type="button"
17
41
  data-action="click->calendar#nextMonth"
@@ -1,3 +1,3 @@
1
1
  module MaquinaComponents
2
- VERSION = "0.7.4"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maquina-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.4
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez