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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f8b65a708eb61c363c49739577ef43d71266873a2ec2f64aea3d5d1e376b626
|
|
4
|
+
data.tar.gz: 167cbbac4465b3bc599c0f86f13f2a9f19f7771ca5e545d7cda0372f298a5755
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e5ca41fad05a869f0307bd23f1fe8a8c180370accfdff2f3ca5ddc66c84dd77133d60e85d3f1acdc5f2d8b978a2f27aea9eb6755ff362c351e2c23002e3ebd0
|
|
7
|
+
data.tar.gz: 35197eb341f652950d8718dc154eed20900f222eb9e4de4187b2fc2ab33b370d28f24cd2990296162f9cad9dc45b36196aff6bfb91cd3145aea6b3c4ef679b54
|
data/Rakefile
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// A key (or chord) bound elsewhere via Stimulus's keyboard event filters,
|
|
4
|
+
// e.g. `data-action="keydown.n@window->hotkey#click"`, activates this
|
|
5
|
+
// element instead of every shortcut needing its own handler. Ignored while
|
|
6
|
+
// the user is typing (an input/textarea/Trix editor has focus) or when the
|
|
7
|
+
// element itself isn't interactable right now (hidden, disabled via
|
|
8
|
+
// pointer-events: none, ...).
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
click(event) {
|
|
11
|
+
if (this.isClickable && !this.shouldIgnore(event)) {
|
|
12
|
+
event.preventDefault()
|
|
13
|
+
this.element.click()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
focus(event) {
|
|
18
|
+
if (this.isClickable && !this.shouldIgnore(event)) {
|
|
19
|
+
event.preventDefault()
|
|
20
|
+
this.element.focus()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
shouldIgnore(event) {
|
|
25
|
+
const target = event.target
|
|
26
|
+
return event.defaultPrevented || !!target?.closest("input, textarea, trix-editor, [contenteditable]")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get isClickable() {
|
|
30
|
+
return getComputedStyle(this.element).pointerEvents !== "none"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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
|