atomic_view 0.1.18 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf51382f0171a3bef2e79ae3229c2de7838e4b69d01fa8ea7ec8cc3ba0d0cde6
4
- data.tar.gz: 0a990c04df1c8cfc688b66c0c4d2805c54e5af984ca4e81942f1225952c15841
3
+ metadata.gz: 2cfa124ea9d90829cb7c8879e99d63b4c0e5587f4ee294ca19557f9dc0b92699
4
+ data.tar.gz: 972a88497711cd0d4c1f9e323346886b9eed136c350120ab0e6adfbb62764f25
5
5
  SHA512:
6
- metadata.gz: d5f777b44351f97e414c6f157389fd3fc00e360e08dbb40dff71e3f4251d60341e4ed0c6ad5479d38a207bc861261572ba0e53460239393ce707b238e5f762a2
7
- data.tar.gz: 791368e6d9ee177cd2ee840697158a876062af1e44881ae505606c51c0b09286b04e627c980c5b7ef047ed9f76af7226c6b5636cc86c7c41a5358af08a608b23
6
+ metadata.gz: 3696f874e0d5e627a572be114d091a18d451427f9e980e79b8b9304a3965b0d5a11060d44b0f7a7711364553199127778dc86ffc8d86d45851a65184250861fb
7
+ data.tar.gz: db66b0814f6c2b03969d1d4f0b422f1494768d24699d308e522deefcd5a629e77bf4382263a573765188ead91c986e0618b52700c8ff897852852ae823578049
data/Rakefile CHANGED
@@ -6,3 +6,5 @@ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
6
6
  load 'rails/tasks/engine.rake'
7
7
 
8
8
  require 'bundler/gem_tasks'
