atomic_view 0.2.1 → 0.3.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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/atomic_view/controllers/tooltip_controller.js +35 -0
  3. data/lib/atomic_view/components/badge_component.rb +25 -5
  4. data/lib/atomic_view/components/card_component/section_component.rb +23 -0
  5. data/lib/atomic_view/components/card_component.html.erb +11 -0
  6. data/lib/atomic_view/components/card_component.rb +66 -5
  7. data/lib/atomic_view/components/concerns/button_variants.rb +1 -1
  8. data/lib/atomic_view/components/index_page_component.html.erb +35 -0
  9. data/lib/atomic_view/components/index_page_component.rb +68 -0
  10. data/lib/atomic_view/components/navigation_tree_component/item_component.html.erb +4 -4
  11. data/lib/atomic_view/components/navigation_tree_component/item_component.rb +2 -1
  12. data/lib/atomic_view/components/navigation_tree_component.html.erb +11 -3
  13. data/lib/atomic_view/components/navigation_tree_component.rb +32 -3
  14. data/lib/atomic_view/components/page_header_component.html.erb +17 -0
  15. data/lib/atomic_view/components/page_header_component.rb +53 -0
  16. data/lib/atomic_view/components/show_page_component.html.erb +28 -0
  17. data/lib/atomic_view/components/show_page_component.rb +72 -0
  18. data/lib/atomic_view/components/timeline_component/item_component.rb +9 -2
  19. data/lib/atomic_view/components/timeline_component.html.erb +2 -2
  20. data/lib/atomic_view/components/timeline_component.rb +2 -2
  21. data/lib/atomic_view/components/tooltip_component.html.erb +13 -0
  22. data/lib/atomic_view/components/tooltip_component.rb +97 -0
  23. data/lib/atomic_view/version.rb +1 -1
  24. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2cfa124ea9d90829cb7c8879e99d63b4c0e5587f4ee294ca19557f9dc0b92699
4
- data.tar.gz: 972a88497711cd0d4c1f9e323346886b9eed136c350120ab0e6adfbb62764f25
3
+ metadata.gz: 35571b6bf96da5419dcc5d10709fb6e391bc3d47991b0914934099003d4e2873
4
+ data.tar.gz: '09145bc057a978d84c5d21c98fe3c86b5e3db12c52d3726480eb79025a9c0d93'
5
5
  SHA512:
6
- metadata.gz: 3696f874e0d5e627a572be114d091a18d451427f9e980e79b8b9304a3965b0d5a11060d44b0f7a7711364553199127778dc86ffc8d86d45851a65184250861fb
7
- data.tar.gz: db66b0814f6c2b03969d1d4f0b422f1494768d24699d308e522deefcd5a629e77bf4382263a573765188ead91c986e0618b52700c8ff897852852ae823578049
6
+ metadata.gz: 26248796be4512d71197dae22674290aeab4c8b1b13dbaa4bc220e4c779858369f11e82ed9c645da13f101e8c9da130c8529694543426ff6c84bff0a73677072
7
+ data.tar.gz: 7220d17e20fca18bff3896b2a31e65846b499a2a453e315af1625c9d181bb118f63659e276bfa0cc3e20766be564525d1d02dec1c12c50d8310d51037c1f8202
@@ -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-pill px-1.5 py-0.5 text-xs font-medium"
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-foreground"
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
- "bg-transparent border border-border text-foreground"
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
- attr_reader :hoverable
37
+ PADDING_CLASSES = "p-2.5 px-4"
7
38
 
8
- def initialize(hoverable: false, **options)
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 call
15
- tag.div(**@options.except(:class), class: class_names(base_classes, hoverable_classes, @options[:class])) { content }
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 border-border p-2.5"
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-7 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]"
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
@@ -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
- <%= label %>
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
- <%= label %>
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(**@options.except(:class), class: container_class) do %>
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
- <%= item %>
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
@@ -16,16 +16,23 @@ module AtomicView
16
16
  # (aging into a full date over time) client-side once the host app
17
17
  # loads local_time's JS -- see `config/importmap.rb`'s "local-time"
18
18
  # pin comment for that setup.
