atomic_view 0.1.8 → 0.1.10
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 +90 -29
- 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/nav_item_component.erb +6 -0
- data/lib/atomic_view/components/nav_item_component.rb +36 -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 -4
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= tag.div(**@options.except(:class), class: html_class) do %>
|
|
2
|
+
<%= icon if icon? %>
|
|
3
|
+
<p class="font-body font-semibold text-base"><%= title %></p>
|
|
4
|
+
<% if description.present? %>
|
|
5
|
+
<p class="text-muted-foreground text-sm"><%= description %></p>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% if content? %>
|
|
8
|
+
<%= content %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class EmptyStateComponent < AtomicView::Component
|
|
6
|
+
renders_one :icon
|
|
7
|
+
|
|
8
|
+
attr_reader :title, :description
|
|
9
|
+
|
|
10
|
+
def initialize(title:, description: nil, **options)
|
|
11
|
+
super()
|
|
12
|
+
@title = title
|
|
13
|
+
@description = description
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def html_class
|
|
20
|
+
class_names(base_classes, @options[:class])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def base_classes
|
|
24
|
+
"flex flex-col items-center text-center gap-2 p-6"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%= content_tag :div, class: container_html_class do %>
|
|
2
2
|
<% if left_section? %>
|
|
3
3
|
<% if left_section_addon? %>
|
|
4
|
-
<span class="inline-flex items-center rounded-l-
|
|
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
5
|
<% elsif left_section_interaction? %>
|
|
6
6
|
<%= left_section %>
|
|
7
7
|
<% else %>
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
</div>
|
|
11
11
|
<% end %>
|
|
12
12
|
<% end %>
|
|
13
|
-
<%= tag_klass.new(@object_name, @method_name, @view_context,
|
|
13
|
+
<%= tag_klass.new(@object_name, @method_name, @view_context, options_without_sections).render %>
|
|
14
14
|
<% if right_section? %>
|
|
15
15
|
<% if right_section_addon? %>
|
|
16
|
-
<span class="inline-flex items-center rounded-r-
|
|
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
17
|
<% elsif right_section_interaction? %>
|
|
18
18
|
<%= right_section %>
|
|
19
19
|
<% else %>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class FieldComponent < ViewComponent::Form::FieldComponent
|
|
4
|
+
include AtomicView::Components::Concerns::FieldChrome
|
|
5
|
+
include AtomicView::Components::Concerns::SectionSupport
|
|
6
|
+
|
|
4
7
|
attr_reader :tag_klass
|
|
5
8
|
|
|
6
9
|
def initialize(form, object_name, method_name, options = {}, tag_klass = ActionView::Helpers::Tags::TextField)
|
|
@@ -10,56 +13,13 @@ module AtomicView
|
|
|
10
13
|
|
|
11
14
|
def html_class
|
|
12
15
|
class_names(
|
|
13
|
-
|
|
14
|
-
"disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-foreground disabled:ring-disabled-ring",
|
|
15
|
-
"bg-transparent dark:bg-white/5 text-primary ring-ring/10 dark:ring-white/10 placeholder:text-placeholder dark:text-white focus:ring-focus-ring focus:border-ring/20 dark:focus:ring-focus-ring",
|
|
16
|
+
field_chrome_classes,
|
|
16
17
|
"pl-10" => left_section? && !(left_section_addon? || left_section_interaction?),
|
|
17
18
|
"pr-10" => right_section? && !(right_section_addon? || right_section_interaction?),
|
|
18
|
-
"shadow-none rounded-none rounded-r-
|
|
19
|
-
"shadow-none rounded-none rounded-l-
|
|
20
|
-
"text-error ring-error-ring placeholder:text-error-placeholder focus:ring-error-focus-ring" => method_errors?
|
|
21
|
-
)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def container_html_class
|
|
25
|
-
class_names(
|
|
26
|
-
"relative rounded-lg shadow-xs",
|
|
27
|
-
{"flex" => left_section_addon? || left_section_interaction? || right_section_addon? || right_section_interaction?},
|
|
28
|
-
options[:container_class]
|
|
19
|
+
"shadow-none rounded-none rounded-r-btn ring-inset" => left_section_addon? || left_section_interaction?,
|
|
20
|
+
"shadow-none rounded-none rounded-l-btn ring-inset" => right_section_addon? || right_section_interaction?
|
|
29
21
|
)
|
|
30
22
|
end
|
|
31
|
-
|
|
32
|
-
def left_section
|
|
33
|
-
options[:left_section]
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def left_section?
|
|
37
|
-
left_section.present?
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def left_section_addon?
|
|
41
|
-
options[:left_section_as_addon].present?
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def left_section_interaction?
|
|
45
|
-
options[:left_section_as_interaction].present?
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def right_section
|
|
49
|
-
options[:right_section] || method_errors? && raw(icon("exclamation-circle", variant: :mini, options: {class: "size-5 text-destructive"}))
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def right_section?
|
|
53
|
-
right_section.present?
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def right_section_addon?
|
|
57
|
-
options[:right_section_as_addon].present?
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def right_section_interaction?
|
|
61
|
-
options[:right_section_as_interaction].present?
|
|
62
|
-
end
|
|
63
23
|
end
|
|
64
24
|
end
|
|
65
25
|
end
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class GroupedCollectionSelectComponent < ViewComponent::Form::GroupedCollectionSelectComponent
|
|
4
|
+
include AtomicView::Components::Concerns::FieldChrome
|
|
5
|
+
|
|
6
|
+
def html_class
|
|
7
|
+
field_chrome_classes(size: :sm)
|
|
8
|
+
end
|
|
4
9
|
end
|
|
5
10
|
end
|
|
6
11
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Kbd
|
|
6
|
+
#
|
|
7
|
+
# A single keyboard "keycap" token -- a modifier symbol (⌘), a letter
|
|
8
|
+
# (K), or a named key (Esc). It's a generic, content-based primitive
|
|
9
|
+
# (like BadgeComponent/ChipComponent) rather than something that knows
|
|
10
|
+
# about multi-key shortcuts -- to show a full chord, render several
|
|
11
|
+
# inside a flex container with a small gap, adding a `+` between them
|
|
12
|
+
# if the convention calls for it (see CommandPaletteComponent for an
|
|
13
|
+
# example, or the preview's "Composing a shortcut" examples).
|
|
14
|
+
class KbdComponent < AtomicView::Component
|
|
15
|
+
def initialize(**options)
|
|
16
|
+
super()
|
|
17
|
+
@options = options
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
tag.kbd(**@options.except(:class), class: class_names(base_classes, @options[:class])) { content }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def base_classes
|
|
27
|
+
"inline-flex h-5 min-w-5 items-center justify-center rounded-well border border-border bg-offset px-1 font-mono text-xs text-muted-foreground"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class LabelComponent < ViewComponent::Form::LabelComponent
|
|
4
|
+
attr_reader :variant
|
|
5
|
+
|
|
6
|
+
def initialize(form, object_name, method_name, content_or_options = nil, options = nil)
|
|
7
|
+
super
|
|
8
|
+
|
|
9
|
+
@variant = @options.delete(:variant) || :default
|
|
10
|
+
end
|
|
11
|
+
|
|
4
12
|
def html_class
|
|
5
|
-
|
|
13
|
+
case variant
|
|
14
|
+
when :caption
|
|
15
|
+
"block text-muted-foreground text-xs font-bold uppercase tracking-wide mb-1"
|
|
16
|
+
when :inline
|
|
17
|
+
"text-foreground text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
|
18
|
+
else
|
|
19
|
+
"block text-foreground text-sm font-medium leading-6 mb-2"
|
|
20
|
+
end
|
|
6
21
|
end
|
|
7
22
|
end
|
|
8
23
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%= tag.dialog(**html_options, id: id, class: html_class, data: data_attributes) do %>
|
|
2
|
+
<div class="p-4">
|
|
3
|
+
<% if title.present? || danger %>
|
|
4
|
+
<div class="flex items-center gap-2">
|
|
5
|
+
<% if danger %>
|
|
6
|
+
<%= danger_icon %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% if title.present? %>
|
|
9
|
+
<h2 class="font-body text-lg font-semibold text-foreground"><%= title %></h2>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<div class="mt-3 text-sm text-muted-foreground">
|
|
15
|
+
<%= content %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% if footer? %>
|
|
19
|
+
<div class="mt-4 flex justify-end gap-2">
|
|
20
|
+
<%= footer %>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Modal
|
|
6
|
+
#
|
|
7
|
+
# A confirmation dialog / modal built on the native `<dialog>` element,
|
|
8
|
+
# wired to `atomic-view--modal` (see
|
|
9
|
+
# `app/assets/javascripts/atomic_view/controllers/modal_controller.js`).
|
|
10
|
+
# Using `<dialog>` gets focus-trap, `::backdrop` styling, and Esc-to-close
|
|
11
|
+
# for free -- the controller only adds `open`/`close`, plus closing when
|
|
12
|
+
# the backdrop itself (not the content box) is clicked.
|
|
13
|
+
#
|
|
14
|
+
# Body content is the component's default slot (the block passed to
|
|
15
|
+
# `render`). The footer -- typically a Cancel/Confirm button pair -- is a
|
|
16
|
+
# `renders_one :footer` slot, since ViewComponent's slot API is the
|
|
17
|
+
# simplest correct way to give this component two independent content
|
|
18
|
+
# areas without inventing a bespoke keyword-based content API.
|
|
19
|
+
#
|
|
20
|
+
# Opening the dialog is the host app's concern: either call
|
|
21
|
+
# `document.getElementById("<id>").showModal()` directly, or wire a
|
|
22
|
+
# trigger element with `data-action="click->atomic-view--modal#open"`
|
|
23
|
+
# (Stimulus resolves the action against the nearest matching controller,
|
|
24
|
+
# which works from inside the dialog's own footer, e.g. a "Cancel"
|
|
25
|
+
# button using `#close`, but not from an unrelated part of the page --
|
|
26
|
+
# see the preview for both patterns).
|
|
27
|
+
class ModalComponent < AtomicView::Component
|
|
28
|
+
renders_one :footer
|
|
29
|
+
|
|
30
|
+
attr_reader :id, :title, :danger
|
|
31
|
+
|
|
32
|
+
def initialize(id:, title: nil, danger: false, **options)
|
|
33
|
+
super()
|
|
34
|
+
@id = id
|
|
35
|
+
@title = title
|
|
36
|
+
@danger = danger
|
|
37
|
+
@options = options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def html_options
|
|
41
|
+
@options.except(:class, :data)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def html_class
|
|
45
|
+
class_names("m-auto bg-surface text-foreground rounded-card shadow-panel p-0 backdrop:bg-backdrop", @options[:class])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def data_attributes
|
|
49
|
+
(@options[:data] || {}).merge(controller: "atomic-view--modal")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def danger_icon
|
|
53
|
+
icon("exclamation-triangle", options: {class: "size-6 text-error"}).to_s.html_safe
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class MonthFieldComponent < ViewComponent::Form::MonthFieldComponent
|
|
4
|
+
def call
|
|
5
|
+
render FieldComponent.new(
|
|
6
|
+
form,
|
|
7
|
+
object_name,
|
|
8
|
+
method_name,
|
|
9
|
+
options,
|
|
10
|
+
ActionView::Helpers::Tags::MonthField
|
|
11
|
+
)
|
|
12
|
+
end
|
|
4
13
|
end
|
|
5
14
|
end
|
|
6
15
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class NavItemComponent < AtomicView::Component
|
|
6
|
+
attr_reader :label, :href, :icon_name, :active
|
|
7
|
+
|
|
8
|
+
def initialize(label:, href:, icon: nil, active: false, **options)
|
|
9
|
+
super()
|
|
10
|
+
@label = label
|
|
11
|
+
@href = href
|
|
12
|
+
@icon_name = icon
|
|
13
|
+
@active = active
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def html_class
|
|
20
|
+
class_names(base_classes, active ? active_classes : inactive_classes, @options[:class])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def base_classes
|
|
24
|
+
"flex items-center gap-2 rounded-btn px-2.5 py-1.5 text-sm font-medium"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def active_classes
|
|
28
|
+
"bg-secondary text-secondary-foreground"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inactive_classes
|
|
32
|
+
"text-muted-foreground hover:bg-offset hover:text-foreground"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<nav class="flex items-center gap-1" aria-label="Pagination">
|
|
2
|
+
<% if first_page? %>
|
|
3
|
+
<span class="<%= disabled_control_classes %>" aria-disabled="true">Previous</span>
|
|
4
|
+
<% else %>
|
|
5
|
+
<%= link_to "Previous", path_for(current_page - 1), class: control_classes %>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<div class="flex items-center gap-1">
|
|
9
|
+
<% page_numbers.each do |page| %>
|
|
10
|
+
<%= link_to page, path_for(page), **page_link_attributes(page) %>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<% if last_page? %>
|
|
15
|
+
<span class="<%= disabled_control_classes %>" aria-disabled="true">Next</span>
|
|
16
|
+
<% else %>
|
|
17
|
+
<%= link_to "Next", path_for(current_page + 1), class: control_classes %>
|
|
18
|
+
<% end %>
|
|
19
|
+
</nav>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Pagination
|
|
6
|
+
#
|
|
7
|
+
# Plain <a href> pagination -- no JS/Turbo/AJAX. `path_for_page` is a
|
|
8
|
+
# Proc/lambda supplied by the consumer (e.g. ->(page) { parks_path(page: page) })
|
|
9
|
+
# so this component stays router/pagination-gem agnostic.
|
|
10
|
+
#
|
|
11
|
+
# The prev/next control on a boundary (page 1's "Previous", the last
|
|
12
|
+
# page's "Next") renders as a <span> rather than a link, since there is
|
|
13
|
+
# no page to navigate to and a "disabled" <a href> would still be
|
|
14
|
+
# clickable/focusable in most browsers -- a plain non-interactive span is
|
|
15
|
+
# the simpler, unambiguous option.
|
|
16
|
+
class PaginationComponent < AtomicView::Component
|
|
17
|
+
include AtomicView::Components::Concerns::ButtonVariants
|
|
18
|
+
|
|
19
|
+
attr_reader :current_page, :total_pages, :path_for_page
|
|
20
|
+
|
|
21
|
+
def initialize(current_page:, total_pages:, path_for_page:)
|
|
22
|
+
super()
|
|
23
|
+
@current_page = current_page
|
|
24
|
+
@total_pages = total_pages
|
|
25
|
+
@path_for_page = path_for_page
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def page_numbers
|
|
29
|
+
(1..total_pages).to_a
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def first_page?
|
|
33
|
+
current_page <= 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def last_page?
|
|
37
|
+
current_page >= total_pages
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def path_for(page)
|
|
41
|
+
path_for_page.call(page)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def control_classes
|
|
45
|
+
class_names(base_classes, variant_classes)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def disabled_control_classes
|
|
49
|
+
class_names(control_classes, "pointer-events-none opacity-50")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def page_link_classes(page)
|
|
53
|
+
class_names(page_base_classes, (page == current_page) ? active_page_classes : inactive_page_classes)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def page_link_attributes(page)
|
|
57
|
+
attributes = {class: page_link_classes(page)}
|
|
58
|
+
attributes[:"aria-current"] = "page" if page == current_page
|
|
59
|
+
attributes
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def variant
|
|
65
|
+
:outline
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def page_base_classes
|
|
69
|
+
"flex size-7 items-center justify-center rounded-btn text-sm font-medium"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def active_page_classes
|
|
73
|
+
"bg-secondary text-secondary-foreground"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def inactive_page_classes
|
|
77
|
+
"text-muted-foreground hover:bg-offset"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module AtomicView
|
|
2
2
|
module Components
|
|
3
3
|
class RadioButtonComponent < ViewComponent::Form::RadioButtonComponent
|
|
4
|
+
def html_class
|
|
5
|
+
"peer border-input dark:bg-input/30 checked:bg-primary checked:text-primary-foreground dark:checked:bg-primary checked:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-full border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
|
|
6
|
+
end
|
|
4
7
|
end
|
|
5
8
|
end
|
|
6
9
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%= tag.div(**@html_options.except(:class), class: track_class) do %>
|
|
2
|
+
<% if link_mode? %>
|
|
3
|
+
<% options.each do |option| %>
|
|
4
|
+
<%= link_to option[:label], option[:href], **link_attributes(option) %>
|
|
5
|
+
<% end %>
|
|
6
|
+
<% else %>
|
|
7
|
+
<% options.each do |option| %>
|
|
8
|
+
<%= tag.span(class: "contents") do %>
|
|
9
|
+
<%= tag.input(**radio_attributes(option)) %>
|
|
10
|
+
<%= tag.label(option[:label], for: radio_id(option), class: label_classes(option)) %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# SegmentedControl
|
|
6
|
+
#
|
|
7
|
+
# Renders a "pill track" of mutually-exclusive segments. Which of two
|
|
8
|
+
# modes is used is inferred from the shape of `options`:
|
|
9
|
+
#
|
|
10
|
+
# - Link mode: options are {label:, href:} hashes, rendered as <a> tags.
|
|
11
|
+
# Meant for plain filter tabs where the "selected" state is just the
|
|
12
|
+
# current request's query string, not a form value (e.g. tabs above a
|
|
13
|
+
# table driven by `?filter=`). `selected` is compared against each
|
|
14
|
+
# option's `label:` rather than its `href:` -- the consumer already
|
|
15
|
+
# has the current filter's display value in hand (e.g.
|
|
16
|
+
# `params[:filter]`), so matching on `label:` avoids making every
|
|
17
|
+
# caller build a comparable canonical href just to find the active
|
|
18
|
+
# tab.
|
|
19
|
+
#
|
|
20
|
+
# - Radio mode: options are {label:, value:} hashes, rendered as native
|
|
21
|
+
# <input type="radio"> + <label> pairs so the control can participate
|
|
22
|
+
# in a real form submission. `name:` is required in this mode and
|
|
23
|
+
# becomes every radio's `name` attribute. `selected` is compared
|
|
24
|
+
# against each option's `value:`. The checked state is styled purely
|
|
25
|
+
# with CSS: each radio gets a `peer` marker class (hidden via
|
|
26
|
+
# `sr-only`) and its label uses `peer-checked:` utilities -- the same
|
|
27
|
+
# technique `check_box_component.rb` and `radio_button_component.rb`
|
|
28
|
+
# use for their checked states, just applied to a sibling label
|
|
29
|
+
# instead of the input itself, since the checked state lives on the
|
|
30
|
+
# input.
|
|
31
|
+
#
|
|
32
|
+
# Both modes share the same visual: an inline-flex, rounded, muted
|
|
33
|
+
# track (`bg-offset`) with the active/checked segment raised via
|
|
34
|
+
# `bg-surface` + `shadow-sm`.
|
|
35
|
+
class SegmentedControlComponent < AtomicView::Component
|
|
36
|
+
attr_reader :options, :selected, :name
|
|
37
|
+
|
|
38
|
+
def initialize(options:, selected:, name: nil, **html_options)
|
|
39
|
+
super()
|
|
40
|
+
@options = options
|
|
41
|
+
@selected = selected
|
|
42
|
+
@name = name
|
|
43
|
+
@html_options = html_options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def link_mode?
|
|
47
|
+
options.first&.key?(:href)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def active?(option)
|
|
51
|
+
link_mode? ? (option[:label] == selected) : (option[:value] == selected)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def track_class
|
|
55
|
+
class_names("inline-flex items-center gap-1 rounded-btn bg-offset p-1", @html_options[:class])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def link_attributes(option)
|
|
59
|
+
attributes = {class: segment_classes(option)}
|
|
60
|
+
attributes[:"aria-current"] = "true" if active?(option)
|
|
61
|
+
attributes
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def radio_id(option)
|
|
65
|
+
"#{name}_#{option[:value]}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def radio_attributes(option)
|
|
69
|
+
{
|
|
70
|
+
type: "radio",
|
|
71
|
+
name: name,
|
|
72
|
+
value: option[:value],
|
|
73
|
+
id: radio_id(option),
|
|
74
|
+
checked: active?(option),
|
|
75
|
+
class: "peer sr-only"
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def label_classes(_option)
|
|
80
|
+
class_names(segment_base_classes, "cursor-pointer peer-checked:bg-surface peer-checked:text-foreground peer-checked:shadow-sm", inactive_classes)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def segment_classes(option)
|
|
86
|
+
class_names(segment_base_classes, active?(option) ? active_classes : inactive_classes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def segment_base_classes
|
|
90
|
+
"rounded-btn px-2.5 py-1 text-sm font-medium transition-colors"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def active_classes
|
|
94
|
+
"bg-surface text-foreground shadow-sm"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inactive_classes
|
|
98
|
+
"text-muted-foreground"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -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
|