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.
- checksums.yaml +4 -4
- data/Rakefile +2 -0
- data/app/assets/javascripts/atomic_view/controllers/gantt_controller.js +199 -0
- data/app/assets/javascripts/atomic_view/controllers/hotkey_controller.js +32 -0
- data/lib/atomic_view/components/datetime_select_component.rb +11 -0
- data/lib/atomic_view/components/dropdown_component.html.erb +8 -1
- data/lib/atomic_view/components/dropdown_component.rb +21 -1
- data/lib/atomic_view/components/gantt_component/date_header_component.html.erb +4 -0
- data/lib/atomic_view/components/gantt_component/date_header_component.rb +60 -0
- data/lib/atomic_view/components/gantt_component/item_component.html.erb +7 -0
- data/lib/atomic_view/components/gantt_component/item_component.rb +194 -0
- data/lib/atomic_view/components/gantt_component/month_band_component.html.erb +3 -0
- data/lib/atomic_view/components/gantt_component/month_band_component.rb +78 -0
- data/lib/atomic_view/components/gantt_component/row_component.html.erb +22 -0
- data/lib/atomic_view/components/gantt_component/row_component.rb +84 -0
- data/lib/atomic_view/components/gantt_component.html.erb +76 -0
- data/lib/atomic_view/components/gantt_component.rb +486 -0
- data/lib/atomic_view/components/link_component.rb +34 -6
- data/lib/atomic_view/version.rb +1 -1
- metadata +14 -2
|
@@ -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 %>
|