9
+
10
+ task default: "app:test"
@@ -0,0 +1,199 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ // `@hotwired/turbo-rails`'s own module re-exports the real Turbo namespace
3
+ // under a *named* `Turbo` export (`export { Turbo }`, itself `* as Turbo
4
+ // from "@hotwired/turbo"`) rather than being that namespace directly --
5
+ // `import * as Turbo from "@hotwired/turbo-rails"` looks right but actually
6
+ // gives back `{ Turbo: {...}, cable: {...} }`, one level too shallow.
7
+ import { Turbo } from "@hotwired/turbo-rails"
8
+
9
+ // Connects to data-controller="atomic-view--gantt"
10
+ //
11
+ // Handles the grid's horizontal (date) pagination:
12
+ //
13
+ // - forward (scrolling right, more days ahead): an IntersectionObserver
14
+ // watches a trailing sentinel, rooted at this grid's own `scroller`
15
+ // target rather than the page viewport (a `loading="lazy"` frame only
16
+ // ever watches the page viewport, which is why the row axis --
17
+ // `GanttComponent#row_pagination_id` -- can use one but this can't).
18
+ // - backward (earlier days): a plain button the user clicks --
19
+ // `dateStartTrigger` -- not auto-loaded on scroll. An
20
+ // IntersectionObserver-driven backward sentinel sitting right at the
21
+ // scroll container's start is already within its own rootMargin the
22
+ // instant it's observed, before the user has done anything, and
23
+ // "wait for a gesture first" guards around that both have real gaps
24
+ // (a plain `scroll` listener never fires if the very first gesture
25
+ // *is* trying to scroll left from `scrollLeft: 0`, since the browser
26
+ // doesn't dispatch one when the position can't change; and gating the
27
+ // observer's own callback doesn't work either, since
28
+ // IntersectionObserver only calls back on a *change* in intersection
29
+ // state, so the one notification for an already-in-view target fires
30
+ // and gets dropped while gated, then never re-fires once ungated,
31
+ // since nothing about its geometry changed in between). A real click
32
+ // sidesteps all of that -- see `loadEarlierDates` below.
33
+ //
34
+ // Both directions share the same fetch/prepend-or-append/rebase mechanics;
35
+ // see `GanttComponent`'s class docs for what each direction's stream
36
+ // response is expected to contain.
37
+ //
38
+ // The forward sentinel and the backward trigger both carry their page URL
39
+ // as a `data-*` attribute on themselves (rather than a Stimulus Value on
40
+ // this controller's root) so a `turbo_stream.replace` of just that element
41
+ // is enough to advance the cursor. `dateSentinelTargetConnected` re-observes
42
+ // automatically whenever Turbo swaps the forward sentinel out for its
43
+ // replacement -- no manual re-wiring needed after each page loads; the
44
+ // backward trigger needs no such wiring at all, since its `data-action`
45
+ // attribute is enough for Stimulus to bind the click handler to whatever
46
+ // element currently has it, including a freshly-swapped-in replacement.
47
+ export default class extends Controller {
48
+ static targets = ["scroller", "dates", "dateSentinel", "dateLoader", "dateStartTrigger", "dateStartLoader", "originAnchored"]
49
+ static values = {
50
+ dateRootMargin: { type: String, default: "0px 400px 0px 0px" },
51
+ }
52
+
53
+ // Target-connected callbacks (below) can fire for targets already present
54
+ // in the initial DOM *before* `connect()` runs -- Stimulus wires up
55
+ // existing targets as part of connecting the controller itself, and that
56
+ // includes invoking their connected callbacks, ahead of calling
57
+ // `connect()`. So the observer `dateSentinelTargetConnected` reaches for
58
+ // has to exist by `initialize()` (guaranteed to run first, exactly once),
59
+ // not `connect()` -- creating it there worked by coincidence whenever a
60
+ // sentinel was added later via Turbo Stream, and threw on the very first
61
+ // (already-in-the-DOM) sentinel otherwise.
62
+ initialize() {
63
+ this.dateForwardLoading = false
64
+ this.dateBackwardLoading = false
65
+ // Running total of pixel width ever prepended to the date header so far
66
+ // -- see `rebaseOriginAnchored` below.
67
+ this.prependedWidth = 0
68
+
69
+ this.dateForwardObserver = new IntersectionObserver(this.handleDateForwardIntersect, {
70
+ root: this.hasScrollerTarget ? this.scrollerTarget : null,
71
+ rootMargin: this.dateRootMarginValue,
72
+ threshold: 0,
73
+ })
74
+ }
75
+
76
+ disconnect() {
77
+ this.dateForwardObserver.disconnect()
78
+ }
79
+
80
+ dateSentinelTargetConnected(element) {
81
+ this.dateForwardObserver.observe(element)
82
+ }
83
+
84
+ dateSentinelTargetDisconnected(element) {
85
+ this.dateForwardObserver.unobserve(element)
86
+ }
87
+
88
+ handleDateForwardIntersect = (entries) => {
89
+ for (const entry of entries) {
90
+ if (entry.isIntersecting) this.loadMoreDates(entry.target)
91
+ }
92
+ }
93
+
94
+ async loadMoreDates(sentinel) {
95
+ const url = sentinel.dataset.nextPage
96
+ if (!url || this.dateForwardLoading) return
97
+
98
+ this.dateForwardLoading = true
99
+ this.toggleLoader(this.hasDateLoaderTarget ? this.dateLoaderTarget : null, true)
100
+
101
+ try {
102
+ await this.renderStream(url, "date columns")
103
+ } finally {
104
+ this.dateForwardLoading = false
105
+ this.toggleLoader(this.hasDateLoaderTarget ? this.dateLoaderTarget : null, false)
106
+ }
107
+ }
108
+
109
+ // Bound via `data-action="click->atomic-view--gantt#loadEarlierDates"` on
110
+ // the `dateStartTrigger` button -- see this class's docs for why backward
111
+ // pagination is a click rather than a scroll-triggered sentinel.
112
+ async loadEarlierDates(event) {
113
+ const trigger = event.currentTarget
114
+ const url = trigger.dataset.prevPage
115
+ if (!url || this.dateBackwardLoading) return
116
+
117
+ this.dateBackwardLoading = true
118
+ trigger.disabled = true
119
+ this.toggleLoader(this.hasDateStartLoaderTarget ? this.dateStartLoaderTarget : null, true)
120
+
121
+ const widthBefore = this.hasDatesTarget ? this.datesTarget.scrollWidth : 0
122
+ const anchoredBeforeRender = new Set(this.originAnchoredTargets)
123
+
124
+ try {
125
+ await this.renderStream(url, "earlier date columns")
126
+
127
+ // The prepended cells pushed everything else to the right by however
128
+ // much width they added -- without this, the user's viewport would
129
+ // stay anchored to the same *scroll offset*, which now points at
130
+ // different (newly-loaded) content instead of what they were just
131
+ // looking at.
132
+ if (this.hasDatesTarget && this.hasScrollerTarget) {
133
+ const addedWidth = this.datesTarget.scrollWidth - widthBefore
134
+ if (addedWidth > 0) {
135
+ this.scrollerTarget.scrollLeft += addedWidth
136
+ this.rebaseOriginAnchored(anchoredBeforeRender, addedWidth)
137
+ }
138
+ }
139
+ } finally {
140
+ this.dateBackwardLoading = false
141
+ // `trigger` may have just been replaced by the stream response (a new
142
+ // page's worth of `data-prev-page`) -- re-read the current element
143
+ // via the target rather than re-enabling the stale reference.
144
+ if (this.hasDateStartTriggerTarget) this.dateStartTriggerTarget.disabled = false
145
+ this.toggleLoader(this.hasDateStartLoaderTarget ? this.dateStartLoaderTarget : null, false)
146
+ }
147
+ }
148
+
149
+ // Prepending date-header cells shifts the header's own flow-based
150
+ // coordinate system right by `addedWidth` -- see `GanttComponent`'s class
151
+ // docs, "How the grid is laid out" -- without moving anything positioned
152
+ // in pixels from a fixed `origin:` instead (item bars, the today strip;
153
+ // anything carrying `data-atomic-view--gantt-target="originAnchored"`).
154
+ // Elements already on the page before this response need nudging right by
155
+ // that same `addedWidth` to stay under the header cell they belong to.
156
+ // Ones this response just backfilled (new bars for the newly-loaded date
157
+ // range) were positioned by the server against the *original* origin with
158
+ // no knowledge of drift accumulated by earlier backward loads on this
159
+ // page, so they need the full running total instead.
160
+ //
161
+ // NOTE: this only rebases bars/the today strip backfilled by *this* date
162
+ // axis. A row loaded afterward via `next_rows_path`, or a bar backfilled
163
+ // by a *forward* `next_dates_path` response, is rendered fresh from the
164
+ // same unshifted origin math and would need this same treatment to stay
165
+ // aligned once `prependedWidth` is nonzero -- not wired up, since neither
166
+ // path currently has a hook to apply it from.
167
+ rebaseOriginAnchored(anchoredBeforeRender, addedWidth) {
168
+ for (const el of this.originAnchoredTargets) {
169
+ const delta = anchoredBeforeRender.has(el) ? addedWidth : this.prependedWidth + addedWidth
170
+ const left = Number.parseFloat(el.style.left) || 0
171
+ el.style.left = `${left + delta}px`
172
+ }
173
+ this.prependedWidth += addedWidth
174
+ }
175
+
176
+ async renderStream(url, description) {
177
+ this.element.setAttribute("aria-busy", "true")
178
+
179
+ try {
180
+ const response = await fetch(url, {
181
+ headers: { Accept: "text/vnd.turbo-stream.html" },
182
+ })
183
+
184
+ if (response.ok) {
185
+ Turbo.renderStreamMessage(await response.text())
186
+ } else {
187
+ console.error(`atomic-view--gantt: failed to load more ${description} from ${url} (${response.status})`)
188
+ }
189
+ } catch (error) {
190
+ console.error(`atomic-view--gantt: network error loading more ${description}`, error)
191
+ } finally {
192
+ this.element.removeAttribute("aria-busy")
193
+ }
194
+ }
195
+
196
+ toggleLoader(loader, visible) {
197
+ if (loader) loader.hidden = !visible
198
+ }
199
+ }
@@ -8,6 +8,17 @@ module AtomicView
8
8
  end
