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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/atomic_view/controllers/chip_controller.js +8 -0
  3. data/app/assets/javascripts/atomic_view/controllers/command_palette_controller.js +96 -0
  4. data/app/assets/javascripts/atomic_view/controllers/dropdown_controller.js +42 -0
  5. data/app/assets/javascripts/atomic_view/controllers/modal_controller.js +23 -0
  6. data/app/assets/javascripts/atomic_view/controllers/theme_toggle_controller.js +45 -0
  7. data/app/assets/javascripts/atomic_view/controllers/toast_controller.js +20 -0
  8. data/app/assets/stylesheets/atomic_view/application.tailwind.css +0 -2
  9. data/app/assets/tailwind/atomic_view/engine.css +90 -29
  10. data/config/importmap.rb +45 -1
  11. data/lib/atomic_view/component.rb +1 -0
  12. data/lib/atomic_view/components/alert_component.erb +15 -0
  13. data/lib/atomic_view/components/alert_component.rb +63 -0
  14. data/lib/atomic_view/components/avatar_component.rb +63 -0
  15. data/lib/atomic_view/components/badge_component.rb +38 -0
  16. data/lib/atomic_view/components/button_component.rb +2 -23
  17. data/lib/atomic_view/components/card_component.rb +29 -0
  18. data/lib/atomic_view/components/chip_component.rb +56 -0
  19. data/lib/atomic_view/components/collection_check_boxes_component.erb +3 -0
  20. data/lib/atomic_view/components/collection_check_boxes_component.rb +25 -0
  21. data/lib/atomic_view/components/collection_radio_buttons_component.erb +3 -0
  22. data/lib/atomic_view/components/collection_radio_buttons_component.rb +25 -0
  23. data/lib/atomic_view/components/collection_select_component.erb +25 -0
  24. data/lib/atomic_view/components/collection_select_component.rb +21 -4
  25. data/lib/atomic_view/components/color_field_component.rb +3 -0
  26. data/lib/atomic_view/components/command_palette_component.erb +41 -0
  27. data/lib/atomic_view/components/command_palette_component.rb +88 -0
  28. data/lib/atomic_view/components/concerns/button_variants.rb +34 -0
  29. data/lib/atomic_view/components/concerns/field_chrome.rb +21 -0
  30. data/lib/atomic_view/components/concerns/section_support.rb +67 -0
  31. data/lib/atomic_view/components/date_field_component.rb +3 -9
  32. data/lib/atomic_view/components/date_select_component.rb +9 -0
  33. data/lib/atomic_view/components/datetime_local_field_component.rb +9 -0
  34. data/lib/atomic_view/components/datetime_select_component.rb +9 -0
  35. data/lib/atomic_view/components/dropdown_component.erb +11 -0
  36. data/lib/atomic_view/components/dropdown_component.rb +57 -0
  37. data/lib/atomic_view/components/empty_state_component.erb +10 -0
  38. data/lib/atomic_view/components/empty_state_component.rb +28 -0
  39. data/lib/atomic_view/components/field_component.erb +3 -3
  40. data/lib/atomic_view/components/field_component.rb +6 -46
  41. data/lib/atomic_view/components/grouped_collection_select_component.rb +5 -0
  42. data/lib/atomic_view/components/kbd_component.rb +31 -0
  43. data/lib/atomic_view/components/label_component.rb +16 -1
  44. data/lib/atomic_view/components/modal_component.erb +24 -0
  45. data/lib/atomic_view/components/modal_component.rb +57 -0
  46. data/lib/atomic_view/components/month_field_component.rb +9 -0
  47. data/lib/atomic_view/components/nav_item_component.erb +6 -0
  48. data/lib/atomic_view/components/nav_item_component.rb +36 -0
  49. data/lib/atomic_view/components/pagination_component.erb +19 -0
  50. data/lib/atomic_view/components/pagination_component.rb +81 -0
  51. data/lib/atomic_view/components/radio_button_component.rb +3 -0
  52. data/lib/atomic_view/components/range_field_component.rb +3 -0
  53. data/lib/atomic_view/components/segmented_control_component.erb +14 -0
  54. data/lib/atomic_view/components/segmented_control_component.rb +102 -0
  55. data/lib/atomic_view/components/select_component.erb +25 -0
  56. data/lib/atomic_view/components/select_component.rb +21 -7
  57. data/lib/atomic_view/components/submit_component.rb +2 -23
  58. data/lib/atomic_view/components/table_component.rb +52 -0
  59. data/lib/atomic_view/components/tabs_component.erb +5 -0
  60. data/lib/atomic_view/components/tabs_component.rb +74 -0
  61. data/lib/atomic_view/components/text_area_component.rb +5 -0
  62. data/lib/atomic_view/components/theme_toggle_component.rb +74 -0
  63. data/lib/atomic_view/components/time_select_component.rb +9 -0
  64. data/lib/atomic_view/components/time_zone_select_component.rb +5 -0
  65. data/lib/atomic_view/components/timeline_component/item_component.erb +10 -0
  66. data/lib/atomic_view/components/timeline_component/item_component.rb +50 -0
  67. data/lib/atomic_view/components/timeline_component.erb +10 -0
  68. data/lib/atomic_view/components/timeline_component.rb +61 -0
  69. data/lib/atomic_view/components/toast_component.erb +14 -0
  70. data/lib/atomic_view/components/toast_component.rb +68 -0
  71. data/lib/atomic_view/components/week_field_component.rb +9 -0
  72. data/lib/atomic_view/components/weekday_select_component.rb +5 -0
  73. data/lib/atomic_view/form_builder.rb +25 -0
  74. data/lib/atomic_view/version.rb +1 -1
  75. data/lib/atomic_view.rb +1 -0
  76. data/lib/generators/atomic_view/install_generator.rb +13 -12
  77. 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-lg bg-transparent dark:bg-white/5 ring-1 ring-ring/10 dark:ring-white/10 px-3 text-primary dark:text-white sm:text-sm"><%= left_section %></span>
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, @options).render %>
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-lg bg-transparent dark:bg-white/5 ring-1 ring-ring/10 dark:ring-white/10 px-3 text-primary dark:text-white sm:text-sm"><%= right_section %></span>
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
- "block w-full appearance-none h-9 min-w-0 z-10 flex-1 rounded-lg border-0 py-1 text-base shadow-xs ring-1",
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-lg" => left_section_addon? || left_section_interaction?,
19
- "shadow-none rounded-none rounded-l-lg" => right_section_addon? || right_section_interaction?,
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
- "block text-primary text-sm font-medium leading-6 mb-2"
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,6 @@
1
+ <%= tag.a(**@options.except(:class), href: href, class: html_class) do %>
2
+ <% if icon_name.present? %>
3
+ <%= icon(icon_name, options: {class: "size-4 shrink-0"}).to_s.html_safe %>
4
+ <% end %>
5
+ <%= label %>
6
+ <% 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
@@ -1,6 +1,9 @@
1
1
  module AtomicView
2
2
  module Components
3
3
  class RangeFieldComponent < ViewComponent::Form::RangeFieldComponent
4
+ def html_class
5
+ "accent-primary h-2 rounded-pill bg-offset"
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
- class_names(
7
- *%W[block w-full h-9 min-w-0 flex-1 rounded-md border-0 py-1 text-sm shadow-xs ring-1],
8
- "disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-foreground disabled:ring-disabled-ring",
9
- "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",
10
- "text-error ring-error-ring placeholder:text-error-placeholder focus:ring-error-focus-ring" => method_errors?
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