atomic_view 0.2.2 → 0.4.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/app/assets/javascripts/atomic_view/controllers/tooltip_controller.js +35 -0
- data/lib/atomic_view/components/badge_component.rb +25 -5
- data/lib/atomic_view/components/card_component/section_component.rb +23 -0
- data/lib/atomic_view/components/card_component.html.erb +11 -0
- data/lib/atomic_view/components/card_component.rb +66 -5
- data/lib/atomic_view/components/concerns/button_variants.rb +1 -1
- data/lib/atomic_view/components/index_page_component.html.erb +35 -0
- data/lib/atomic_view/components/index_page_component.rb +68 -0
- data/lib/atomic_view/components/lazy_pagination_component.html.erb +8 -0
- data/lib/atomic_view/components/lazy_pagination_component.rb +43 -0
- data/lib/atomic_view/components/navigation_tree_component/item_component.html.erb +4 -4
- data/lib/atomic_view/components/navigation_tree_component/item_component.rb +2 -1
- data/lib/atomic_view/components/navigation_tree_component.html.erb +11 -3
- data/lib/atomic_view/components/navigation_tree_component.rb +32 -3
- data/lib/atomic_view/components/page_header_component.html.erb +17 -0
- data/lib/atomic_view/components/page_header_component.rb +53 -0
- data/lib/atomic_view/components/show_page_component.html.erb +28 -0
- data/lib/atomic_view/components/show_page_component.rb +72 -0
- data/lib/atomic_view/components/table_component/cell_component.html.erb +6 -0
- data/lib/atomic_view/components/table_component/cell_component.rb +58 -0
- data/lib/atomic_view/components/table_component/column_component.rb +54 -0
- data/lib/atomic_view/components/table_component/row_component.html.erb +3 -0
- data/lib/atomic_view/components/table_component/row_component.rb +55 -0
- data/lib/atomic_view/components/table_component.rb +51 -6
- data/lib/atomic_view/components/tooltip_component.html.erb +13 -0
- data/lib/atomic_view/components/tooltip_component.rb +97 -0
- data/lib/atomic_view/version.rb +1 -1
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75aad701c9a9b4598ba4a086f8a3c1c37faaac0bbf23f4ef2927b4d667364f1b
|
|
4
|
+
data.tar.gz: 1a63769c7c94188b829105777227267be43df6480e6b2f864a5c7be4e831a179
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba9732b3bf6ed19951e2c29ac2feafa7af299679135645bf28650808fd9ff6a6c9e9c3d994be9d23319b69a4679e4e7a8d67a73ae46603a963544a11430e0579
|
|
7
|
+
data.tar.gz: 10aff2eb5c66efb09918961c87cc708941cd30721c769cd3c2402082d09898098b32389cdfffe52519bbd3bedfa34bb8f2f265694cb0ed5226148abcfcbcd058
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { computePosition, offset, flip, shift, autoUpdate } from "@floating-ui/dom"
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["trigger", "content"]
|
|
6
|
+
static values = { placement: { type: String, default: "top" } }
|
|
7
|
+
|
|
8
|
+
show() {
|
|
9
|
+
this.contentTarget.classList.remove("hidden")
|
|
10
|
+
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
|
|
11
|
+
computePosition(this.triggerTarget, this.contentTarget, {
|
|
12
|
+
strategy: "fixed",
|
|
13
|
+
placement: this.placementValue,
|
|
14
|
+
middleware: [offset(8), flip(), shift({ padding: 8 })]
|
|
15
|
+
}).then(({ x, y }) => {
|
|
16
|
+
Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px` })
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
document.addEventListener("keydown", this.onKeydown)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
hide() {
|
|
23
|
+
this.contentTarget.classList.add("hidden")
|
|
24
|
+
if (this.cleanup) this.cleanup()
|
|
25
|
+
document.removeEventListener("keydown", this.onKeydown)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onKeydown = (event) => {
|
|
29
|
+
if (event.key === "Escape") this.hide()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
disconnect() {
|
|
33
|
+
this.hide()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module AtomicView
|
|
4
4
|
module Components
|
|
5
|
+
# Badge
|
|
6
|
+
#
|
|
7
|
+
# A small rectangular status/label pill. `default`/`secondary` are solid
|
|
8
|
+
# fills for general-purpose emphasis (a count, a category tag);
|
|
9
|
+
# `success`/`warning`/`destructive`/`info` are soft, bordered/tinted
|
|
10
|
+
# semantic colors for actual state (active/upcoming/cancelled/etc.) --
|
|
11
|
+
# the same `border-x bg-x/10 text-x` treatment AlertComponent already
|
|
12
|
+
# uses for its variants, just as a compact pill instead of a banner.
|
|
13
|
+
# `outline` is the neutral, colorless version of that same bordered
|
|
14
|
+
# style, for a status that doesn't map to any of the semantic colors.
|
|
15
|
+
#
|
|
16
|
+
# Every variant carries a `border` (transparent on the solid fills) so
|
|
17
|
+
# badges of different variants sitting side by side stay the same
|
|
18
|
+
# height regardless of which ones happen to have a visible border.
|
|
5
19
|
class BadgeComponent < AtomicView::Component
|
|
6
20
|
attr_reader :variant
|
|
7
21
|
|
|
@@ -18,19 +32,25 @@ module AtomicView
|
|
|
18
32
|
private
|
|
19
33
|
|
|
20
34
|
def base_classes
|
|
21
|
-
"inline-flex items-center rounded-
|
|
35
|
+
"inline-flex items-center rounded-btn border px-1.5 py-0.5 text-xs font-medium"
|
|
22
36
|
end
|
|
23
37
|
|
|
24
38
|
def variant_classes
|
|
25
39
|
case variant
|
|
26
40
|
when :secondary
|
|
27
|
-
"bg-secondary text-secondary-foreground"
|
|
41
|
+
"border-transparent bg-secondary text-secondary-foreground"
|
|
28
42
|
when :destructive
|
|
29
|
-
"bg-destructive text-destructive
|
|
43
|
+
"border-destructive bg-destructive/10 text-destructive"
|
|
44
|
+
when :success
|
|
45
|
+
"border-success bg-success/10 text-success"
|
|
46
|
+
when :warning
|
|
47
|
+
"border-warning bg-warning/10 text-warning"
|
|
48
|
+
when :info
|
|
49
|
+
"border-info bg-info/10 text-info"
|
|
30
50
|
when :outline
|
|
31
|
-
"
|
|
51
|
+
"border-border bg-transparent text-foreground"
|
|
32
52
|
else
|
|
33
|
-
"bg-primary text-primary-foreground"
|
|
53
|
+
"border-transparent bg-primary text-primary-foreground"
|
|
34
54
|
end
|
|
35
55
|
end
|
|
36
56
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class CardComponent
|
|
6
|
+
# CardComponent::Section
|
|
7
|
+
#
|
|
8
|
+
# One row of a sectioned card -- see CardComponent's docs. Just a
|
|
9
|
+
# padded, free-form content block; CardComponent's `divide-y` wrapper
|
|
10
|
+
# is what adds the border between sections, not this component.
|
|
11
|
+
class SectionComponent < AtomicView::Component
|
|
12
|
+
def initialize(**options)
|
|
13
|
+
super()
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
tag.div(**@options.except(:class), class: class_names(CardComponent::PADDING_CLASSES, @options[:class])) { content }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%= tag.div(**@options.except(:class), class: html_class) do %>
|
|
2
|
+
<% if sectioned? %>
|
|
3
|
+
<%= tag.div(class: divider_class) do %>
|
|
4
|
+
<% sections.each do |section| %>
|
|
5
|
+
<%= section %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= content %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% end %>
|
|
@@ -2,23 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
module AtomicView
|
|
4
4
|
module Components
|
|
5
|
+
# Card
|
|
6
|
+
#
|
|
7
|
+
# A generic surface container for grouping related content. `default` is
|
|
8
|
+
# a plain bordered panel; `destructive` is the same shape with a tinted
|
|
9
|
+
# red border/background, for a "danger zone" section (e.g. an archive or
|
|
10
|
+
# delete action with its consequences explained) -- the same
|
|
11
|
+
# `border-destructive bg-destructive/10` treatment AlertComponent already
|
|
12
|
+
# uses for its own error variant. The variant only colors the card
|
|
13
|
+
# itself; any heading/button inside it is the caller's own content.
|
|
14
|
+
#
|
|
15
|
+
# Pass plain content for a single padded panel. For a settings-list style
|
|
16
|
+
# card -- several rows, each with its own label/action, a divider
|
|
17
|
+
# between them but not around the outside -- use `with_section` instead:
|
|
18
|
+
#
|
|
19
|
+
# render(AtomicView::Components::CardComponent.new(variant: :destructive)) do |card|
|
|
20
|
+
# card.with_section do
|
|
21
|
+
# tag.div(class: "flex items-center justify-between gap-4") do
|
|
22
|
+
# tag.div { tag.p("Archive this rental", class: "font-medium") + tag.p("...", class: "text-sm text-muted-foreground") } +
|
|
23
|
+
# render(LinkComponent.new("#", variant: :muted)) { "Archive" }
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# card.with_section { ... }
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# Each section gets the card's own padding (rather than the card as a
|
|
31
|
+
# whole), and a `divide-y` on the section container adds a border
|
|
32
|
+
# between consecutive sections automatically -- there's no first/last
|
|
33
|
+
# index to track, Tailwind's `divide-y` only ever borders the boundary
|
|
34
|
+
# between two siblings. A card with no sections given renders exactly as
|
|
35
|
+
# before -- `with_section` is opt-in, not a second required API.
|
|
5
36
|
class CardComponent < AtomicView::Component
|
|
6
|
-
|
|
37
|
+
PADDING_CLASSES = "p-2.5 px-4"
|
|
7
38
|
|
|
8
|
-
|
|
39
|
+
renders_many :sections, "SectionComponent"
|
|
40
|
+
|
|
41
|
+
attr_reader :hoverable, :variant
|
|
42
|
+
|
|
43
|
+
def initialize(variant: :default, hoverable: false, **options)
|
|
9
44
|
super()
|
|
45
|
+
@variant = variant
|
|
10
46
|
@hoverable = hoverable
|
|
11
47
|
@options = options
|
|
12
48
|
end
|
|
13
49
|
|
|
14
|
-
def
|
|
15
|
-
|
|
50
|
+
def sectioned?
|
|
51
|
+
sections.any?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def html_class
|
|
55
|
+
class_names(base_classes, variant_classes, hoverable_classes, padding_class, @options[:class])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The divider color between sections is tied to the variant rather
|
|
59
|
+
# than a flat `divide-border` -- a neutral gray divider reads fine on
|
|
60
|
+
# the plain surface background, but loses almost all contrast against
|
|
61
|
+
# a tinted `bg-destructive/10` panel in light mode.
|
|
62
|
+
def divider_class
|
|
63
|
+
class_names("divide-y", (variant == :destructive) ? "divide-destructive/30" : "divide-border")
|
|
16
64
|
end
|
|
17
65
|
|
|
18
66
|
private
|
|
19
67
|
|
|
20
68
|
def base_classes
|
|
21
|
-
"bg-surface rounded-card border
|
|
69
|
+
"bg-surface rounded-card border"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def padding_class
|
|
73
|
+
PADDING_CLASSES unless sectioned?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def variant_classes
|
|
77
|
+
case variant
|
|
78
|
+
when :destructive
|
|
79
|
+
"border-destructive bg-destructive/10"
|
|
80
|
+
else
|
|
81
|
+
"border-border"
|
|
82
|
+
end
|
|
22
83
|
end
|
|
23
84
|
|
|
24
85
|
def hoverable_classes
|
|
@@ -7,7 +7,7 @@ module AtomicView
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def base_classes
|
|
10
|
-
"h-
|
|
10
|
+
"h-9 rounded-btn px-2.5 inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def variant_classes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<% breadcrumbs_content = breadcrumbs.to_s %>
|
|
2
|
+
<% title_content = title.to_s %>
|
|
3
|
+
<% badge_content = badge.to_s %>
|
|
4
|
+
<% subtitle_content = subtitle.to_s %>
|
|
5
|
+
<% actions_content = actions.to_s %>
|
|
6
|
+
|
|
7
|
+
<%= tag.div(**@options.except(:class, :id), id: html_id, class: html_class) do %>
|
|
8
|
+
<%= tag.div(class: "mb-4") do %>
|
|
9
|
+
<%= render(AtomicView::Components::PageHeaderComponent.new) do |header| %>
|
|
10
|
+
<% header.with_breadcrumbs { breadcrumbs_content } if breadcrumbs? %>
|
|
11
|
+
<% header.with_title { title_content } if title? %>
|
|
12
|
+
<% header.with_badge { badge_content } if badge? %>
|
|
13
|
+
<% header.with_subtitle { subtitle_content } if subtitle? %>
|
|
14
|
+
<% header.with_actions { actions_content } if actions? %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<% if filters? %>
|
|
19
|
+
<%= tag.div(filters, class: "mb-4") %>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if toolbar? %>
|
|
23
|
+
<%= tag.div(toolbar, class: "mb-4") %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<% if empty? %>
|
|
27
|
+
<%= empty_state if empty_state? %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<%= content %>
|
|
30
|
+
|
|
31
|
+
<% if pagination? %>
|
|
32
|
+
<%= tag.div(pagination, class: "mt-4") %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# IndexPage
|
|
6
|
+
#
|
|
7
|
+
# The layout shell for a collection page: a PageHeaderComponent up top,
|
|
8
|
+
# an optional `filters` row (e.g. status pills), an optional `toolbar`
|
|
9
|
+
# row below that (search, dropdowns), then either the default content
|
|
10
|
+
# block or an `empty_state` slot, and finally an optional `pagination`
|
|
11
|
+
# slot. This is layout only -- the content region has no idea what's
|
|
12
|
+
# inside it. It doesn't reference TableComponent, doesn't assume rows or
|
|
13
|
+
# columns, and doesn't care whether the caller renders a table, a card
|
|
14
|
+
# grid, or anything else. A smarter, column-aware table is an app-level
|
|
15
|
+
# concern to build on top of this, not something this component owns.
|
|
16
|
+
#
|
|
17
|
+
# render(AtomicView::Components::IndexPageComponent.new(empty: @rentals.empty?, dom_id: :rentals)) do |page|
|
|
18
|
+
# page.with_title { "Rentals" }
|
|
19
|
+
# page.with_subtitle { "#{@rental_count} rentals across #{@park_count} parks" }
|
|
20
|
+
# page.with_actions { render(LinkComponent.new(new_rental_path)) { "New rental" } }
|
|
21
|
+
# page.with_filters { render(SegmentedControlComponent.new(options: STATUS_OPTIONS, selected: params[:status] || "All")) }
|
|
22
|
+
# page.with_toolbar { render(SearchFieldComponent.new(...)) }
|
|
23
|
+
# page.with_pagination { render(PaginationComponent.new(current_page: @pagy.page, total_pages: @pagy.pages, path_for_page: ->(p) { rentals_path(page: p) })) }
|
|
24
|
+
# page.with_empty_state { render(EmptyStateComponent.new(title: "No rentals yet")) }
|
|
25
|
+
#
|
|
26
|
+
# render(AtomicView::Components::TableComponent.new) { ... }
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# `breadcrumbs`/`title`/`badge`/`subtitle`/`actions` are forwarded
|
|
30
|
+
# straight into an internally-rendered PageHeaderComponent -- see its
|
|
31
|
+
# docs for what each renders. `empty:` and `dom_id:` stay plain
|
|
32
|
+
# caller-supplied values rather than being derived from a model/Pagy
|
|
33
|
+
# object, so this component never needs to know about ActiveRecord or a
|
|
34
|
+
# pagination library -- an app-level wrapper (e.g. a `ListViewComponent`
|
|
35
|
+
# that already knows about `Pagy`/routes) is the right place to compute
|
|
36
|
+
# them before calling this one.
|
|
37
|
+
class IndexPageComponent < AtomicView::Component
|
|
38
|
+
renders_one :breadcrumbs
|
|
39
|
+
renders_one :title
|
|
40
|
+
renders_one :badge
|
|
41
|
+
renders_one :subtitle
|
|
42
|
+
renders_one :actions
|
|
43
|
+
renders_one :filters
|
|
44
|
+
renders_one :toolbar
|
|
45
|
+
renders_one :empty_state
|
|
46
|
+
renders_one :pagination
|
|
47
|
+
|
|
48
|
+
attr_reader :dom_id
|
|
49
|
+
|
|
50
|
+
def initialize(empty: false, dom_id: nil, **options)
|
|
51
|
+
super()
|
|
52
|
+
@empty = empty
|
|
53
|
+
@dom_id = dom_id
|
|
54
|
+
@options = options
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def empty? = @empty
|
|
58
|
+
|
|
59
|
+
def html_class
|
|
60
|
+
class_names(@options[:class])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def html_id
|
|
64
|
+
@options[:id] || dom_id
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<% if next_page? %>
|
|
2
|
+
<%= tag.turbo_frame(id: id, loading: :lazy, src: next_page_path, class: "flex items-center justify-center gap-2 py-3") do %>
|
|
3
|
+
<span class="size-3.5 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" aria-hidden="true"></span>
|
|
4
|
+
<span class="sr-only">Loading more…</span>
|
|
5
|
+
<% end %>
|
|
6
|
+
<% else %>
|
|
7
|
+
<%= tag.turbo_frame(id: id) %>
|
|
8
|
+
<% end %>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# LazyPagination
|
|
6
|
+
#
|
|
7
|
+
# The infinite-scroll counterpart to PaginationComponent's page-number
|
|
8
|
+
# links -- use PaginationComponent for numbered pages, this one for a
|
|
9
|
+
# list that loads its next page automatically as the user scrolls.
|
|
10
|
+
# Stays equally pagination-gem agnostic: the caller resolves both
|
|
11
|
+
# `next_page` (any presence-checkable value, e.g. a Pagy `next` page
|
|
12
|
+
# number) and `next_page_path` (the URL that lazily loaded page comes
|
|
13
|
+
# from) themselves.
|
|
14
|
+
#
|
|
15
|
+
# render(AtomicView::Components::LazyPaginationComponent.new(
|
|
16
|
+
# next_page: @pagy.next,
|
|
17
|
+
# next_page_path: @pagy.next && bookings_path(page: @pagy.next)
|
|
18
|
+
# ))
|
|
19
|
+
#
|
|
20
|
+
# Renders a `<turbo-frame loading="lazy" src="...">` (the same
|
|
21
|
+
# `tag.turbo_frame` technique GanttComponent's row/date pagination
|
|
22
|
+
# uses -- see that class's docs -- rather than a hard dependency on
|
|
23
|
+
# turbo-rails' view helpers) wrapping a spinner while `next_page` is
|
|
24
|
+
# present. Once there's no next page, it renders an empty
|
|
25
|
+
# `<turbo-frame>` with the same `id` instead of nothing at all, so a
|
|
26
|
+
# later `turbo_stream.replace(id, ...)` response from the last real
|
|
27
|
+
# page's fetch always has a target to replace.
|
|
28
|
+
class LazyPaginationComponent < AtomicView::Component
|
|
29
|
+
attr_reader :next_page, :next_page_path, :id
|
|
30
|
+
|
|
31
|
+
def initialize(next_page:, next_page_path: nil, id: :pagination)
|
|
32
|
+
super()
|
|
33
|
+
@next_page = next_page
|
|
34
|
+
@next_page_path = next_page_path
|
|
35
|
+
@id = id
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def next_page?
|
|
39
|
+
next_page.present?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
<% if icon_name.present? %>
|
|
5
5
|
<%= icon(icon_name, options: {class: "size-4 shrink-0"}).to_s.html_safe %>
|
|
6
6
|
<% end %>
|
|
7
|
-
|
|
8
|
-
<%= icon("chevron-right", options: {class: "ml-auto size-4 shrink-0 transition-transform"}).to_s.html_safe %>
|
|
7
|
+
<span class="truncate group-data-[collapsed]/nav:hidden"><%= label %></span>
|
|
8
|
+
<%= icon("chevron-right", options: {class: "ml-auto size-4 shrink-0 transition-transform group-data-[collapsed]/nav:hidden"}).to_s.html_safe %>
|
|
9
9
|
<% end %>
|
|
10
|
-
<div class="ml-6 mt-1 flex flex-col gap-1">
|
|
10
|
+
<div class="ml-6 mt-1 flex flex-col gap-1 group-data-[collapsed]/nav:hidden">
|
|
11
11
|
<% items.each do |item| %>
|
|
12
12
|
<%= item %>
|
|
13
13
|
<% end %>
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
<% if icon_name.present? %>
|
|
19
19
|
<%= icon(icon_name, options: {class: "size-4 shrink-0"}).to_s.html_safe %>
|
|
20
20
|
<% end %>
|
|
21
|
-
|
|
21
|
+
<span class="truncate group-data-[collapsed]/nav:hidden"><%= label %></span>
|
|
22
22
|
<% end %>
|
|
23
23
|
<% end %>
|
|
@@ -38,7 +38,8 @@ module AtomicView
|
|
|
38
38
|
private
|
|
39
39
|
|
|
40
40
|
def base_classes
|
|
41
|
-
"flex items-center gap-2 rounded-btn px-2.5 py-1.5 text-sm font-medium"
|
|
41
|
+
"flex items-center gap-2 rounded-btn px-2.5 py-1.5 text-sm font-medium " \
|
|
42
|
+
"group-data-[collapsed]/nav:justify-center group-data-[collapsed]/nav:px-2"
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def active_classes
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
<%= tag.nav(
|
|
2
|
-
<% if label.present? %>
|
|
1
|
+
<%= tag.nav(**html_options, class: container_class, data: data_attributes) do %>
|
|
2
|
+
<% if label.present? && !collapsed? %>
|
|
3
3
|
<div class="px-2.5 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"><%= label %></div>
|
|
4
4
|
<% end %>
|
|
5
5
|
<% items.each do |item| %>
|
|
6
|
-
|
|
6
|
+
<% if collapsed? %>
|
|
7
|
+
<%= render(AtomicView::Components::TooltipComponent.new(text: item.label, placement: tooltip_placement)) do |tooltip| %>
|
|
8
|
+
<% tooltip.with_trigger do %>
|
|
9
|
+
<%= item %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% else %>
|
|
13
|
+
<%= item %>
|
|
14
|
+
<% end %>
|
|
7
15
|
<% end %>
|
|
8
16
|
<% end %>
|
|
@@ -21,19 +21,48 @@ module AtomicView
|
|
|
21
21
|
# as a disclosure (a `<summary>` row with a trailing chevron) whose own
|
|
22
22
|
# `with_item` children render nested inside it once open -- see
|
|
23
23
|
# `ItemComponent` for the per-item API.
|
|
24
|
+
#
|
|
25
|
+
# Pass `collapsed: true` for an icon-only rail -- labels, chevrons, and
|
|
26
|
+
# any open group's nested items are hidden with CSS (a `group/nav`
|
|
27
|
+
# marker plus `group-data-[collapsed]/nav:*` variants on each item, the
|
|
28
|
+
# same "no JS" approach as the disclosure groups above), not swapped out
|
|
29
|
+
# in Ruby. Each top-level item is automatically wrapped in a
|
|
30
|
+
# `TooltipComponent` showing its `label:` on hover, since the icon alone
|
|
31
|
+
# loses the item's name -- nested items stay unwrapped, since their
|
|
32
|
+
# container is hidden while collapsed (unreachable in a rail without a
|
|
33
|
+
# flyout, which is out of scope here):
|
|
34
|
+
#
|
|
35
|
+
# render(NavigationTreeComponent.new(collapsed: true)) do |tree|
|
|
36
|
+
# tree.with_item(label: "Overview", href: "#", icon: "home", active: true)
|
|
37
|
+
# end
|
|
24
38
|
class NavigationTreeComponent < AtomicView::Component
|
|
25
39
|
renders_many :items, "ItemComponent"
|
|
26
40
|
|
|
27
|
-
attr_reader :label
|
|
41
|
+
attr_reader :label, :tooltip_placement
|
|
28
42
|
|
|
29
|
-
def initialize(label: nil, **options)
|
|
43
|
+
def initialize(label: nil, collapsed: false, tooltip_placement: "right", **options)
|
|
30
44
|
super()
|
|
31
45
|
@label = label
|
|
46
|
+
@collapsed = collapsed
|
|
47
|
+
@tooltip_placement = tooltip_placement
|
|
32
48
|
@options = options
|
|
33
49
|
end
|
|
34
50
|
|
|
51
|
+
def collapsed?
|
|
52
|
+
@collapsed
|
|
53
|
+
end
|
|
54
|
+
|
|
35
55
|
def container_class
|
|
36
|
-
class_names("flex flex-col gap-1", @options[:class])
|
|
56
|
+
class_names("group/nav flex flex-col gap-1", collapsed? ? "items-center" : nil, @options[:class])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def html_options
|
|
60
|
+
@options.except(:class, :data)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def data_attributes
|
|
64
|
+
attributes = @options[:data] || {}
|
|
65
|
+
collapsed? ? attributes.merge(collapsed: true) : attributes
|
|
37
66
|
end
|
|
38
67
|
end
|
|
39
68
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%= tag.div(**@options.except(:class), class: html_class) do %>
|
|
2
|
+
<div>
|
|
3
|
+
<% if breadcrumbs? %>
|
|
4
|
+
<div class="mb-1 text-xs font-semibold text-primary"><%= breadcrumbs %></div>
|
|
5
|
+
<% end %>
|
|
6
|
+
<div class="flex items-center gap-2">
|
|
7
|
+
<h1 class="text-2xl font-bold text-foreground sm:text-xl"><%= title %></h1>
|
|
8
|
+
<% if badge? %><%= badge %><% end %>
|
|
9
|
+
</div>
|
|
10
|
+
<% if subtitle? %>
|
|
11
|
+
<div class="mt-1 text-sm text-muted-foreground"><%= subtitle %></div>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<% if actions? %>
|
|
15
|
+
<div class="flex shrink-0 items-center gap-2"><%= actions %></div>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# PageHeader
|
|
6
|
+
#
|
|
7
|
+
# The title/breadcrumb/badge/actions cluster shared by IndexPageComponent
|
|
8
|
+
# and ShowPageComponent (and usable standalone, e.g. above a lazily-loaded
|
|
9
|
+
# turbo-frame fragment that has no page shell of its own). Every region is
|
|
10
|
+
# a free-form slot -- this component only lays them out, it has no
|
|
11
|
+
# opinion about hrefs, badge variants, or what an action is.
|
|
12
|
+
#
|
|
13
|
+
# render(AtomicView::Components::PageHeaderComponent.new) do |header|
|
|
14
|
+
# header.with_breadcrumbs { link_to "Rentals", rentals_path, class: "text-xs font-semibold text-primary" }
|
|
15
|
+
# header.with_title { "RS002 · Karen Wilson" }
|
|
16
|
+
# header.with_badge { render(BadgeComponent.new) { "Active" } }
|
|
17
|
+
# header.with_subtitle { "rental_9f21ba7c" }
|
|
18
|
+
# header.with_actions { render(LinkComponent.new(edit_rental_path(@rental), variant: :outline)) { "Edit" } }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# `title` is the one slot every caller is expected to fill -- there's no
|
|
22
|
+
# runtime enforcement of that (this gem doesn't raise on missing slots
|
|
23
|
+
# elsewhere either, e.g. DropdownComponent's `menu`), just a documented
|
|
24
|
+
# expectation. `badge` renders inline right after the title (e.g. a
|
|
25
|
+
# status pill); `subtitle` renders on its own line below (a record count,
|
|
26
|
+
# a copyable reference id); `breadcrumbs` renders above the title as a
|
|
27
|
+
# small eyebrow link; `actions` renders as a button/link cluster on the
|
|
28
|
+
# opposite side of the row, wrapping above the title/breadcrumb column on
|
|
29
|
+
# narrow viewports.
|
|
30
|
+
class PageHeaderComponent < AtomicView::Component
|
|
31
|
+
renders_one :breadcrumbs
|
|
32
|
+
renders_one :title
|
|
33
|
+
renders_one :badge
|
|
34
|
+
renders_one :subtitle
|
|
35
|
+
renders_one :actions
|
|
36
|
+
|
|
37
|
+
def initialize(**options)
|
|
38
|
+
super()
|
|
39
|
+
@options = options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def html_class
|
|
43
|
+
class_names(base_classes, @options[:class])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def base_classes
|
|
49
|
+
"flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<% breadcrumbs_content = breadcrumbs.to_s %>
|
|
2
|
+
<% title_content = title.to_s %>
|
|
3
|
+
<% badge_content = badge.to_s %>
|
|
4
|
+
<% subtitle_content = subtitle.to_s %>
|
|
5
|
+
<% actions_content = actions.to_s %>
|
|
6
|
+
|
|
7
|
+
<%= tag.article(**@options.except(:class), class: html_class) do %>
|
|
8
|
+
<%= tag.div(class: "mb-8") do %>
|
|
9
|
+
<%= render(AtomicView::Components::PageHeaderComponent.new) do |header| %>
|
|
10
|
+
<% header.with_breadcrumbs { breadcrumbs_content } if breadcrumbs? %>
|
|
11
|
+
<% header.with_title { title_content } if title? %>
|
|
12
|
+
<% header.with_badge { badge_content } if badge? %>
|
|
13
|
+
<% header.with_subtitle { subtitle_content } if subtitle? %>
|
|
14
|
+
<% header.with_actions { actions_content } if actions? %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<% if highlight? %>
|
|
19
|
+
<%= tag.div(highlight, class: "mb-8") %>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<%= tag.div(class: body_class) do %>
|
|
23
|
+
<% if sidebar? %>
|
|
24
|
+
<%= tag.div(sidebar, class: sidebar_class) %>
|
|
25
|
+
<% end %>
|
|
26
|
+
<%= tag.div(content, class: main_class) %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# ShowPage
|
|
6
|
+
#
|
|
7
|
+
# The layout shell for a single-record detail page: a PageHeaderComponent
|
|
8
|
+
# up top, an optional full-width `highlight` region below it, then a
|
|
9
|
+
# two-column body -- the default content block as the main column, and an
|
|
10
|
+
# optional `sidebar` slot as a second column. This is layout only -- it
|
|
11
|
+
# has no opinion about what a record's detail page actually contains.
|
|
12
|
+
# `highlight` is deliberately not called "stepper" or "progress": it's
|
|
13
|
+
# just a full-width slot for whatever sits between the header and the
|
|
14
|
+
# body (a lifecycle stepper, a banner, nothing at all) -- naming it after
|
|
15
|
+
# a specific piece of content would smuggle a content assumption into a
|
|
16
|
+
# layout component. Same reasoning for `sidebar`: it's a column, not a
|
|
17
|
+
# "details panel" -- fill it with a CardComponent, a plain `<dl>`,
|
|
18
|
+
# whatever the record calls for.
|
|
19
|
+
#
|
|
20
|
+
# render(AtomicView::Components::ShowPageComponent.new) do |page|
|
|
21
|
+
# page.with_breadcrumbs { link_to "Rentals", rentals_path, class: "text-xs font-semibold text-primary" }
|
|
22
|
+
# page.with_title { "RS002 · Karen Wilson" }
|
|
23
|
+
# page.with_badge { render(BadgeComponent.new) { "Active" } }
|
|
24
|
+
# page.with_actions { render(LinkComponent.new(edit_rental_path(@rental), variant: :outline)) { "Edit" } }
|
|
25
|
+
# page.with_highlight { render(RentalStepperComponent.new(rental: @rental)) }
|
|
26
|
+
# page.with_sidebar { render "rentals/details_panel", rental: @rental }
|
|
27
|
+
#
|
|
28
|
+
# tag.section { tag.h2("Overview", class: "text-lg font-semibold mb-2") + render(CardComponent.new) { @rental.notes } }
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# `breadcrumbs`/`title`/`badge`/`subtitle`/`actions` are forwarded
|
|
32
|
+
# straight into an internally-rendered PageHeaderComponent -- see its
|
|
33
|
+
# docs for what each renders. The default content block is the main
|
|
34
|
+
# column; when `sidebar` is omitted, the main column spans full width
|
|
35
|
+
# rather than leaving an empty second column.
|
|
36
|
+
#
|
|
37
|
+
# The two-column grid (sidebar first in document order, reordered after
|
|
38
|
+
# the main column from `sm:` up) matches every existing hand-rolled show
|
|
39
|
+
# page in the consuming app -- that's the "good defaults" this component
|
|
40
|
+
# exists to stop re-typing, not a novel layout choice.
|
|
41
|
+
class ShowPageComponent < AtomicView::Component
|
|
42
|
+
renders_one :breadcrumbs
|
|
43
|
+
renders_one :title
|
|
44
|
+
renders_one :badge
|
|
45
|
+
renders_one :subtitle
|
|
46
|
+
renders_one :actions
|
|
47
|
+
renders_one :highlight
|
|
48
|
+
renders_one :sidebar
|
|
49
|
+
|
|
50
|
+
def initialize(**options)
|
|
51
|
+
super()
|
|
52
|
+
@options = options
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def html_class
|
|
56
|
+
class_names(@options[:class])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def body_class
|
|
60
|
+
class_names("grid grid-cols-1 gap-8", {"sm:grid-cols-3 sm:gap-12" => sidebar?})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sidebar_class
|
|
64
|
+
"flex flex-col gap-8 sm:order-2 sm:col-span-1"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def main_class
|
|
68
|
+
class_names("flex flex-col gap-4 sm:gap-8", {"sm:order-1 sm:col-span-2" => sidebar?})
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%= tag.td(**html_options, class: html_class) do %>
|
|
2
|
+
<% if linked? %>
|
|
3
|
+
<%= link_to "", path, class: "absolute inset-0", tabindex: (first ? 0 : -1), "aria-hidden": (first ? nil : "true"), "aria-label": (first ? label : nil), data: {row_link: true} %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<%= tag.span(content, class: "relative") %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TableComponent
|
|
6
|
+
# TableComponent::Cell
|
|
7
|
+
#
|
|
8
|
+
# One <td> within a RowComponent -- see that class's docs for the
|
|
9
|
+
# standalone-render requirement and the per-cell stretched-link
|
|
10
|
+
# design. `first` and `clickable` are set by RowComponent's `cells`
|
|
11
|
+
# slot from the row itself, not passed by the caller.
|
|
12
|
+
#
|
|
13
|
+
# Visible content renders as a normal sibling of the (empty,
|
|
14
|
+
# transparent) stretched `<a>`, wrapped in its own `position:
|
|
15
|
+
# relative` span so a real link nested inside a cell's content (e.g.
|
|
16
|
+
# a second, more specific action) still stays clickable above the
|
|
17
|
+
# row-wide overlay.
|
|
18
|
+
class CellComponent < AtomicView::Component
|
|
19
|
+
attr_reader :path, :label, :first, :align, :clickable
|
|
20
|
+
|
|
21
|
+
def initialize(path:, label:, first:, align: :left, clickable: true, **options)
|
|
22
|
+
super()
|
|
23
|
+
@path = path
|
|
24
|
+
@label = label
|
|
25
|
+
@first = first
|
|
26
|
+
@align = align
|
|
27
|
+
@clickable = clickable
|
|
28
|
+
@options = options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def linked?
|
|
32
|
+
clickable && path.present?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def html_class
|
|
36
|
+
class_names(base_classes, align_classes, @options[:class])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def html_options
|
|
40
|
+
@options.except(:class)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def base_classes
|
|
46
|
+
"relative px-2.5 py-2.5 border-b border-border/5"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def align_classes
|
|
50
|
+
case align
|
|
51
|
+
when :center then "text-center font-semibold"
|
|
52
|
+
when :right then "text-right"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TableComponent
|
|
6
|
+
# TableComponent::Column
|
|
7
|
+
#
|
|
8
|
+
# One <th> in the header row TableComponent auto-renders once at
|
|
9
|
+
# least one `with_column` is given -- see TableComponent's docs.
|
|
10
|
+
#
|
|
11
|
+
# `label` defaults to `model.human_attribute_name(attribute)`
|
|
12
|
+
# (mirroring Rails' own form label defaulting) when a `model:` is
|
|
13
|
+
# given -- TableComponent passes its own `model:` through to every
|
|
14
|
+
# column automatically, so callers only need `attribute:`. Pass
|
|
15
|
+
# `label:` directly instead for a column with no backing
|
|
16
|
+
# attribute (e.g. a blank header over a row of "Edit" links).
|
|
17
|
+
class ColumnComponent < AtomicView::Component
|
|
18
|
+
attr_reader :model, :attribute, :align, :width
|
|
19
|
+
|
|
20
|
+
def initialize(model: nil, attribute: nil, label: nil, align: :left, width: nil)
|
|
21
|
+
super()
|
|
22
|
+
raise ArgumentError, "label is required when model is not given" if model.nil? && label.nil?
|
|
23
|
+
|
|
24
|
+
@model = model
|
|
25
|
+
@attribute = attribute
|
|
26
|
+
@label = label
|
|
27
|
+
@align = align
|
|
28
|
+
@width = width
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def label
|
|
32
|
+
@label || model.human_attribute_name(attribute)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
tag.th(label, scope: "col", class: header_class)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def header_class
|
|
42
|
+
class_names("p-2 font-medium", align_class, width)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def align_class
|
|
46
|
+
case align
|
|
47
|
+
when :center then "text-center"
|
|
48
|
+
when :right then "text-right"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TableComponent
|
|
6
|
+
# TableComponent::Row
|
|
7
|
+
#
|
|
8
|
+
# A single <tr>, renderable standalone -- no parent TableComponent
|
|
9
|
+
# instance required. That matters because a turbo_stream
|
|
10
|
+
# `append`/`update` response renders exactly one row outside of any
|
|
11
|
+
# table context, e.g.:
|
|
12
|
+
#
|
|
13
|
+
# turbo_stream.append(:bookings_body, AtomicView::Components::TableComponent::RowComponent.new(
|
|
14
|
+
# record: @booking, label: @booking.camper_name, path: booking_path(@booking)
|
|
15
|
+
# ) { |row| row.with_cell { @booking.camper_name } })
|
|
16
|
+
#
|
|
17
|
+
# `path` defaults to `record` itself so `path_to(record)`-style route
|
|
18
|
+
# helpers resolve it via `to_model`/`to_param` the same way `link_to`
|
|
19
|
+
# would; pass `path:` explicitly when the row's destination isn't
|
|
20
|
+
# just `record`'s own show page.
|
|
21
|
+
#
|
|
22
|
+
# Each `with_cell` becomes its own stretched link to `path` -- a
|
|
23
|
+
# single `<a>` can't span multiple `<td>` siblings the way it could
|
|
24
|
+
# wrap a `<div>` row -- but only the first cell's link stays in the
|
|
25
|
+
# tab order (`tabindex="0"`; the rest are `tabindex="-1"` and
|
|
26
|
+
# `aria-hidden`), so a keyboard/screen-reader user hits one focus
|
|
27
|
+
# stop per row rather than one per cell. `label` is that first
|
|
28
|
+
# link's accessible name. Pass `clickable: false` for a row that
|
|
29
|
+
# shouldn't navigate anywhere -- cells then render as plain,
|
|
30
|
+
# unlinked content.
|
|
31
|
+
class RowComponent < AtomicView::Component
|
|
32
|
+
renders_many :cells, ->(**kwargs) { CellComponent.new(path: path, label: label, first: cells.empty?, clickable: clickable, **kwargs) }
|
|
33
|
+
|
|
34
|
+
attr_reader :record, :label, :path, :clickable
|
|
35
|
+
|
|
36
|
+
def initialize(record:, label:, path: nil, clickable: true, **options)
|
|
37
|
+
super()
|
|
38
|
+
@record = record
|
|
39
|
+
@label = label
|
|
40
|
+
@path = path || record
|
|
41
|
+
@clickable = clickable
|
|
42
|
+
@options = options
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def html_class
|
|
46
|
+
class_names(clickable ? "hover:bg-offset" : nil, @options[:class])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def html_options
|
|
50
|
+
@options.except(:class)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -4,10 +4,10 @@ module AtomicView
|
|
|
4
4
|
module Components
|
|
5
5
|
# Table
|
|
6
6
|
#
|
|
7
|
-
# A thin styling wrapper around <table>.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# A thin styling wrapper around <table>. By default this owns no
|
|
8
|
+
# row/column concept -- the consuming view builds its own
|
|
9
|
+
# <thead>/<tbody> markup and calls the yielded component's #head_class
|
|
10
|
+
# / #row_class helpers to pick up the shared styling:
|
|
11
11
|
#
|
|
12
12
|
# <%= render AtomicView::Components::TableComponent.new do |table| %>
|
|
13
13
|
# <thead>
|
|
@@ -21,17 +21,53 @@ module AtomicView
|
|
|
21
21
|
# </tr>
|
|
22
22
|
# </tbody>
|
|
23
23
|
# <% end %>
|
|
24
|
+
#
|
|
25
|
+
# `with_column` is an opt-in alternative to hand-building the <thead>:
|
|
26
|
+
# give it at least one column and TableComponent renders the header row
|
|
27
|
+
# itself, from `<th>`s built by TableComponent::ColumnComponent -- you
|
|
28
|
+
# still own `<tbody>` content (typically TableComponent::RowComponent
|
|
29
|
+
# rows) in the block, same as before:
|
|
30
|
+
#
|
|
31
|
+
# <%= render AtomicView::Components::TableComponent.new(model: Booking) do |table| %>
|
|
32
|
+
# <% table.with_column(attribute: :camper_name) %>
|
|
33
|
+
# <% table.with_column(attribute: :status, align: :center) %>
|
|
34
|
+
# <% table.with_column(label: "") %>
|
|
35
|
+
#
|
|
36
|
+
# <tbody>
|
|
37
|
+
# <% @bookings.each do |booking| %>
|
|
38
|
+
# <%= render(AtomicView::Components::TableComponent::RowComponent.new(record: booking, label: booking.camper_name, path: booking_path(booking))) do |row| %>
|
|
39
|
+
# <% row.with_cell { booking.camper_name } %>
|
|
40
|
+
# <% row.with_cell(align: :center) { render(BadgeComponent.new) { booking.status } } %>
|
|
41
|
+
# <% row.with_cell { render(LinkComponent.new(edit_booking_path(booking), variant: :outline)) { "Edit" } } %>
|
|
42
|
+
# <% end %>
|
|
43
|
+
# <% end %>
|
|
44
|
+
# </tbody>
|
|
45
|
+
# <% end %>
|
|
46
|
+
#
|
|
47
|
+
# A table with no columns given renders exactly as before -- `with_column`
|
|
48
|
+
# is opt-in, not a second required API. `model:` is passed through to
|
|
49
|
+
# each column so `with_column(attribute: :camper_name)` can default its
|
|
50
|
+
# label via `model.human_attribute_name(:camper_name)`; a column with no
|
|
51
|
+
# backing attribute (like the blank "" header above, for an actions
|
|
52
|
+
# column) just passes `label:` directly instead.
|
|
24
53
|
class TableComponent < AtomicView::Component
|
|
25
54
|
HEAD_ROW_CLASSES = "text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b border-border"
|
|
26
55
|
BODY_ROW_CLASSES = "border-b border-border hover:bg-offset"
|
|
27
56
|
|
|
28
|
-
|
|
57
|
+
renders_many :columns, ->(**kwargs) { ColumnComponent.new(model: model, **kwargs) }
|
|
58
|
+
|
|
59
|
+
attr_reader :model
|
|
60
|
+
|
|
61
|
+
def initialize(model: nil, **options)
|
|
29
62
|
super()
|
|
63
|
+
@model = model
|
|
30
64
|
@options = options
|
|
31
65
|
end
|
|
32
66
|
|
|
33
67
|
def call
|
|
34
|
-
tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class]))
|
|
68
|
+
tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class])) do
|
|
69
|
+
safe_join([columns_header, content].compact)
|
|
70
|
+
end
|
|
35
71
|
end
|
|
36
72
|
|
|
37
73
|
def head_class(**options)
|
|
@@ -44,6 +80,15 @@ module AtomicView
|
|
|
44
80
|
|
|
45
81
|
private
|
|
46
82
|
|
|
83
|
+
def columns_header
|
|
84
|
+
return unless columns?
|
|
85
|
+
tag.thead { tag.tr(class: head_class) { safe_join(columns) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def columns?
|
|
89
|
+
columns.any?
|
|
90
|
+
end
|
|
91
|
+
|
|
47
92
|
def base_classes
|
|
48
93
|
"w-full text-sm"
|
|
49
94
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%= tag.span(**html_options, class: html_class, data: data_attributes) do %>
|
|
2
|
+
<%= tag.span(class: "inline-block", data: trigger_data_attributes, aria: {describedby: content_id}) do %>
|
|
3
|
+
<%= trigger %>
|
|
4
|
+
<% end %>
|
|
5
|
+
|
|
6
|
+
<%= tag.span(id: content_id, role: "tooltip", class: content_class, data: content_data_attributes) do %>
|
|
7
|
+
<% if body? %>
|
|
8
|
+
<%= body %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<%= text %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Tooltip
|
|
6
|
+
#
|
|
7
|
+
# A trigger plus a floating label, positioned by `@floating-ui/dom` and
|
|
8
|
+
# wired to `atomic-view--tooltip` (see
|
|
9
|
+
# `app/assets/javascripts/atomic_view/controllers/tooltip_controller.js`).
|
|
10
|
+
# The controller shows the tooltip on hover/focus of the trigger and
|
|
11
|
+
# hides it on mouseleave/blur/Esc, keeping it anchored to the trigger
|
|
12
|
+
# while the page scrolls or resizes.
|
|
13
|
+
#
|
|
14
|
+
# The bubble uses `strategy: "fixed"` (and a `fixed` class, not
|
|
15
|
+
# `absolute`) rather than `DropdownComponent`'s approach -- an
|
|
16
|
+
# absolutely-positioned bubble's containing block is the trigger's own
|
|
17
|
+
# `relative` wrapper span, so its shrink-to-fit width silently caps out
|
|
18
|
+
# at the *trigger's* rendered width no matter how generous `max-w-*` is.
|
|
19
|
+
# `fixed` makes the viewport the containing block instead, so
|
|
20
|
+
# `content_class` (below) actually controls the bubble's width.
|
|
21
|
+
#
|
|
22
|
+
# Pass `text:` for the common case of a short plain-text label:
|
|
23
|
+
#
|
|
24
|
+
# render(TooltipComponent.new(text: "Delete")) do |tooltip|
|
|
25
|
+
# tooltip.with_trigger { icon_button }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# or a `body` slot for anything richer than plain text.
|
|
29
|
+
#
|
|
30
|
+
# The bubble defaults to `max-w-xs` (20rem) -- wide enough for a short
|
|
31
|
+
# label or two lines of `body` content. Pass `content_class:` to widen
|
|
32
|
+
# (or otherwise restyle) it for longer text:
|
|
33
|
+
#
|
|
34
|
+
# render(TooltipComponent.new(text: "...", content_class: "max-w-sm"))
|
|
35
|
+
#
|
|
36
|
+
# It's a separate option from `class:`, which targets the outer trigger
|
|
37
|
+
# wrapper -- `content_class:` is merged (via TailwindMerge) onto the
|
|
38
|
+
# floating bubble itself, so `max-w-sm` here replaces the default
|
|
39
|
+
# `max-w-xs` rather than fighting it.
|
|
40
|
+
class TooltipComponent < AtomicView::Component
|
|
41
|
+
renders_one :trigger
|
|
42
|
+
renders_one :body
|
|
43
|
+
|
|
44
|
+
PLACEMENTS = %w[top bottom left right].freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :text, :placement
|
|
47
|
+
|
|
48
|
+
def initialize(text: nil, placement: "top", content_class: nil, **options)
|
|
49
|
+
super()
|
|
50
|
+
@text = text
|
|
51
|
+
@placement = PLACEMENTS.include?(placement) ? placement : "top"
|
|
52
|
+
@id = "tooltip-#{SecureRandom.hex(4)}"
|
|
53
|
+
@content_class = content_class
|
|
54
|
+
@options = options
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def html_options
|
|
58
|
+
@options.except(:class, :data)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def html_class
|
|
62
|
+
class_names("relative inline-block", @options[:class])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def data_attributes
|
|
66
|
+
(@options[:data] || {}).merge(
|
|
67
|
+
:controller => "atomic-view--tooltip",
|
|
68
|
+
"atomic-view--tooltip-placement-value" => placement
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def trigger_data_attributes
|
|
73
|
+
{
|
|
74
|
+
"atomic-view--tooltip-target" => "trigger",
|
|
75
|
+
:action => "mouseenter->atomic-view--tooltip#show mouseleave->atomic-view--tooltip#hide " \
|
|
76
|
+
"focusin->atomic-view--tooltip#show focusout->atomic-view--tooltip#hide"
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def content_id
|
|
81
|
+
@id
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def content_data_attributes
|
|
85
|
+
{"atomic-view--tooltip-target" => "content"}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def content_class
|
|
89
|
+
class_names(
|
|
90
|
+
"pointer-events-none hidden fixed z-50 max-w-xs rounded-well bg-foreground px-2 py-1 text-xs " \
|
|
91
|
+
"font-medium text-surface shadow-soft",
|
|
92
|
+
@content_class
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/atomic_view/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atomic_view
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joel Warrington
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: zeitwerk
|
|
@@ -148,6 +148,7 @@ files:
|
|
|
148
148
|
- app/assets/javascripts/atomic_view/controllers/modal_controller.js
|
|
149
149
|
- app/assets/javascripts/atomic_view/controllers/theme_toggle_controller.js
|
|
150
150
|
- app/assets/javascripts/atomic_view/controllers/toast_controller.js
|
|
151
|
+
- app/assets/javascripts/atomic_view/controllers/tooltip_controller.js
|
|
151
152
|
- app/assets/stylesheets/atomic_view/application.css
|
|
152
153
|
- app/assets/stylesheets/atomic_view/application.tailwind.css
|
|
153
154
|
- app/assets/tailwind/atomic_view/engine.css
|
|
@@ -171,7 +172,9 @@ files:
|
|
|
171
172
|
- lib/atomic_view/components/avatar_component.rb
|
|
172
173
|
- lib/atomic_view/components/badge_component.rb
|
|
173
174
|
- lib/atomic_view/components/button_component.rb
|
|
175
|
+
- lib/atomic_view/components/card_component.html.erb
|
|
174
176
|
- lib/atomic_view/components/card_component.rb
|
|
177
|
+
- lib/atomic_view/components/card_component/section_component.rb
|
|
175
178
|
- lib/atomic_view/components/check_box_component.rb
|
|
176
179
|
- lib/atomic_view/components/chip_component.rb
|
|
177
180
|
- lib/atomic_view/components/collection_check_boxes_component.html.erb
|
|
@@ -212,8 +215,12 @@ files:
|
|
|
212
215
|
- lib/atomic_view/components/gantt_component/row_component.html.erb
|
|
213
216
|
- lib/atomic_view/components/gantt_component/row_component.rb
|
|
214
217
|
- lib/atomic_view/components/grouped_collection_select_component.rb
|
|
218
|
+
- lib/atomic_view/components/index_page_component.html.erb
|
|
219
|
+
- lib/atomic_view/components/index_page_component.rb
|
|
215
220
|
- lib/atomic_view/components/kbd_component.rb
|
|
216
221
|
- lib/atomic_view/components/label_component.rb
|
|
222
|
+
- lib/atomic_view/components/lazy_pagination_component.html.erb
|
|
223
|
+
- lib/atomic_view/components/lazy_pagination_component.rb
|
|
217
224
|
- lib/atomic_view/components/link_component.html.erb
|
|
218
225
|
- lib/atomic_view/components/link_component.rb
|
|
219
226
|
- lib/atomic_view/components/modal_component.html.erb
|
|
@@ -224,6 +231,8 @@ files:
|
|
|
224
231
|
- lib/atomic_view/components/navigation_tree_component/item_component.html.erb
|
|
225
232
|
- lib/atomic_view/components/navigation_tree_component/item_component.rb
|
|
226
233
|
- lib/atomic_view/components/number_field_component.rb
|
|
234
|
+
- lib/atomic_view/components/page_header_component.html.erb
|
|
235
|
+
- lib/atomic_view/components/page_header_component.rb
|
|
227
236
|
- lib/atomic_view/components/pagination_component.html.erb
|
|
228
237
|
- lib/atomic_view/components/pagination_component.rb
|
|
229
238
|
- lib/atomic_view/components/password_field_component.rb
|
|
@@ -234,8 +243,15 @@ files:
|
|
|
234
243
|
- lib/atomic_view/components/segmented_control_component.rb
|
|
235
244
|
- lib/atomic_view/components/select_component.html.erb
|
|
236
245
|
- lib/atomic_view/components/select_component.rb
|
|
246
|
+
- lib/atomic_view/components/show_page_component.html.erb
|
|
247
|
+
- lib/atomic_view/components/show_page_component.rb
|
|
237
248
|
- lib/atomic_view/components/submit_component.rb
|
|
238
249
|
- lib/atomic_view/components/table_component.rb
|
|
250
|
+
- lib/atomic_view/components/table_component/cell_component.html.erb
|
|
251
|
+
- lib/atomic_view/components/table_component/cell_component.rb
|
|
252
|
+
- lib/atomic_view/components/table_component/column_component.rb
|
|
253
|
+
- lib/atomic_view/components/table_component/row_component.html.erb
|
|
254
|
+
- lib/atomic_view/components/table_component/row_component.rb
|
|
239
255
|
- lib/atomic_view/components/tabs_component.html.erb
|
|
240
256
|
- lib/atomic_view/components/tabs_component.rb
|
|
241
257
|
- lib/atomic_view/components/telephone_field_component.rb
|
|
@@ -250,6 +266,8 @@ files:
|
|
|
250
266
|
- lib/atomic_view/components/timeline_component/item_component.rb
|
|
251
267
|
- lib/atomic_view/components/toast_component.html.erb
|
|
252
268
|
- lib/atomic_view/components/toast_component.rb
|
|
269
|
+
- lib/atomic_view/components/tooltip_component.html.erb
|
|
270
|
+
- lib/atomic_view/components/tooltip_component.rb
|
|
253
271
|
- lib/atomic_view/components/url_field_component.rb
|
|
254
272
|
- lib/atomic_view/components/week_field_component.rb
|
|
255
273
|
- lib/atomic_view/components/weekday_select_component.rb
|