9
9
 
10
10
  def call
11
+ # `ViewComponent::Form::DatetimeSelectComponent#call` builds
12
+ # `ActionView::Helpers::Tags::DatetimeSelect` straight from
13
+ # `object_name`/`method_name` without `options[:object]`, so it
14
+ # resolves the bound object by looking up an `@<object_name>` ivar
15
+ # on the view context instead of using `form.object` -- outside a
16
+ # real `form_for`-rendered view that ivar doesn't exist, so Rails'
17
+ # `DateTimeSelector` silently falls back to `Time.current` instead
18
+ # of the model's actual value. Set it explicitly so the rendered
19
+ # selection always reflects the object, not the clock.
20
+ options[:object] = object
21
+
11
22
  content_tag(:div, super, class: "flex gap-2")
12
23
  end
13
24
  end
@@ -1,6 +1,13 @@
1
1
  <%= tag.div(**html_options, class: html_class, data: data_attributes) do %>
2
2
  <%= tag.div(class: trigger_class, data: trigger_data_attributes) do %>
3
- <%= trigger %>
3
+ <% if trigger? %>
4
+ <%= trigger %>
5
+ <% elsif default_trigger? %>
6
+ <%= tag.button(type: "button", class: default_trigger_class, aria: {haspopup: "menu"}) do %>
7
+ <%= label %>
8
+ <%= icon("chevron-down", variant: :mini, options: {class: "size-4 shrink-0 text-muted-foreground"}) %>
9
+ <% end %>
10
+ <% end %>
4
11
  <% end %>
