atomic_view 0.1.9 → 0.1.11
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/chip_controller.js +8 -0
- data/app/assets/javascripts/atomic_view/controllers/command_palette_controller.js +96 -0
- data/app/assets/javascripts/atomic_view/controllers/dropdown_controller.js +42 -0
- data/app/assets/javascripts/atomic_view/controllers/modal_controller.js +23 -0
- data/app/assets/javascripts/atomic_view/controllers/theme_toggle_controller.js +45 -0
- data/app/assets/javascripts/atomic_view/controllers/toast_controller.js +20 -0
- data/app/assets/stylesheets/atomic_view/application.tailwind.css +0 -2
- data/app/assets/tailwind/atomic_view/engine.css +91 -28
- data/config/importmap.rb +45 -1
- data/lib/atomic_view/component.rb +1 -0
- data/lib/atomic_view/components/alert_component.erb +15 -0
- data/lib/atomic_view/components/alert_component.rb +63 -0
- data/lib/atomic_view/components/avatar_component.rb +63 -0
- data/lib/atomic_view/components/badge_component.rb +38 -0
- data/lib/atomic_view/components/button_component.rb +2 -23
- data/lib/atomic_view/components/card_component.rb +29 -0
- data/lib/atomic_view/components/chip_component.rb +56 -0
- data/lib/atomic_view/components/collection_check_boxes_component.erb +3 -0
- data/lib/atomic_view/components/collection_check_boxes_component.rb +25 -0
- data/lib/atomic_view/components/collection_radio_buttons_component.erb +3 -0
- data/lib/atomic_view/components/collection_radio_buttons_component.rb +25 -0
- data/lib/atomic_view/components/collection_select_component.erb +25 -0
- data/lib/atomic_view/components/collection_select_component.rb +21 -4
- data/lib/atomic_view/components/color_field_component.rb +3 -0
- data/lib/atomic_view/components/command_palette_component.erb +41 -0
- data/lib/atomic_view/components/command_palette_component.rb +88 -0
- data/lib/atomic_view/components/concerns/button_variants.rb +34 -0
- data/lib/atomic_view/components/concerns/field_chrome.rb +21 -0
- data/lib/atomic_view/components/concerns/section_support.rb +67 -0
- data/lib/atomic_view/components/date_field_component.rb +3 -9
- data/lib/atomic_view/components/date_select_component.rb +9 -0
- data/lib/atomic_view/components/datetime_local_field_component.rb +9 -0
- data/lib/atomic_view/components/datetime_select_component.rb +9 -0
- data/lib/atomic_view/components/dropdown_component.erb +11 -0
- data/lib/atomic_view/components/dropdown_component.rb +57 -0
- data/lib/atomic_view/components/empty_state_component.erb +10 -0
- data/lib/atomic_view/components/empty_state_component.rb +28 -0
- data/lib/atomic_view/components/field_component.erb +3 -3
- data/lib/atomic_view/components/field_component.rb +6 -46
- data/lib/atomic_view/components/grouped_collection_select_component.rb +5 -0
- data/lib/atomic_view/components/kbd_component.rb +31 -0
- data/lib/atomic_view/components/label_component.rb +16 -1
- data/lib/atomic_view/components/modal_component.erb +24 -0
- data/lib/atomic_view/components/modal_component.rb +57 -0
- data/lib/atomic_view/components/month_field_component.rb +9 -0
- data/lib/atomic_view/components/navigation_tree_component/item_component.erb +23 -0
- data/lib/atomic_view/components/navigation_tree_component/item_component.rb +54 -0
- data/lib/atomic_view/components/navigation_tree_component.erb +8 -0
- data/lib/atomic_view/components/navigation_tree_component.rb +40 -0
- data/lib/atomic_view/components/pagination_component.erb +19 -0
- data/lib/atomic_view/components/pagination_component.rb +81 -0
- data/lib/atomic_view/components/radio_button_component.rb +3 -0
- data/lib/atomic_view/components/range_field_component.rb +3 -0
- data/lib/atomic_view/components/segmented_control_component.erb +14 -0
- data/lib/atomic_view/components/segmented_control_component.rb +102 -0
- data/lib/atomic_view/components/select_component.erb +25 -0
- data/lib/atomic_view/components/select_component.rb +21 -7
- data/lib/atomic_view/components/submit_component.rb +2 -23
- data/lib/atomic_view/components/table_component.rb +52 -0
- data/lib/atomic_view/components/tabs_component.erb +5 -0
- data/lib/atomic_view/components/tabs_component.rb +74 -0
- data/lib/atomic_view/components/text_area_component.rb +5 -0
- data/lib/atomic_view/components/theme_toggle_component.rb +74 -0
- data/lib/atomic_view/components/time_select_component.rb +9 -0
- data/lib/atomic_view/components/time_zone_select_component.rb +5 -0
- data/lib/atomic_view/components/timeline_component/item_component.erb +10 -0
- data/lib/atomic_view/components/timeline_component/item_component.rb +50 -0
- data/lib/atomic_view/components/timeline_component.erb +10 -0
- data/lib/atomic_view/components/timeline_component.rb +61 -0
- data/lib/atomic_view/components/toast_component.erb +14 -0
- data/lib/atomic_view/components/toast_component.rb +68 -0
- data/lib/atomic_view/components/week_field_component.rb +9 -0
- data/lib/atomic_view/components/weekday_select_component.rb +5 -0
- data/lib/atomic_view/form_builder.rb +25 -0
- data/lib/atomic_view/version.rb +1 -1
- data/lib/atomic_view.rb +1 -0
- data/lib/generators/atomic_view/install_generator.rb +13 -12
- metadata +62 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%= content_tag :div, class: container_html_class do %>
|
|
2
|
+
<% if left_section? %>
|
|
3
|
+
<% if left_section_addon? %>
|
|
4
|
+
<span class="inline-flex items-center rounded-l-btn border border-r-0 border-ring/10 dark:border-white/10 bg-transparent dark:bg-white/5 px-3 text-primary dark:text-white sm:text-sm"><%= left_section %></span>
|
|
5
|
+
<% elsif left_section_interaction? %>
|
|
6
|
+
<%= left_section %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
9
|
+
<%= left_section %>
|
|
10
|
+
</div>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= select_tag %>
|
|
14
|
+
<% if right_section? %>
|
|
15
|
+
<% if right_section_addon? %>
|
|
16
|
+
<span class="inline-flex items-center rounded-r-btn border border-l-0 border-ring/10 dark:border-white/10 bg-transparent dark:bg-white/5 px-3 text-primary dark:text-white sm:text-sm"><%= right_section %></span>
|
|
17
|
+
<% elsif right_section_interaction? %>
|
|
18
|
+
<%= right_section %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
21
|
+
<%= right_section %>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class SelectComponent < ViewComponent::Form::SelectComponent
|
|
4
|
+
include AtomicView::Components::Concerns::FieldChrome
|
|
5
|
+
include AtomicView::Components::Concerns::SectionSupport
|
|
6
|
+
|
|
4
7
|
def html_class
|
|
5
8
|
class_names(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
options[:class]
|
|
9
|
+
field_chrome_classes(size: :sm),
|
|
10
|
+
options[:class],
|
|
11
|
+
"pl-10" => left_section? && !(left_section_addon? || left_section_interaction?),
|
|
12
|
+
"pr-10" => right_section? && !(right_section_addon? || right_section_interaction?),
|
|
13
|
+
"shadow-none rounded-none rounded-r-btn ring-inset" => left_section_addon? || left_section_interaction?,
|
|
14
|
+
"shadow-none rounded-none rounded-l-btn ring-inset" => right_section_addon? || right_section_interaction?
|
|
13
15
|
)
|
|
14
16
|
end
|
|
17
|
+
|
|
18
|
+
def select_tag
|
|
19
|
+
ActionView::Helpers::Tags::Select.new(
|
|
20
|
+
object_name,
|
|
21
|
+
method_name,
|
|
22
|
+
@view_context,
|
|
23
|
+
choices,
|
|
24
|
+
options,
|
|
25
|
+
html_options,
|
|
26
|
+
&content
|
|
27
|
+
).render
|
|
28
|
+
end
|
|
15
29
|
end
|
|
16
30
|
end
|
|
17
31
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class SubmitComponent < ViewComponent::Form::SubmitComponent
|
|
4
|
+
include AtomicView::Components::Concerns::ButtonVariants
|
|
5
|
+
|
|
4
6
|
attr_reader :variant
|
|
5
7
|
|
|
6
8
|
def initialize(form, value_or_options = nil, options = nil)
|
|
@@ -18,29 +20,6 @@ module AtomicView
|
|
|
18
20
|
def html_class
|
|
19
21
|
class_names(base_classes, variant_classes, @options[:class])
|
|
20
22
|
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def base_classes
|
|
25
|
-
"h-8 rounded-md px-3 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]"
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def variant_classes
|
|
29
|
-
case variant
|
|
30
|
-
when :primary
|
|
31
|
-
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90"
|
|
32
|
-
when :secondary
|
|
33
|
-
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80"
|
|
34
|
-
when :destructive
|
|
35
|
-
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20"
|
|
36
|
-
when :muted
|
|
37
|
-
"bg-transparent text-muted-foreground hover:bg-muted"
|
|
38
|
-
when :link
|
|
39
|
-
"text-primary underline-offset-4 hover:underline"
|
|
40
|
-
else
|
|
41
|
-
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90"
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
23
|
end
|
|
45
24
|
end
|
|
46
25
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Table
|
|
6
|
+
#
|
|
7
|
+
# A thin styling wrapper around <table>. Consistent with the rest of the
|
|
8
|
+
# gem, this does not own row/column iteration -- the consuming view
|
|
9
|
+
# builds its own <thead>/<tbody> markup and calls the yielded component's
|
|
10
|
+
# #head_class / #row_class helpers to pick up the shared styling.
|
|
11
|
+
#
|
|
12
|
+
# <%= render AtomicView::Components::TableComponent.new do |table| %>
|
|
13
|
+
# <thead>
|
|
14
|
+
# <tr class="<%= table.head_class %>">
|
|
15
|
+
# <th class="px-3 py-2">Name</th>
|
|
16
|
+
# </tr>
|
|
17
|
+
# </thead>
|
|
18
|
+
# <tbody>
|
|
19
|
+
# <tr class="<%= table.row_class %>">
|
|
20
|
+
# <td class="px-3 py-2">Riverbend</td>
|
|
21
|
+
# </tr>
|
|
22
|
+
# </tbody>
|
|
23
|
+
# <% end %>
|
|
24
|
+
class TableComponent < AtomicView::Component
|
|
25
|
+
HEAD_ROW_CLASSES = "text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b border-border"
|
|
26
|
+
BODY_ROW_CLASSES = "border-b border-border hover:bg-offset"
|
|
27
|
+
|
|
28
|
+
def initialize(**options)
|
|
29
|
+
super()
|
|
30
|
+
@options = options
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class])) { content }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def head_class(**options)
|
|
38
|
+
class_names(HEAD_ROW_CLASSES, options[:class])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def row_class(**options)
|
|
42
|
+
class_names(BODY_ROW_CLASSES, options[:class])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def base_classes
|
|
48
|
+
"w-full text-sm"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Tabs
|
|
6
|
+
#
|
|
7
|
+
# Renders an underlined tab bar for navigating between a record's
|
|
8
|
+
# sub-pages (e.g. Overview/Invoices/Activity). Like
|
|
9
|
+
# SegmentedControlComponent's link mode, `options` are {label:, href:}
|
|
10
|
+
# hashes and `selected` is compared against each option's `label:` --
|
|
11
|
+
# the consumer already has the current tab's display value in hand
|
|
12
|
+
# (e.g. from the request path), so matching on `label:` avoids making
|
|
13
|
+
# every caller build a comparable canonical href.
|
|
14
|
+
#
|
|
15
|
+
# Unlike SegmentedControlComponent's pill track (mutually-exclusive
|
|
16
|
+
# filter chips raised on a muted background), tabs read as page-level
|
|
17
|
+
# navigation -- a plain row of links with a colored underline on the
|
|
18
|
+
# active one, sharing a bottom divider with the rest of the row.
|
|
19
|
+
#
|
|
20
|
+
# Pass `turbo_frame:` to swap tabs without a full page load: every tab
|
|
21
|
+
# link gets a matching `data-turbo-frame`. Wrap *both* this component
|
|
22
|
+
# and the content below it in the same `<turbo-frame id="...">` --
|
|
23
|
+
# not just the content -- so the newly-active tab's underline arrives
|
|
24
|
+
# in the same swap as the new content, rather than the frame updating
|
|
25
|
+
# while the tab bar outside it still shows the old tab active. The
|
|
26
|
+
# component doesn't render that frame itself -- it stays agnostic
|
|
27
|
+
# about the content underneath, the same way it doesn't know
|
|
28
|
+
# what each `href` points to.
|
|
29
|
+
class TabsComponent < AtomicView::Component
|
|
30
|
+
attr_reader :options, :selected, :turbo_frame
|
|
31
|
+
|
|
32
|
+
def initialize(options:, selected:, turbo_frame: nil, **html_options)
|
|
33
|
+
super()
|
|
34
|
+
@options = options
|
|
35
|
+
@selected = selected
|
|
36
|
+
@turbo_frame = turbo_frame
|
|
37
|
+
@html_options = html_options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def active?(option)
|
|
41
|
+
option[:label] == selected
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def container_class
|
|
45
|
+
class_names("flex gap-6 border-b border-border", @html_options[:class])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tab_attributes(option)
|
|
49
|
+
attributes = {class: tab_classes(option)}
|
|
50
|
+
attributes[:"aria-current"] = "true" if active?(option)
|
|
51
|
+
attributes[:data] = {turbo_frame: turbo_frame} if turbo_frame.present?
|
|
52
|
+
attributes
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def tab_classes(option)
|
|
58
|
+
class_names(base_classes, active?(option) ? active_classes : inactive_classes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def base_classes
|
|
62
|
+
"-mb-px border-b-2 pb-3 text-sm font-semibold"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def active_classes
|
|
66
|
+
"border-accent text-accent"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inactive_classes
|
|
70
|
+
"border-transparent text-muted-foreground hover:text-foreground"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class TextAreaComponent < ViewComponent::Form::TextAreaComponent
|
|
4
|
+
include AtomicView::Components::Concerns::FieldChrome
|
|
5
|
+
|
|
6
|
+
def html_class
|
|
7
|
+
class_names(field_chrome_classes, "h-auto min-h-24 resize-y")
|
|
8
|
+
end
|
|
4
9
|
end
|
|
5
10
|
end
|
|
6
11
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# ThemeToggle
|
|
6
|
+
#
|
|
7
|
+
# A button that flips the `.dark` class on `<html>` -- the convention
|
|
8
|
+
# engine.css itself documents (see its "Explicit .dark class toggle
|
|
9
|
+
# only" comment) -- between light and dark, persisting the choice to
|
|
10
|
+
# `localStorage` and falling back to the OS preference
|
|
11
|
+
# (`prefers-color-scheme`) the first time a visitor arrives with no
|
|
12
|
+
# stored choice.
|
|
13
|
+
#
|
|
14
|
+
# The sun/moon crossfade is pure CSS (`dark:` utility classes, which
|
|
15
|
+
# resolve off that same `.dark` class per engine.css's
|
|
16
|
+
# `@custom-variant dark`) -- the `atomic-view--theme-toggle` Stimulus
|
|
17
|
+
# controller only toggles the class and persists it, no host app JS
|
|
18
|
+
# required, same as every other atomic_view controller (auto-pinned
|
|
19
|
+
# per config/importmap.rb).
|
|
20
|
+
#
|
|
21
|
+
# Applying `.dark` before first paint (to avoid a flash of the wrong
|
|
22
|
+
# theme) is the host app's concern -- a small inline script in
|
|
23
|
+
# `<head>`, e.g.:
|
|
24
|
+
#
|
|
25
|
+
# <script>
|
|
26
|
+
# try {
|
|
27
|
+
# var t = localStorage.getItem("atomic-view-theme");
|
|
28
|
+
# var dark = t ? t === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
29
|
+
# if (dark) document.documentElement.classList.add("dark");
|
|
30
|
+
# } catch (e) {}
|
|
31
|
+
# </script>
|
|
32
|
+
#
|
|
33
|
+
# This is the same FOUC-prevention step any dark-mode toggle needs,
|
|
34
|
+
# since Stimulus only connects after the DOM has already painted once.
|
|
35
|
+
class ThemeToggleComponent < AtomicView::Component
|
|
36
|
+
def initialize(**options)
|
|
37
|
+
super()
|
|
38
|
+
@options = options
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
tag.button(
|
|
43
|
+
**@options.except(:class),
|
|
44
|
+
type: "button",
|
|
45
|
+
class: class_names(base_classes, @options[:class]),
|
|
46
|
+
data: {controller: "atomic-view--theme-toggle", action: "click->atomic-view--theme-toggle#toggle"},
|
|
47
|
+
aria: {label: "Toggle theme"}
|
|
48
|
+
) { safe_join([sun_icon, moon_icon]) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def base_classes
|
|
54
|
+
"relative inline-flex size-8 items-center justify-center rounded-full text-muted-foreground hover:bg-offset hover:text-foreground"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def sun_icon
|
|
58
|
+
icon("sun", options: {class: sun_classes}).to_s.html_safe
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def moon_icon
|
|
62
|
+
icon("moon", options: {class: moon_classes}).to_s.html_safe
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def sun_classes
|
|
66
|
+
"absolute size-4 rotate-0 scale-100 opacity-100 transition-all duration-300 dark:rotate-90 dark:scale-50 dark:opacity-0"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def moon_classes
|
|
70
|
+
"absolute size-4 -rotate-90 scale-50 opacity-0 transition-all duration-300 dark:rotate-0 dark:scale-100 dark:opacity-100"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class TimeSelectComponent < ViewComponent::Form::TimeSelectComponent
|
|
4
|
+
include AtomicView::Components::Concerns::FieldChrome
|
|
5
|
+
|
|
6
|
+
def html_class
|
|
7
|
+
field_chrome_classes(size: :sm)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
content_tag(:div, super, class: "flex gap-2")
|
|
12
|
+
end
|
|
4
13
|
end
|
|
5
14
|
end
|
|
6
15
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= marker %>
|
|
2
|
+
<div class="min-w-0 flex-1 pt-0.5">
|
|
3
|
+
<div class="text-sm text-foreground">
|
|
4
|
+
<%= meta %>
|
|
5
|
+
<% if time.present? %>
|
|
6
|
+
<%= local_time_ago(time, class: "ml-2 text-xs text-muted-foreground") %>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
9
|
+
<%= content if content? %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TimelineComponent
|
|
6
|
+
# A single Timeline row: a marker (icon or avatar) plus a meta line
|
|
7
|
+
# (optional actor + description + time) and optional free-form block
|
|
8
|
+
# content. Rendered via `TimelineComponent#with_item` -- see
|
|
9
|
+
# `TimelineComponent`'s class docs.
|
|
10
|
+
#
|
|
11
|
+
# `time:` takes a real `Time`/`Date`/`DateTime`/
|
|
12
|
+
# `ActiveSupport::TimeWithZone` -- not a preformatted string -- and is
|
|
13
|
+
# rendered with the `local_time` gem's `local_time_ago` helper (see
|
|
14
|
+
# `AtomicView::Component`), which degrades gracefully to an absolute
|
|
15
|
+
# UTC timestamp server-side and upgrades to a relative "3 hours ago"
|
|
16
|
+
# (aging into a full date over time) client-side once the host app
|
|
17
|
+
# loads local_time's JS -- see `config/importmap.rb`'s "local-time"
|
|
18
|
+
# pin comment for that setup.
|
|
19
|
+
class ItemComponent < AtomicView::Component
|
|
20
|
+
attr_reader :icon_name, :avatar, :actor, :description, :time
|
|
21
|
+
|
|
22
|
+
def initialize(description:, icon: nil, avatar: nil, actor: nil, time: nil)
|
|
23
|
+
super()
|
|
24
|
+
@icon_name = icon
|
|
25
|
+
@avatar = avatar
|
|
26
|
+
@actor = actor
|
|
27
|
+
@description = description
|
|
28
|
+
@time = time
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def marker
|
|
32
|
+
if avatar.present?
|
|
33
|
+
render(AvatarComponent.new(initials: avatar, class: "size-7 shrink-0 text-xs"))
|
|
34
|
+
else
|
|
35
|
+
tag.span(class: "flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground") do
|
|
36
|
+
icon(icon_name, options: {class: "size-3.5"}).to_s.html_safe
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def meta
|
|
42
|
+
parts = []
|
|
43
|
+
parts << tag.strong(actor, class: "font-semibold") if actor.present?
|
|
44
|
+
parts << description
|
|
45
|
+
safe_join(parts, " ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= tag.div(**@options.except(:class), class: container_class) do %>
|
|
2
|
+
<% items.each_with_index do |item, index| %>
|
|
3
|
+
<div class="<%= item_wrapper_class(index) %>">
|
|
4
|
+
<% if render_line?(index) %>
|
|
5
|
+
<div class="absolute left-[13px] top-7 -bottom-6 w-px bg-border"></div>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= item %>
|
|
8
|
+
</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Timeline
|
|
6
|
+
#
|
|
7
|
+
# A vertical audit/activity feed -- e.g. a record's created/changed/
|
|
8
|
+
# commented history. Each row is a `with_item` slot rather than a plain
|
|
9
|
+
# data hash: every item needs the same marker + meta-line + connecting-
|
|
10
|
+
# line layout generated consistently, and slots let each item take
|
|
11
|
+
# strongly-typed keyword arguments (with real defaults) plus optional
|
|
12
|
+
# free-form block content, instead of a loose symbol-keyed hash.
|
|
13
|
+
#
|
|
14
|
+
# render(TimelineComponent.new) do |timeline|
|
|
15
|
+
# timeline.with_item(icon: "flag", actor: "Karen Will", description: "created this park", time: 1.year.ago)
|
|
16
|
+
#
|
|
17
|
+
# timeline.with_item(avatar: "KW", actor: "Karen Will", description: "commented", time: 3.hours.ago) do
|
|
18
|
+
# tag.div("Following up...", class: "mt-2 rounded-btn bg-offset p-2 text-sm")
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Each item needs exactly one marker: `icon:` (a Heroicon name, shown in
|
|
23
|
+
# a plain circular badge) or `avatar:` (initials, rendered via
|
|
24
|
+
# `AvatarComponent`) -- for a system event vs. something a person did.
|
|
25
|
+
# `actor:` is optional bold text before `description:` (omit it for
|
|
26
|
+
# system-generated events, e.g. "Status changed automatically"). `time:`
|
|
27
|
+
# takes a real `Time`/`Date`/`DateTime`/`ActiveSupport::TimeWithZone`
|
|
28
|
+
# rather than a preformatted string -- see `ItemComponent`'s class docs
|
|
29
|
+
# for how it's rendered. The block passed to `with_item` is optional
|
|
30
|
+
# free-form HTML-safe content below the meta line -- a diff comparison,
|
|
31
|
+
# a badge change, a comment bubble -- since that part varies too much
|
|
32
|
+
# to model as a keyword argument. See `ItemComponent` for the per-item
|
|
33
|
+
# API.
|
|
34
|
+
class TimelineComponent < AtomicView::Component
|
|
35
|
+
renders_many :items, "ItemComponent"
|
|
36
|
+
|
|
37
|
+
def initialize(**options)
|
|
38
|
+
super()
|
|
39
|
+
@options = options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def container_class
|
|
43
|
+
class_names("flex flex-col", @options[:class])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def item_wrapper_class(index)
|
|
47
|
+
class_names("relative flex gap-3.5", "pb-6" => !last?(index))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_line?(index)
|
|
51
|
+
!last?(index)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def last?(index)
|
|
57
|
+
index == items.length - 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%= tag.div(**html_options, class: html_class, data: data_attributes, role: "alert") do %>
|
|
2
|
+
<%= icon(icon_name, options: {class: icon_class}).to_s.html_safe %>
|
|
3
|
+
<div class="min-w-0 flex-1">
|
|
4
|
+
<p class="font-body text-sm font-semibold text-foreground"><%= title %></p>
|
|
5
|
+
<% if description.present? %>
|
|
6
|
+
<p class="mt-1 text-sm text-muted-foreground"><%= description %></p>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
9
|
+
<% if dismissible %>
|
|
10
|
+
<%= tag.button(type: "button", class: "inline-flex shrink-0 items-center text-muted-foreground hover:text-foreground", data: {action: "click->atomic-view--toast#dismiss"}, aria: {label: "Dismiss"}) do %>
|
|
11
|
+
<%= icon("x-mark", variant: :mini, options: {class: "size-4"}).to_s.html_safe %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Toast
|
|
6
|
+
#
|
|
7
|
+
# A single, self-dismissing notification. This component only renders
|
|
8
|
+
# one toast and wires it to `atomic-view--toast`, the Stimulus controller
|
|
9
|
+
# that handles its own dismiss/auto-dismiss lifecycle (see
|
|
10
|
+
# `app/assets/javascripts/atomic_view/controllers/toast_controller.js`).
|
|
11
|
+
#
|
|
12
|
+
# Stacking multiple toasts (newest on top/bottom, fixed to a screen
|
|
13
|
+
# corner) is intentionally out of scope -- wrap however many
|
|
14
|
+
# ToastComponents a consuming app renders in a plain CSS container, e.g.
|
|
15
|
+
# `fixed bottom-4 right-4 flex flex-col-reverse gap-2 z-50`.
|
|
16
|
+
class ToastComponent < AtomicView::Component
|
|
17
|
+
attr_reader :variant, :title, :description, :dismissible, :auto_dismiss_ms
|
|
18
|
+
|
|
19
|
+
VARIANTS = {
|
|
20
|
+
success: {icon: "check-circle", border: "border-l-success", icon_color: "text-success"},
|
|
21
|
+
error: {icon: "x-circle", border: "border-l-destructive", icon_color: "text-destructive"},
|
|
22
|
+
info: {icon: "information-circle", border: "border-l-primary", icon_color: "text-primary"}
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(title:, variant: :info, description: nil, dismissible: true, auto_dismiss_ms: nil, **options)
|
|
26
|
+
super()
|
|
27
|
+
@variant = variant
|
|
28
|
+
@title = title
|
|
29
|
+
@description = description
|
|
30
|
+
@dismissible = dismissible
|
|
31
|
+
@auto_dismiss_ms = auto_dismiss_ms
|
|
32
|
+
@options = options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def html_options
|
|
36
|
+
@options.except(:class, :data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def html_class
|
|
40
|
+
class_names(base_classes, variant_config[:border], @options[:class])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def icon_name
|
|
44
|
+
variant_config[:icon]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def icon_class
|
|
48
|
+
class_names("size-5 shrink-0", variant_config[:icon_color])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def data_attributes
|
|
52
|
+
attributes = (@options[:data] || {}).merge(controller: "atomic-view--toast")
|
|
53
|
+
attributes["atomic-view--toast-auto-dismiss-ms-value"] = auto_dismiss_ms if auto_dismiss_ms.present?
|
|
54
|
+
attributes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def variant_config
|
|
60
|
+
VARIANTS.fetch(variant, VARIANTS[:info])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def base_classes
|
|
64
|
+
"flex items-start gap-3 rounded-card border border-border border-l-4 bg-surface p-3 shadow-soft"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class WeekFieldComponent < ViewComponent::Form::WeekFieldComponent
|
|
4
|
+
def call
|
|
5
|
+
render FieldComponent.new(
|
|
6
|
+
form,
|
|
7
|
+
object_name,
|
|
8
|
+
method_name,
|
|
9
|
+
options,
|
|
10
|
+
ActionView::Helpers::Tags::WeekField
|
|
11
|
+
)
|
|
12
|
+
end
|
|
4
13
|
end
|
|
5
14
|
end
|
|
6
15
|
end
|
|
@@ -3,5 +3,30 @@
|
|
|
3
3
|
module AtomicView
|
|
4
4
|
class FormBuilder < ViewComponent::Form::Builder
|
|
5
5
|
namespace Components
|
|
6
|
+
|
|
7
|
+
# view_component-form's Helpers::Rails module forwards most native Rails
|
|
8
|
+
# form helpers to their ViewComponent equivalents, but it misses
|
|
9
|
+
# `weekday_select` (added natively to Rails' FormBuilder in Rails 7).
|
|
10
|
+
# Without this override, `form.weekday_select` would bypass
|
|
11
|
+
# AtomicView::Components::WeekdaySelectComponent entirely and fall back to
|
|
12
|
+
# ActionView's unstyled native helper.
|
|
13
|
+
def weekday_select(method, options = {}, html_options = {})
|
|
14
|
+
render_component(
|
|
15
|
+
:weekday_select, @object_name, method,
|
|
16
|
+
objectify_options(options), @default_html_options.merge(html_options)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rails 7.1 renamed `field_helpers`' entry from `:text_area` to
|
|
21
|
+
# `:textarea`, so view_component-form's dynamic method generation (which
|
|
22
|
+
# iterates `field_helpers`) now defines a component-backed `textarea`
|
|
23
|
+
# method but leaves `text_area` falling through to Rails' native,
|
|
24
|
+
# unstyled helper. Without this override, `form.text_area` would bypass
|
|
25
|
+
# AtomicView::Components::TextAreaComponent entirely.
|
|
26
|
+
def text_area(method, options = {})
|
|
27
|
+
render_component(
|
|
28
|
+
:text_area, @object_name, method, objectify_options(options)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
6
31
|
end
|
|
7
32
|
end
|
data/lib/atomic_view/version.rb
CHANGED