19
+ #
20
+ # Any other keyword argument (`id:`, `data:`, `class:`, ...) passes
21
+ # through to the row's outer wrapper `<div>` in
22
+ # `TimelineComponent`'s template, so a caller can address a whole
23
+ # row -- marker, meta line, and content -- for e.g. a Turbo Stream
24
+ # `remove`/`replace`, or wire up a Stimulus target on the row itself.
19
25
  class ItemComponent < AtomicView::Component
20
- attr_reader :icon_name, :avatar, :actor, :description, :time
26
+ attr_reader :icon_name, :avatar, :actor, :description, :time, :html_options
21
27
 
22
- def initialize(description:, icon: nil, avatar: nil, actor: nil, time: nil)
28
+ def initialize(description:, icon: nil, avatar: nil, actor: nil, time: nil, **html_options)
23
29
  super()
24
30
  @icon_name = icon
25
31
  @avatar = avatar
26
32
  @actor = actor
27
33
  @description = description
28
34
  @time = time
35
+ @html_options = html_options
29
36
  end
30
37
 
31
38
  def marker
@@ -1,10 +1,10 @@
1
1
  <%= tag.div(**@options.except(:class), class: container_class) do %>
2
2
  <% items.each_with_index do |item, index| %>
3
- <div class="<%= item_wrapper_class(index) %>">
3
+ <%= tag.div(**item.html_options.except(:class), class: item_wrapper_class(index, item)) do %>
4
4
  <% if render_line?(index) %>
5
5
  <div class="absolute left-[13px] top-7 -bottom-6 w-px bg-border"></div>
6
6
  <% end %>
7
7
  <%= item %>
8
- </div>
8
+ <% end %>
9
9
  <% end %>
10
10
  <% end %>
@@ -47,8 +47,8 @@ module AtomicView
47
47
  # `continues:` says this render is one page of a longer feed -- the connector
48
48
  # keeps going past the last item, into whatever the next turbo frame appends
49
49
  # or the stream prepends above.
50
- def item_wrapper_class(index)
51
- class_names("relative flex gap-3.5", "pb-6" => !last?(index) || @continues)
50
+ def item_wrapper_class(index, item)
51
+ class_names("relative flex gap-3.5", {"pb-6" => !last?(index) || @continues}, item.html_options[:class])
52
52
  end
53
53
 
54
54
  def render_line?(index)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicView
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.1
4
+ version: 0.3.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-12 00:00:00.000000000 Z
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,6 +215,8 @@ 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
217
222
  - lib/atomic_view/components/link_component.html.erb
@@ -224,6 +229,8 @@ files:
224
229
  - lib/atomic_view/components/navigation_tree_component/item_component.html.erb
225
230
  - lib/atomic_view/components/navigation_tree_component/item_component.rb
226
231
  - lib/atomic_view/components/number_field_component.rb
232
+ - lib/atomic_view/components/page_header_component.html.erb
233
+ - lib/atomic_view/components/page_header_component.rb
227
234
  - lib/atomic_view/components/pagination_component.html.erb
228
235
  - lib/atomic_view/components/pagination_component.rb
229
236
  - lib/atomic_view/components/password_field_component.rb
@@ -234,6 +241,8 @@ files:
234
241
  - lib/atomic_view/components/segmented_control_component.rb
235
242
  - lib/atomic_view/components/select_component.html.erb
236
243
  - lib/atomic_view/components/select_component.rb
244
+ - lib/atomic_view/components/show_page_component.html.erb
245
+ - lib/atomic_view/components/show_page_component.rb
237
246
  - lib/atomic_view/components/submit_component.rb
238
247
  - lib/atomic_view/components/table_component.rb
239
248
  - lib/atomic_view/components/tabs_component.html.erb
@@ -250,6 +259,8 @@ files:
250
259
  - lib/atomic_view/components/timeline_component/item_component.rb
251
260
  - lib/atomic_view/components/toast_component.html.erb
252
261
  - lib/atomic_view/components/toast_component.rb
262
+ - lib/atomic_view/components/tooltip_component.html.erb
263
+ - lib/atomic_view/components/tooltip_component.rb
253
264
  - lib/atomic_view/components/url_field_component.rb
254
265
  - lib/atomic_view/components/week_field_component.rb
255
266
  - lib/atomic_view/components/weekday_select_component.rb