5
12
 
6
13
  <% if menu? %>
@@ -24,8 +24,11 @@ module AtomicView
24
24
  renders_one :trigger
25
25
  renders_one :menu
26
26
 
27
- def initialize(**options)
27
+ attr_reader :label
28
+
29
+ def initialize(label: nil, **options)
28
30
  super()
31
+ @label = label
29
32
  @options = options
30
33
  end
31
34
 
@@ -33,6 +36,23 @@ module AtomicView
33
36
  @options.except(:class, :data, :trigger_class)
34
37
  end
35
38
 
39
+ # True when no `trigger` slot was given but `label:` was -- the
40
+ # template falls back to rendering a button with `default_trigger_class`,
41
+ # styled to match `FieldChrome` (the same ring-based border/focus
42
+ # treatment as text fields and selects), instead of every consumer
43
+ # hand-rolling a `border border-border` button that looks subtly
44
+ # different from the rest of the form chrome.
45
+ def default_trigger?
46
+ !trigger? && label.present?
47
+ end
48
+
49
+ def default_trigger_class
50
+ "inline-flex h-8 items-center gap-1.5 rounded-btn border-0 px-3 text-sm font-medium shadow-xs ring-1 " \
51
+ "bg-transparent dark:bg-white/5 text-foreground ring-ring/10 dark:ring-white/10 hover:bg-offset " \
52
+ "focus:ring-focus-ring focus:border-ring/20 dark:focus:ring-focus-ring " \
53
+ "disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-foreground disabled:ring-disabled-ring"
54
+ end
55
+
36
56
  def html_class
37
57
  class_names("relative inline-block", @options[:class])
38
58
  end
