atomic_view 0.1.17 → 0.2.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.
@@ -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 %>
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtomicView
4
+ module Components
5
+ class GanttComponent
6
+ # A single resource row within a `GanttComponent` -- a label (e.g. a
7
+ # site code) plus a horizontally-scrolling track that its `ItemComponent`
8
+ # bars position themselves within. Rendered via `GanttComponent#with_row`
9
+ # -- see that class's docs for the full picture, including what
10
+ # `origin:` means and why it has to stay consistent across requests.
11
+ #
12
+ # `track_id` is the id a `next_dates_path`/`prev_dates_path` Turbo
13
+ # Stream response `append`s/`prepend`s new bars to once more date
14
+ # columns load -- this row's own markup doesn't otherwise get
15
+ # re-rendered when that happens.
16
+ class RowComponent < AtomicView::Component
17
+ renders_many :items, "AtomicView::Components::GanttComponent::ItemComponent"
18
+
19
+ attr_reader :id, :label, :sublabel, :href, :origin, :cell_width, :label_width, :lanes, :today
20
+
21
+ # @param id [String] unique DOM id for this row.
22
+ # @param label [String] primary label (e.g. a site code).
23
+ # @param sublabel [String, nil] secondary label under it (e.g. a park name).
24
+ # @param href [String, nil] wraps the label in a link when given.
25
+ # @param origin [Date] must match the `origin:` given to every
26
+ # `ItemComponent` in this row (and every other row in this grid) --
27
+ # used here only for the optional `today:` highlight strip.
28
+ # @param cell_width [Integer] must match the parent `GanttComponent`'s.
29
+ # @param label_width [Integer] must match the parent `GanttComponent`'s.
30
+ # @param lanes [Integer] how many horizontal lanes of overlapping
31
+ # items this row's track should reserve height for -- `1` (the
32
+ # default) fits a row with no overlaps. See
33
+ # `ItemComponent#lane`/`GanttComponent.pack_lanes`.
34
+ # @param today [Date, nil] renders a highlighted strip at this date's
35
+ # column, when given (typically threaded through from the parent
36
+ # `GanttComponent#today`).
37
+ def initialize(
38
+ id:,
39
+ label:,
40
+ origin:,
41
+ sublabel: nil,
42
+ href: nil,
43
+ cell_width: GanttComponent::DEFAULT_CELL_WIDTH,
44
+ label_width: GanttComponent::DEFAULT_LABEL_WIDTH,
45
+ lanes: 1,
46
+ today: nil,
47
+ **options
48
+ )
49
+ super()
50
+ @id = id
51
+ @label = label
52
+ @sublabel = sublabel
53
+ @href = href
54
+ @origin = origin
55
+ @cell_width = cell_width
56
+ @label_width = label_width
57
+ @lanes = lanes
58
+ @today = today
59
+ @options = options
60
+ end
61
+
62
+ def track_id = "#{id}_track"
63
+
64
+ def html_options
65
+ @options.except(:class)
66
+ end
67
+
68
+ def html_class
69
+ class_names("flex", @options[:class])
70
+ end
71
+
72
+ def track_style
73
+ min_height = GanttComponent::LANE_TOP_PADDING * 2 + (lanes * GanttComponent::LANE_HEIGHT)
74
+ "background-image: repeating-linear-gradient(to right, var(--color-border) 0, var(--color-border) 1px, transparent 1px, transparent #{cell_width}px); min-height: #{min_height}px"
75
+ end
76
+
77
+ def today_offset_px
78
+ return nil unless today
79
+ GanttComponent.offset_px(today, origin: origin, cell_width: cell_width)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,76 @@
1
+ <%= tag.div(id: id, **html_options, class: html_class, data: data_attributes) do %>
2
+ <% if paginated_dates_backward? %>
3
+ <div class="flex items-center border-b border-border px-3 py-1.5">
4
+ <button
5
+ type="button"
6
+ id="<%= date_start_trigger_id %>"
7
+ class="flex items-center gap-1.5 rounded-btn px-2 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
8
+ data-atomic-view--gantt-target="dateStartTrigger"
9
+ data-action="click->atomic-view--gantt#loadEarlierDates"
10
+ data-prev-page="<%= prev_dates_path %>"
11
+ >
12
+ <span id="<%= date_start_loader_id %>" class="size-3.5 flex-none animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" data-atomic-view--gantt-target="dateStartLoader" hidden aria-hidden="true"></span>
13
+ Load earlier days
14
+ </button>
15
+ </div>
16
+ <% end %>
17
+
18
+ <div id="<%= scroller_id %>" class="overflow-x-auto" data-atomic-view--gantt-target="scroller">
19
+ <div class="w-max min-w-full">
20
+ <div class="flex">
21
+ <div class="sticky left-0 top-0 z-30 flex-none border-b border-r border-border bg-surface" style="width: <%= label_width %>px"></div>
22
+
23
+ <div class="flex flex-col">
24
+ <div id="<%= month_band_id %>" class="grid h-5 flex-none grid-flow-col border-b border-border/60 bg-surface" style="<%= column_grid_style %>">
25
+ <% month_segments.each do |segment| %>
26
+ <%= render(AtomicView::Components::GanttComponent::MonthBandComponent.new(
27
+ id: "#{month_band_id}_#{segment[:month_start].strftime("%Y-%m")}",
28
+ label: segment[:month_start].strftime("%B %Y"),
29
+ span: segment[:count]
30
+ )) %>
31
+ <% end %>
32
+ </div>
33
+
34
+ <div id="<%= dates_id %>" class="grid grid-flow-col" style="<%= column_grid_style %>" data-atomic-view--gantt-target="dates">
35
+ <% dates.each do |date| %>
36
+ <%= render(AtomicView::Components::GanttComponent::DateHeaderComponent.new(date: date, today: today)) %>
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+
41
+ <div id="<%= date_loader_id %>" class="flex flex-none items-center justify-center px-3" data-atomic-view--gantt-target="dateLoader" hidden>
42
+ <span class="size-3.5 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" aria-hidden="true"></span>
43
+ <span class="sr-only">Loading more days…</span>
44
+ </div>
45
+
46
+ <% if paginated_dates_forward? %>
47
+ <div
48
+ id="<%= date_sentinel_id %>"
49
+ class="w-px flex-none self-stretch"
50
+ data-atomic-view--gantt-target="dateSentinel"
51
+ data-next-page="<%= next_dates_path %>"
52
+ ></div>
53
+ <% end %>
54
+ </div>
55
+
56
+ <div id="<%= rows_id %>" class="flex flex-col">
57
+ <% rows.each do |row| %>
58
+ <%= row %>
59
+ <% end %>
60
+ </div>
61
+
62
+ <% if empty? %>
63
+ <p class="px-4 py-6 text-center text-sm text-muted-foreground"><%= empty_message %></p>
64
+ <% end %>
65
+ </div>
66
+ </div>
67
+
68
+ <% if paginated_rows? %>
69
+ <%= tag.turbo_frame(id: row_pagination_id, src: next_rows_path, loading: :lazy, class: "flex items-center justify-center gap-2 border-t border-border py-3") do %>
70
+ <span class="size-3.5 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" aria-hidden="true"></span>
71
+ <span class="sr-only">Loading more rows…</span>
72
+ <% end %>
73
+ <% else %>
74
+ <%= tag.turbo_frame(id: row_pagination_id) %>
75
+ <% end %>
76
+ <% end %>