@@ -0,0 +1,4 @@
1
+ <%= tag.div(**html_options, class: html_class) do %>
2
+ <span><%= primary_label %></span>
3
+ <span class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80"><%= secondary_label %></span>
4
+ <% end %>
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtomicView
4
+ module Components
5
+ class GanttComponent
6
+ # A single day column header cell within a `GanttComponent`'s
7
+ # `#{id}_dates` grid. Carries no width of its own -- the parent grid's
8
+ # `grid-auto-columns` (see `GanttComponent#column_grid_style`) sizes
9
+ # every cell uniformly, so appending/prepending one is nothing more
10
+ # than inserting a plain DOM node. Standalone (like `ItemComponent`,
11
+ # unlike `RowComponent`/its items) for the same reason `ItemComponent`
12
+ # is -- a `next_dates_path`/`prev_dates_path` Turbo Stream response has
13
+ # to `append`/`prepend` *new* header cells into `#{id}_dates` alongside
14
+ # the new bars it backfills into existing rows (see `GanttComponent`'s
15
+ # class docs, "Loading more days"), and it needs something it can
16
+ # render for just those new dates without re-rendering the whole grid.
17
+ #
18
+ # render(AtomicView::Components::GanttComponent::DateHeaderComponent.new(date: new_date, today: Date.current))
19
+ class DateHeaderComponent < AtomicView::Component
20
+ attr_reader :date, :today
21
+
22
+ # @param date [Date] the day this column represents.
23
+ # @param today [Date, nil] highlights this cell when given and equal
24
+ # to `date`.
25
+ def initialize(date:, today: nil, **options)
26
+ super()
27
+ @date = date
28
+ @today = today
29
+ @options = options
30
+ end
31
+
32
+ def html_options
33
+ @options.except(:class)
34
+ end
35
+
36
+ def html_class
37
+ class_names(base_classes, {"text-primary" => today?}, @options[:class])
38
+ end
39
+
40
+ def today?
41
+ today.present? && date.to_date == today.to_date
42
+ end
43
+
44
+ def primary_label
45
+ date.day.to_s
46
+ end
47
+
48
+ def secondary_label
49
+ date.strftime("%a")
50
+ end
51
+
52
+ private
53
+
54
+ def base_classes
55
+ "sticky top-0 z-10 flex flex-col items-center justify-center gap-0.5 border-b border-border bg-surface py-2 text-xs font-semibold text-muted-foreground"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ <%= content_tag(tag_name, **html_options, id: id, href: href, style: position_style, class: html_class, data: data_attributes) do %>
2
+ <% if content.present? %>
3
+ <%= content %>
4
+ <% else %>
5
+ <span class="sticky truncate" style="<%= label_style %>"><%= label %></span>
6
+ <% end %>
7
+ <% end %>
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtomicView
4
+ module Components
5
+ class GanttComponent
6
+ # A single bar within a `GanttComponent::RowComponent`'s track,
7
+ # positioned by real date math rather than a column index -- see
8
+ # `GanttComponent`'s class docs for why. Rendered via
9
+ # `RowComponent#with_item`, or standalone (e.g. from a
10
+ # `next_dates_path` Turbo Stream response `append`ing a newly-loaded
11
+ # bar into an existing row's `RowComponent#track_id`) since it carries
12
+ # everything it needs -- including `origin:` -- to position itself with
13
+ # no help from its parent row.
14
+ #
15
+ # row.with_item(starts_on: booking.starts_on, ends_on: booking.ends_on, origin: @origin, label: booking.camper_name, variant: :success)
16
+ #
17
+ # row.with_item(starts_on: booking.starts_on, ends_on: booking.ends_on, origin: @origin, variant: :outline) do
18
+ # tag.span("Inquiry", class: "italic")
19
+ # end
20
+ #
21
+ # `label:` renders as truncated text; pass a block instead for
22
+ # anything richer (an avatar, a badge) -- the block replaces `label:`
23
+ # entirely rather than appending to it.
24
+ #
25
+ # == Updating one bar in place
26
+ #
27
+ # Give it `id:` (e.g. `dom_id(booking, :bar)`) and it becomes a stable
28
+ # Turbo Stream target -- when a booking's status/dates/label change,
29
+ # `turbo_stream.replace(dom_id(booking, :bar), render(...))` swaps just
30
+ # that bar, no different from updating any other DOM-id'd partial. This
31
+ # is a plain, independent update -- no relation to the row/date
32
+ # pagination sentinels elsewhere in this component.
33
+ #
34
+ # == Overlapping bars in the same row
35
+ #
36
+ # Two items with overlapping `starts_on`..`ends_on` ranges need to
37
+ # render in separate horizontal "lanes" so they don't paint on top of
38
+ # each other -- `lane:` (a 0-based integer, default `0`) picks which
39
+ # one. Compute lane assignments for a row's items with
40
+ # `GanttComponent.pack_lanes`, then pass `lanes:` (the count) to the
41
+ # parent `RowComponent#with_row` so its track reserves enough height:
42
+ #
43
+ # bookings = [overlapping_booking_a, overlapping_booking_b, later_booking]
44
+ # lanes = AtomicView::Components::GanttComponent.pack_lanes(bookings.map { |b| [b.starts_on, b.ends_on] })
45
+ #
46
+ # gantt.with_row(id: dom_id(site, :row), label: site.code, origin: @origin, lanes: lanes.max + 1) do |row|
47
+ # bookings.zip(lanes).each { |booking, lane| row.with_item(starts_on: booking.starts_on, ends_on: booking.ends_on, origin: @origin, lane: lane, label: booking.camper_name) }
48
+ # end
49
+ #
50
+ # Like `origin:`, a newly-appended bar (from a `next_dates_path`
51
+ # response) needs its `lane:` computed the same way, against every
52
+ # other item already in that row -- and if that pushes the row's lane
53
+ # count higher than what was initially rendered, the response should
54
+ # also `turbo_stream.replace` (or otherwise resize) the row's track to
55
+ # fit, since `RowComponent` only sizes itself once, from the `lanes:`
56
+ # it was given at render time.
57
+ class ItemComponent < AtomicView::Component
58
+ VARIANTS = %i[primary success warning destructive muted outline].freeze
59
+
60
+ attr_reader :id, :starts_on, :ends_on, :origin, :cell_width, :label_width, :lane, :label, :href, :variant
61
+
62
+ # @param id [String, nil] DOM id for this bar. Omit if you never need
63
+ # to target it directly; give it one (e.g. `dom_id(booking, :bar)`)
64
+ # to make it a stable Turbo Stream target for later in-place
65
+ # updates -- see "Updating one bar in place" above.
66
+ # @param starts_on [Date] first day this bar covers.
67
+ # @param ends_on [Date] last day this bar covers (inclusive). Pass
68
+ # the same value as `starts_on` for a single-day bar. There's no
69
+ # "open-ended" concept here -- resolve an ongoing booking to a real
70
+ # end date yourself (e.g. the last date currently loaded) before
71
+ # calling this.
72
+ # @param origin [Date] must match the `origin:` every other row/item
73
+ # in this grid uses -- the date that renders at pixel 0.
74
+ # @param cell_width [Integer] must match the parent `GanttComponent`'s.
75
+ # @param label_width [Integer] must match the parent `GanttComponent`'s
76
+ # -- used only to keep the label clear of the sticky label column
77
+ # when this bar's own true start is scrolled out of view (see
78
+ # `label_style`).
79
+ # @param lane [Integer] which horizontal lane (0-based) this bar
80
+ # renders in, for when it overlaps another item in the same row --
81
+ # see "Overlapping bars in the same row" above. `0` (the default)
82
+ # is fine for a row with no overlaps.
83
+ # @param label [String, nil] truncated text label; omit in favor of a
84
+ # block for richer content.
85
+ # @param href [String, nil] wraps the bar in a link when given --
86
+ # the whole bar becomes clickable, with a hover/focus treatment to
87
+ # match.
88
+ # @param variant [Symbol] one of #{VARIANTS.join(", ")} -- maps onto
89
+ # the gem's existing success/warning/destructive/muted tokens
90
+ # rather than domain-specific statuses, so map your own status
91
+ # values onto these however fits.
92
+ def initialize(starts_on:, ends_on:, origin:, id: nil, cell_width: GanttComponent::DEFAULT_CELL_WIDTH, label_width: GanttComponent::DEFAULT_LABEL_WIDTH, lane: 0, label: nil, href: nil, variant: :primary, **options)
93
+ super()
94
+ @id = id
95
+ @starts_on = starts_on
96
+ @ends_on = ends_on
97
+ @origin = origin
98
+ @cell_width = cell_width
99
+ @label_width = label_width
100
+ @lane = lane
101
+ @label = label
102
+ @href = href
103
+ @variant = variant.to_sym
104
+ @options = options
105
+ end
106
+
107
+ def html_options
108
+ @options.except(:class, :data)
109
+ end
110
+
111
+ # Marks this bar as a Turbo-target for the `atomic-view--gantt`
112
+ # Stimulus controller's backward-pagination rebasing -- see
113
+ # `GanttComponent`'s class docs, "How the grid is laid out": a bar's
114
+ # `left` is fixed in pixels against `origin:`, so unlike the flow-
115
+ # positioned date header cells above it, it needs nudging whenever a
116
+ # `prev_dates_path` response prepends earlier day columns ahead of
117
+ # it.
118
+ def data_attributes
119
+ (@options[:data] || {}).merge("atomic-view--gantt-target" => "originAnchored")
120
+ end
121
+
122
+ def position_style
123
+ left = GanttComponent.offset_px(starts_on, origin: origin, cell_width: cell_width)
124
+ width = GanttComponent.span_px(starts_on, ends_on, cell_width: cell_width)
125
+ top = GanttComponent::LANE_TOP_PADDING + (lane * GanttComponent::LANE_HEIGHT)
126
+ "left: #{left}px; width: #{width}px; top: #{top}px; height: #{GanttComponent::BAR_HEIGHT}px"
127
+ end
128
+
129
+ # `sticky`, so a bar starting well before the currently-loaded window
130
+ # -- rendered with a large negative `left` in `position_style` above,
131
+ # per "New rows outside the currently-loaded date window" in
132
+ # `GanttComponent`'s class docs -- keeps its label visible near
133
+ # whichever edge of the bar is currently on screen, instead of the
134
+ # label sitting at the bar's own (unreachably off-screen) true start.
135
+ # Offset past `label_width` so a stuck label never renders underneath
136
+ # the sticky row-label column.
137
+ #
138
+ # Only reachable in the plain `label:` path (see the template) --
139
+ # `position: sticky` computes relative to the *nearest* ancestor with
140
+ # non-visible `overflow`, so it only works here because that path
141
+ # skips `overflow_class` below; custom block content still gets
142
+ # wrapped in an `overflow-hidden` bar (needed to clip it to
143
+ # `rounded-btn`'s corners), which would silently break stickiness --
144
+ # its nearest non-visible-overflow ancestor would become the bar
145
+ # itself instead of the real scrolling container.
146
+ def label_style
147
+ "left: #{label_width + 8}px"
148
+ end
149
+
150
+ def html_class
151
+ class_names(base_classes, overflow_class, href.present? ? interactive_classes : nil, variant_classes, @options[:class])
152
+ end
153
+
154
+ def tag_name
155
+ href.present? ? :a : :div
156
+ end
157
+
158
+ private
159
+
160
+ # See `label_style` above for why this only applies when there's
161
+ # custom block content to clip -- the plain-label path needs to stay
162
+ # free of it for the sticky label to work.
163
+ def overflow_class
164
+ "overflow-hidden" if content.present?
165
+ end
166
+
167
+ def base_classes
168
+ "absolute z-[1] flex items-center rounded-btn px-2.5 text-xs font-semibold whitespace-nowrap"
169
+ end
170
+
171
+ def interactive_classes
172
+ "cursor-pointer transition-[filter] hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
173
+ end
174
+
175
+ def variant_classes
176
+ case variant
177
+ when :success
178
+ "bg-success text-success-foreground"
179
+ when :warning
180
+ "bg-warning text-warning-foreground"
181
+ when :destructive
182
+ "bg-destructive text-destructive-foreground line-through opacity-80"
183
+ when :muted
184
+ "bg-muted text-muted-foreground opacity-70"
185
+ when :outline
186
+ "border-2 border-dashed border-muted-foreground/40 bg-transparent text-muted-foreground"
187
+ else
188
+ "bg-primary text-primary-foreground"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,3 @@
1
+ <%= tag.div(**html_options, id: id, style: style, class: html_class) do %>
2
+ <%= label %>
3
+ <% end %>
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtomicView
4
+ module Components
5
+ class GanttComponent
6
+ # One "September 2026"-style segment in the `GanttComponent`'s month
7
+ # band strip (see `GanttComponent#month_band_id`), above the
8
+ # day/weekday header row. `#{gantt_id}_month_band` is a single-row CSS
9
+ # Grid sharing the same `grid-auto-columns` as `#{gantt_id}_dates` (see
10
+ # `GanttComponent#column_grid_style`), so a segment carries no pixel
11
+ # width of its own -- just `grid-column: span #{span}`, `span` day-wide
12
+ # tracks stretched together automatically. That also means a segment
13
+ # never needs resizing once rendered: appending/prepending more days
14
+ # elsewhere in the same grid doesn't touch its track sizing, so it's
15
+ # exactly as correct after ten more `prepend`s as it was on the first
16
+ # render.
17
+ #
18
+ # `GanttComponent` renders one of these per distinct month present in
19
+ # `dates:` on the initial render (see `GanttComponent#month_segments`).
20
+ #
21
+ # == Loading more days
22
+ #
23
+ # A `next_dates_path`/`prev_dates_path` Turbo Stream response that
24
+ # `append`s/`prepend`s new `DateHeaderComponent` cells to
25
+ # `#{gantt_id}_dates` (see `GanttComponent`'s class docs) should do the
26
+ # same here, to `#{gantt_id}_month_band`. Simplest when each batch of
27
+ # newly-loaded dates is itself exactly one calendar month (see
28
+ # `RentalsController` in this gem's dummy app for a worked example) --
29
+ # then every response adds exactly one new segment, always with a real
30
+ # label, no chunking needed. A batch that can straddle a month
31
+ # boundary needs to chunk its own dates by month the same way
32
+ # `GanttComponent#month_segments` does, and skip the label (`label:
33
+ # nil`) on whichever chunk continues the month a previous segment
34
+ # already labeled.
35
+ #
36
+ # Order matters the same way it does for date cells: `append` new
37
+ # segments in chronological order; `prepend` them in *reverse*
38
+ # chronological order (latest chunk first), since each prepend pushes
39
+ # the previous one further right.
40
+ class MonthBandComponent < AtomicView::Component
41
+ attr_reader :id, :label, :span
42
+
43
+ # @param id [String, nil] DOM id for this segment.
44
+ # @param label [String, nil] the text to show, e.g. `"September 2026"`
45
+ # -- `nil` renders a blank (unlabeled) segment, for a batch of
46
+ # newly-loaded dates that continue a month whose label an earlier
47
+ # segment already shows (see "Loading more days" above).
48
+ # @param span [Integer] how many day columns this segment covers --
49
+ # becomes `grid-column: span #{span}`.
50
+ def initialize(span:, label: nil, id: nil, **options)
51
+ super()
52
+ @id = id
53
+ @label = label
54
+ @span = span
55
+ @options = options
56
+ end
57
+
58
+ def html_options
59
+ @options.except(:class)
60
+ end
61
+
62
+ def html_class
63
+ class_names(base_classes, @options[:class])
64
+ end
65
+
66
+ def style
67
+ "grid-column: span #{span}"
68
+ end
69
+
70
+ private
71
+
72
+ def base_classes
73
+ "flex items-center overflow-hidden whitespace-nowrap px-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,22 @@
1
+ <%= tag.div(id: id, **html_options, class: html_class) do %>
2
+ <div class="sticky left-0 z-10 flex-none border-r border-b border-border bg-surface px-3 py-2" style="width: <%= label_width %>px">
3
+ <% if href.present? %>
4
+ <%= link_to label, href, class: "block truncate text-sm font-semibold text-foreground hover:underline" %>
5
+ <% else %>
6
+ <div class="truncate text-sm font-semibold text-foreground"><%= label %></div>
7
+ <% end %>
8
+ <% if sublabel.present? %>
9
+ <div class="truncate text-xs text-muted-foreground"><%= sublabel %></div>
10
+ <% end %>
11
+ </div>
12
+
13
+ <div id="<%= track_id %>" class="relative flex-1 border-b border-border" style="<%= track_style %>">
14
+ <% if today_offset_px %>
15
+ <div class="absolute inset-y-0 bg-primary/5" data-atomic-view--gantt-target="originAnchored" style="left: <%= today_offset_px %>px; width: <%= cell_width %>px"></div>
16
+ <% end %>
17
+
18
+ <% items.each do |item| %>
19
+ <%= item %>
20
+ <% end %>
21
+ </div>
22
+ <% end %>