atomic_view 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/javascripts/atomic_view/controllers/auto_submit_controller.js +30 -0
- data/app/assets/javascripts/atomic_view/controllers/searchable_select_controller.js +65 -0
- data/lib/atomic_view/components/chip_component.rb +73 -4
- data/lib/atomic_view/components/collection_select_component.html.erb +15 -1
- data/lib/atomic_view/components/collection_select_component.rb +4 -2
- data/lib/atomic_view/components/concerns/searchable.rb +47 -0
- data/lib/atomic_view/components/filters_component.html.erb +23 -0
- data/lib/atomic_view/components/filters_component.rb +132 -0
- data/lib/atomic_view/components/select_component.html.erb +15 -1
- data/lib/atomic_view/components/select_component.rb +4 -2
- data/lib/atomic_view/components/table_component/cell_component.html.erb +1 -1
- data/lib/atomic_view/components/table_component/cell_component.rb +15 -7
- data/lib/atomic_view/components/table_component.rb +32 -18
- data/lib/atomic_view/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38ae2486d6b3e69c8668b5cc8b1c8ac229e8af46f2bfa8c0217c24b6a9ba0d4e
|
|
4
|
+
data.tar.gz: 28f821334d300ed260178324a905541230a944ca0d32763a7d031ab302f4396f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11e8dd2b1b6c2de279a77e819b834e1ab742be92e1c48b61586ec8944eadb1e1fe7587e3e34ab6d85ac13121c0e3dbb3f099c62b6db2b7054e5e7f2d9c8fe2c4
|
|
7
|
+
data.tar.gz: 74391beffa39c72a0ca5bccd400039d704ae773d5e57dc9a7696a493a825aa1f57efb00c6d959f26dac809d36a2b7c881a7112d71a09c83d156d9d4d9be2898e
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Debounced `requestSubmit()` for a filter form -- wired once on the
|
|
4
|
+
// `<form>` itself (see FiltersComponent), not per-field, since `input`/
|
|
5
|
+
// `change` events from any native form control (a text field, a select, a
|
|
6
|
+
// radio -- including a ChipComponent radio dropped into `with_chip`)
|
|
7
|
+
// bubble up to the form regardless of where they originate.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static values = {delay: {type: Number, default: 150}}
|
|
10
|
+
|
|
11
|
+
initialize() {
|
|
12
|
+
this.submit = this.submit.bind(this)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
if (this.delayValue > 0) this.submit = this.debounce(this.submit, this.delayValue)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
submit() {
|
|
20
|
+
this.element.requestSubmit()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
debounce(fn, delay) {
|
|
24
|
+
let timeout
|
|
25
|
+
return (...args) => {
|
|
26
|
+
clearTimeout(timeout)
|
|
27
|
+
timeout = setTimeout(() => fn.apply(this, args), delay)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Progressively enhances a real, hidden <select> (see the `searchable`
|
|
4
|
+
// concern in select_component.rb/collection_select_component.rb) into a
|
|
5
|
+
// trigger + search + list combobox. Reads the select's own <option>
|
|
6
|
+
// elements to build the list -- there's no separate Ruby-side data source
|
|
7
|
+
// to duplicate or keep in sync with `selected`/`include_blank`/etc.
|
|
8
|
+
// Picking an item writes back into the real select's value and dispatches
|
|
9
|
+
// a bubbling `change`, so it participates in a real form submission (e.g.
|
|
10
|
+
// FiltersComponent's form-level auto-submit) exactly like a plain select.
|
|
11
|
+
//
|
|
12
|
+
// Paired with `atomic-view--dropdown` on the same element for open/close,
|
|
13
|
+
// positioning, and outside-click/Esc handling -- this controller only
|
|
14
|
+
// owns the list content and the select/label sync.
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
static targets = ["select", "input", "label", "list"]
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
this.buildList()
|
|
20
|
+
this.syncLabel()
|
|
21
|
+
this.selectTarget.addEventListener("change", this.syncLabel)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
this.selectTarget.removeEventListener("change", this.syncLabel)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
buildList() {
|
|
29
|
+
this.listTarget.innerHTML = ""
|
|
30
|
+
|
|
31
|
+
Array.from(this.selectTarget.options).forEach((option) => {
|
|
32
|
+
const button = document.createElement("button")
|
|
33
|
+
button.type = "button"
|
|
34
|
+
button.textContent = option.text
|
|
35
|
+
button.dataset.searchText = option.text.toLowerCase()
|
|
36
|
+
button.className = "flex w-full items-center gap-2.5 rounded-well px-2 py-2 text-sm text-foreground hover:bg-offset"
|
|
37
|
+
button.addEventListener("click", () => this.choose(option))
|
|
38
|
+
this.listTarget.appendChild(button)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
choose(option) {
|
|
43
|
+
this.selectTarget.value = option.value
|
|
44
|
+
this.selectTarget.dispatchEvent(new Event("change", {bubbles: true}))
|
|
45
|
+
this.syncLabel()
|
|
46
|
+
this.dropdownController?.close()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
filter() {
|
|
50
|
+
const query = this.inputTarget.value.trim().toLowerCase()
|
|
51
|
+
|
|
52
|
+
this.listTarget.querySelectorAll("button").forEach((button) => {
|
|
53
|
+
button.classList.toggle("hidden", !button.dataset.searchText.includes(query))
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
syncLabel = () => {
|
|
58
|
+
const selected = this.selectTarget.options[this.selectTarget.selectedIndex]
|
|
59
|
+
this.labelTarget.textContent = selected ? selected.text : ""
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get dropdownController() {
|
|
63
|
+
return this.application.getControllerForElementAndIdentifier(this.element, "atomic-view--dropdown")
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -10,27 +10,96 @@ module AtomicView
|
|
|
10
10
|
# Pass `dismiss_button_options` only to customize that behavior, e.g. to
|
|
11
11
|
# trigger a network request instead of (or in addition to) the default
|
|
12
12
|
# remove animation.
|
|
13
|
+
#
|
|
14
|
+
# A chip can also be *selectable* rather than dismissible -- for a row
|
|
15
|
+
# of filter options (e.g. status: All/Active/Completed) rendered as
|
|
16
|
+
# individual pills rather than a joined SegmentedControlComponent
|
|
17
|
+
# track. Two selectable modes, matching SegmentedControlComponent's own
|
|
18
|
+
# link/radio split:
|
|
19
|
+
#
|
|
20
|
+
# - `href:` renders an `<a>` -- plain navigation, for a page not inside
|
|
21
|
+
# an auto-submitting form. `selected:` drives the active styling.
|
|
22
|
+
# - `name:` (+ `value:`, `selected:`) renders a real
|
|
23
|
+
# `<input type="radio"> + <label>` pair, styled identically, so it
|
|
24
|
+
# participates in a real form submission -- e.g. dropped straight
|
|
25
|
+
# into FiltersComponent's `with_chip`, where the radio's native
|
|
26
|
+
# `change` event bubbles to the form's auto-submit action with no
|
|
27
|
+
# extra wiring.
|
|
28
|
+
#
|
|
29
|
+
# `dismissible:` only applies to the plain (non-selectable) mode -- a
|
|
30
|
+
# filter pill is "unselected" by picking a different one, not by its
|
|
31
|
+
# own close button.
|
|
32
|
+
#
|
|
33
|
+
# The selected treatment is additive (`ring-2 ring-current`), not a
|
|
34
|
+
# bg/text override -- a caller coloring different values differently
|
|
35
|
+
# (e.g. a status chip carrying `class: "text-success"`) keeps that
|
|
36
|
+
# color when selected, with `ring-current` picking it up automatically
|
|
37
|
+
# for the emphasis ring, rather than every selected chip flattening to
|
|
38
|
+
# the same neutral look regardless of its own color.
|
|
13
39
|
class ChipComponent < AtomicView::Component
|
|
14
|
-
attr_reader :dismissible, :dismiss_button_options, :leading
|
|
40
|
+
attr_reader :dismissible, :dismiss_button_options, :leading, :href, :name, :value, :selected
|
|
15
41
|
|
|
16
|
-
def initialize(dismissible: false, dismiss_button_options: {}, leading: nil, **options)
|
|
42
|
+
def initialize(dismissible: false, dismiss_button_options: {}, leading: nil, href: nil, name: nil, value: nil, selected: false, **options)
|
|
17
43
|
super()
|
|
18
44
|
@dismissible = dismissible
|
|
19
45
|
@dismiss_button_options = dismiss_button_options
|
|
20
46
|
@leading = leading
|
|
47
|
+
@href = href
|
|
48
|
+
@name = name
|
|
49
|
+
@value = value
|
|
50
|
+
@selected = selected
|
|
21
51
|
@options = options
|
|
22
52
|
end
|
|
23
53
|
|
|
24
54
|
def call
|
|
55
|
+
return radio_chip if name.present?
|
|
56
|
+
return link_chip if href.present?
|
|
57
|
+
|
|
58
|
+
span_chip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def span_chip
|
|
25
64
|
tag.span(**@options.except(:class, :data), class: class_names(base_classes, @options[:class]), data: data_attributes) do
|
|
26
65
|
safe_join([leading, content, dismiss_button].compact)
|
|
27
66
|
end
|
|
28
67
|
end
|
|
29
68
|
|
|
30
|
-
|
|
69
|
+
def link_chip
|
|
70
|
+
link_to(href, **@options.except(:class), class: class_names(base_classes, selected_classes, @options[:class]), aria: {current: (selected ? "true" : nil)}) do
|
|
71
|
+
safe_join([leading, content].compact)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Wrapped in a `display: contents` span -- Tailwind's `peer-checked:`
|
|
76
|
+
# relies on the CSS general sibling combinator (`~`), which matches
|
|
77
|
+
# *every* later sibling under the same parent once a peer is
|
|
78
|
+
# checked, not just the one meant to pair with it. Rendered as flat
|
|
79
|
+
# siblings (as `with_chip`'s multiple chips are), checking any one
|
|
80
|
+
# radio would light up every chip after it. This span isolates each
|
|
81
|
+
# input/label pair's sibling relationship to just the two of them --
|
|
82
|
+
# `display: contents` keeps it invisible to layout, same technique
|
|
83
|
+
# SegmentedControlComponent's own radio mode already uses.
|
|
84
|
+
def radio_chip
|
|
85
|
+
tag.span(class: "contents") do
|
|
86
|
+
safe_join([
|
|
87
|
+
tag.input(**@options.except(:class, :id), type: "radio", name: name, value: value, id: radio_id, checked: selected, class: "peer sr-only"),
|
|
88
|
+
tag.label(safe_join([leading, content].compact), for: radio_id, class: class_names(base_classes, "cursor-pointer peer-checked:ring-2 peer-checked:ring-current peer-checked:font-semibold", @options[:class]))
|
|
89
|
+
])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def radio_id
|
|
94
|
+
"#{name}_#{value}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def selected_classes
|
|
98
|
+
"ring-2 ring-current font-semibold" if selected
|
|
99
|
+
end
|
|
31
100
|
|
|
32
101
|
def base_classes
|
|
33
|
-
"inline-flex items-center gap-1 rounded-
|
|
102
|
+
"inline-flex items-center gap-1 rounded-btn bg-offset px-2 py-0.5 text-xs font-medium text-foreground border border-border"
|
|
34
103
|
end
|
|
35
104
|
|
|
36
105
|
def data_attributes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= content_tag :div, class: container_html_class do %>
|
|
1
|
+
<%= content_tag :div, class: container_html_class, data: searchable_container_data do %>
|
|
2
2
|
<% if left_section? %>
|
|
3
3
|
<% if left_section_addon? %>
|
|
4
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>
|
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
<% end %>
|
|
12
12
|
<% end %>
|
|
13
13
|
<%= select_tag %>
|
|
14
|
+
<% if searchable? %>
|
|
15
|
+
<button type="button" data-atomic-view--dropdown-target="trigger" data-action="click->atomic-view--dropdown#toggle" class="<%= field_chrome_classes(size: :sm) %> flex items-center justify-between gap-2 text-left">
|
|
16
|
+
<span data-atomic-view--searchable-select-target="label" class="truncate"></span>
|
|
17
|
+
<%= icon "chevron-down", variant: :micro, options: {class: "text-muted-foreground shrink-0"} %>
|
|
18
|
+
</button>
|
|
19
|
+
|
|
20
|
+
<div data-atomic-view--dropdown-target="menu" class="hidden absolute z-50 w-full bg-surface border border-border rounded-card shadow-panel p-1">
|
|
21
|
+
<div class="relative px-1 pb-1.5 pt-1">
|
|
22
|
+
<%= icon "magnifying-glass", variant: :micro, options: {class: "pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground"} %>
|
|
23
|
+
<input type="search" autocomplete="off" data-atomic-view--searchable-select-target="input" data-action="input->atomic-view--searchable-select#filter" class="w-full rounded-well border border-input bg-surface py-1.5 pl-8 pr-2 text-sm text-foreground placeholder:text-placeholder focus:border-ring focus:outline-none">
|
|
24
|
+
</div>
|
|
25
|
+
<div data-atomic-view--searchable-select-target="list" class="max-h-64 overflow-y-auto"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
14
28
|
<% if right_section? %>
|
|
15
29
|
<% if right_section_addon? %>
|
|
16
30
|
<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>
|
|
@@ -3,6 +3,7 @@ module AtomicView
|
|
|
3
3
|
class CollectionSelectComponent < ViewComponent::Form::CollectionSelectComponent
|
|
4
4
|
include AtomicView::Components::Concerns::FieldChrome
|
|
5
5
|
include AtomicView::Components::Concerns::SectionSupport
|
|
6
|
+
include AtomicView::Components::Concerns::Searchable
|
|
6
7
|
|
|
7
8
|
def html_class
|
|
8
9
|
class_names(
|
|
@@ -10,7 +11,8 @@ module AtomicView
|
|
|
10
11
|
"pl-10" => left_section? && !(left_section_addon? || left_section_interaction?),
|
|
11
12
|
"pr-10" => right_section? && !(right_section_addon? || right_section_interaction?),
|
|
12
13
|
"shadow-none rounded-none rounded-r-btn ring-inset" => left_section_addon? || left_section_interaction?,
|
|
13
|
-
"shadow-none rounded-none rounded-l-btn ring-inset" => right_section_addon? || right_section_interaction
|
|
14
|
+
"shadow-none rounded-none rounded-l-btn ring-inset" => right_section_addon? || right_section_interaction?,
|
|
15
|
+
"sr-only" => searchable?
|
|
14
16
|
)
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -23,7 +25,7 @@ module AtomicView
|
|
|
23
25
|
value_method,
|
|
24
26
|
text_method,
|
|
25
27
|
options,
|
|
26
|
-
|
|
28
|
+
searchable_html_options
|
|
27
29
|
).render
|
|
28
30
|
end
|
|
29
31
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
module Concerns
|
|
6
|
+
# Shared by SelectComponent/CollectionSelectComponent: `searchable:
|
|
7
|
+
# true` progressively enhances the real, native `<select>` (kept in
|
|
8
|
+
# the DOM -- visually hidden, but still the actual form value) with a
|
|
9
|
+
# trigger + search + list combobox on top, wired to
|
|
10
|
+
# `atomic-view--searchable-select`. That controller reads the
|
|
11
|
+
# select's own `<option>` elements to build its list -- there's no
|
|
12
|
+
# separate Ruby-side data source to keep in sync -- and writes back
|
|
13
|
+
# into the select's value (dispatching `change`) when an option is
|
|
14
|
+
# picked, so it participates in a real form submission exactly like
|
|
15
|
+
# a plain select (e.g. FiltersComponent's form-level auto-submit)
|
|
16
|
+
# with no special-casing.
|
|
17
|
+
module Searchable
|
|
18
|
+
def searchable?
|
|
19
|
+
options[:searchable].present?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `html_options` (the real <select>'s raw HTML attributes, kept
|
|
23
|
+
# separate from `options` by ViewComponent::Form::SelectComponent)
|
|
24
|
+
# needs the target attribute merged in only when searchable --
|
|
25
|
+
# every select_tag override should render with this instead of
|
|
26
|
+
# the raw `html_options`.
|
|
27
|
+
def searchable_html_options
|
|
28
|
+
return html_options unless searchable?
|
|
29
|
+
|
|
30
|
+
html_options.merge(data: (html_options[:data] || {}).merge("atomic-view--searchable-select-target" => "select"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The searchable UI's controllers need to live on the same
|
|
34
|
+
# container the real <select> is already inside of (so it's a
|
|
35
|
+
# valid Stimulus target scope) -- that's `container_html_class`'s
|
|
36
|
+
# div (from SectionSupport), already `position: relative` for
|
|
37
|
+
# icon-section positioning, reused here as the dropdown's anchor
|
|
38
|
+
# too rather than introducing another wrapping element.
|
|
39
|
+
def searchable_container_data
|
|
40
|
+
return {} unless searchable?
|
|
41
|
+
|
|
42
|
+
{controller: "atomic-view--dropdown atomic-view--searchable-select"}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%= form_with(url: url, method: method, builder: AtomicView::FormBuilder, **html_options, class: html_class, data: data_attributes) do |form| %>
|
|
2
|
+
<% assign_form(form) %>
|
|
3
|
+
|
|
4
|
+
<% preserve.each do |key, value| %>
|
|
5
|
+
<%= hidden_field_tag(key, value) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<% chips.each do |chip| %>
|
|
9
|
+
<div class="flex flex-wrap items-center gap-2"><%= chip %></div>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<% if row? %>
|
|
13
|
+
<div class="flex flex-col gap-2 sm:flex-row items-stretch w-full">
|
|
14
|
+
<% if search? %>
|
|
15
|
+
<div class="sm:flex-[2]"><%= search %></div>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<% filters.each do |filter| %>
|
|
19
|
+
<div class="sm:flex-1"><%= filter %></div>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Filters
|
|
6
|
+
#
|
|
7
|
+
# The layout/boilerplate for an index page's filter bar: one
|
|
8
|
+
# auto-submitting `form_with`, three fixed slots (`with_chip`,
|
|
9
|
+
# `with_search`, `with_filter`), each always rendered in the same spot
|
|
10
|
+
# rather than a freeform block callers arrange by hand.
|
|
11
|
+
#
|
|
12
|
+
# <%= render(AtomicView::Components::FiltersComponent.new(url: rentals_path(format: :turbo_stream), preserve: {view: params[:view]})) do |filters| %>
|
|
13
|
+
# <% filters.with_chip do %>
|
|
14
|
+
# <% status_options.each do |status| %>
|
|
15
|
+
# <%= render(AtomicView::Components::ChipComponent.new(name: "status", value: status.value, selected: params[:status] == status.value)) { status.label } %>
|
|
16
|
+
# <% end %>
|
|
17
|
+
# <% end %>
|
|
18
|
+
#
|
|
19
|
+
# <% filters.with_search do |form| %>
|
|
20
|
+
# <%= form.search_field :q, value: params[:q], placeholder: "Search by camper..." %>
|
|
21
|
+
# <% end %>
|
|
22
|
+
#
|
|
23
|
+
# <% filters.with_filter do |form| %>
|
|
24
|
+
# <%= form.collection_select(:park_id, Park.all, :id, :name, include_blank: "All parks", selected: params[:park_id]) %>
|
|
25
|
+
# <% end %>
|
|
26
|
+
# <% end %>
|
|
27
|
+
#
|
|
28
|
+
# - `with_chip` (repeatable) -- content-only, one full-width row per
|
|
29
|
+
# call, above everything else, in declaration order. Each call
|
|
30
|
+
# represents one logical group of options (e.g. "status") -- the
|
|
31
|
+
# caller loops and renders one selectable `ChipComponent` per option
|
|
32
|
+
# inside it; a second, independent chip group is just a second
|
|
33
|
+
# `with_chip` call.
|
|
34
|
+
# - `with_search` (single) -- yields the shared form builder; fixed
|
|
35
|
+
# spot at the start of the second row, given more width than each
|
|
36
|
+
# `with_filter`.
|
|
37
|
+
# - `with_filter` (repeatable) -- yields the same form builder; renders
|
|
38
|
+
# after search, sharing the remaining width equally.
|
|
39
|
+
#
|
|
40
|
+
# The yielded "form" in `with_search`/`with_filter` isn't literally the
|
|
41
|
+
# `ActionView::Helpers::FormBuilder` -- it's a thin slot wrapper that
|
|
42
|
+
# delegates every method to the real form builder once FiltersComponent
|
|
43
|
+
# builds it (`form_with` doesn't exist yet when these blocks are
|
|
44
|
+
# captured), so `form.search_field`/`form.select`/`form.collection_select`
|
|
45
|
+
# all work exactly as if it were the real builder.
|
|
46
|
+
#
|
|
47
|
+
# Auto-submit is wired once on the `<form>` itself (`atomic-view--auto-submit`,
|
|
48
|
+
# listening for bubbled `input`/`change`), not per-field -- any native
|
|
49
|
+
# form control dropped into any slot, including a `ChipComponent` radio
|
|
50
|
+
# in `with_chip`, submits on change with no extra wiring.
|
|
51
|
+
#
|
|
52
|
+
# `preserve:` renders one hidden field per key/value, for page state
|
|
53
|
+
# that isn't itself a filter but must ride along on every submit (e.g.
|
|
54
|
+
# a `view:` toggle owned by a different control entirely).
|
|
55
|
+
class FiltersComponent < AtomicView::Component
|
|
56
|
+
# A stand-in for the real form builder, yielded into `with_search`/
|
|
57
|
+
# `with_filter` blocks before the real builder exists. FiltersComponent
|
|
58
|
+
# assigns the real builder onto every instance of this once `form_with`
|
|
59
|
+
# actually yields it -- see `#assign_form`.
|
|
60
|
+
class FieldSlot < AtomicView::Component
|
|
61
|
+
attr_accessor :form
|
|
62
|
+
|
|
63
|
+
def call
|
|
64
|
+
content
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def method_missing(name, ...)
|
|
68
|
+
return form.public_send(name, ...) if form.respond_to?(name)
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def respond_to_missing?(name, include_private = false)
|
|
73
|
+
form.respond_to?(name) || super
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ViewComponent::Base already includes ActionView's generic,
|
|
77
|
+
# object-argument Form(Options)Helper methods for template
|
|
78
|
+
# rendering -- e.g. a bare `select(object, method, choices, ...)`,
|
|
79
|
+
# not FormBuilder's per-attribute `form.select(method, choices,
|
|
80
|
+
# ...)`. Those real, inherited methods share names with the form
|
|
81
|
+
# builder's and would otherwise win over method_missing entirely,
|
|
82
|
+
# getting called with the wrong arity/meaning. Undefining every
|
|
83
|
+
# name the real form builder responds to forces all of them
|
|
84
|
+
# through method_missing above instead.
|
|
85
|
+
(AtomicView::FormBuilder.instance_methods - Object.instance_methods).each do |name|
|
|
86
|
+
undef_method(name) if method_defined?(name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
renders_many :chips
|
|
91
|
+
renders_one :search, FieldSlot
|
|
92
|
+
renders_many :filters, FieldSlot
|
|
93
|
+
|
|
94
|
+
attr_reader :url, :method, :preserve
|
|
95
|
+
|
|
96
|
+
def initialize(url:, method: :get, preserve: {}, **options)
|
|
97
|
+
super()
|
|
98
|
+
@url = url
|
|
99
|
+
@method = method
|
|
100
|
+
@preserve = preserve
|
|
101
|
+
@options = options
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def html_class
|
|
105
|
+
class_names("flex flex-col gap-3 w-full", @options[:class])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def html_options
|
|
109
|
+
@options.except(:class, :data)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def data_attributes
|
|
113
|
+
(@options[:data] || {}).merge(
|
|
114
|
+
controller: "atomic-view--auto-submit",
|
|
115
|
+
action: "input->atomic-view--auto-submit#submit change->atomic-view--auto-submit#submit"
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Called from the template once `form_with` actually yields the real
|
|
120
|
+
# builder -- pushes it onto every FieldSlot instance so `with_search`/
|
|
121
|
+
# `with_filter`'s already-captured blocks can delegate to it.
|
|
122
|
+
def assign_form(form)
|
|
123
|
+
search.form = form if search?
|
|
124
|
+
filters.each { |filter| filter.form = form }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def row?
|
|
128
|
+
search? || filters.any?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= content_tag :div, class: container_html_class do %>
|
|
1
|
+
<%= content_tag :div, class: container_html_class, data: searchable_container_data do %>
|
|
2
2
|
<% if left_section? %>
|
|
3
3
|
<% if left_section_addon? %>
|
|
4
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>
|
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
<% end %>
|
|
12
12
|
<% end %>
|
|
13
13
|
<%= select_tag %>
|
|
14
|
+
<% if searchable? %>
|
|
15
|
+
<button type="button" data-atomic-view--dropdown-target="trigger" data-action="click->atomic-view--dropdown#toggle" class="<%= field_chrome_classes(size: :sm) %> flex items-center justify-between gap-2 text-left">
|
|
16
|
+
<span data-atomic-view--searchable-select-target="label" class="truncate"></span>
|
|
17
|
+
<%= icon "chevron-down", variant: :micro, options: {class: "text-muted-foreground shrink-0"} %>
|
|
18
|
+
</button>
|
|
19
|
+
|
|
20
|
+
<div data-atomic-view--dropdown-target="menu" class="hidden absolute z-50 w-full bg-surface border border-border rounded-card shadow-panel p-1">
|
|
21
|
+
<div class="relative px-1 pb-1.5 pt-1">
|
|
22
|
+
<%= icon "magnifying-glass", variant: :micro, options: {class: "pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground"} %>
|
|
23
|
+
<input type="search" autocomplete="off" data-atomic-view--searchable-select-target="input" data-action="input->atomic-view--searchable-select#filter" class="w-full rounded-well border border-input bg-surface py-1.5 pl-8 pr-2 text-sm text-foreground placeholder:text-placeholder focus:border-ring focus:outline-none">
|
|
24
|
+
</div>
|
|
25
|
+
<div data-atomic-view--searchable-select-target="list" class="max-h-64 overflow-y-auto"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
14
28
|
<% if right_section? %>
|
|
15
29
|
<% if right_section_addon? %>
|
|
16
30
|
<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>
|
|
@@ -3,6 +3,7 @@ module AtomicView
|
|
|
3
3
|
class SelectComponent < ViewComponent::Form::SelectComponent
|
|
4
4
|
include AtomicView::Components::Concerns::FieldChrome
|
|
5
5
|
include AtomicView::Components::Concerns::SectionSupport
|
|
6
|
+
include AtomicView::Components::Concerns::Searchable
|
|
6
7
|
|
|
7
8
|
def html_class
|
|
8
9
|
class_names(
|
|
@@ -11,7 +12,8 @@ module AtomicView
|
|
|
11
12
|
"pl-10" => left_section? && !(left_section_addon? || left_section_interaction?),
|
|
12
13
|
"pr-10" => right_section? && !(right_section_addon? || right_section_interaction?),
|
|
13
14
|
"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
|
|
15
|
+
"shadow-none rounded-none rounded-l-btn ring-inset" => right_section_addon? || right_section_interaction?,
|
|
16
|
+
"sr-only" => searchable?
|
|
15
17
|
)
|
|
16
18
|
end
|
|
17
19
|
|
|
@@ -22,7 +24,7 @@ module AtomicView
|
|
|
22
24
|
@view_context,
|
|
23
25
|
choices,
|
|
24
26
|
options,
|
|
25
|
-
|
|
27
|
+
searchable_html_options,
|
|
26
28
|
&content
|
|
27
29
|
).render
|
|
28
30
|
end
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
<% if linked? %>
|
|
3
3
|
<%= link_to "", path, class: "absolute inset-0", tabindex: (first ? 0 : -1), "aria-hidden": (first ? nil : "true"), "aria-label": (first ? label : nil), data: {row_link: true} %>
|
|
4
4
|
<% end %>
|
|
5
|
-
<%= tag.span(content, class:
|
|
5
|
+
<%= tag.span(content, class: content_class) %>
|
|
6
6
|
<% end %>
|
|
@@ -10,21 +10,25 @@ module AtomicView
|
|
|
10
10
|
# design. `first` and `clickable` are set by RowComponent's `cells`
|
|
11
11
|
# slot from the row itself, not passed by the caller.
|
|
12
12
|
#
|
|
13
|
-
# Visible content renders as a
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
13
|
+
# Visible content renders as a sibling of the (empty, transparent)
|
|
14
|
+
# stretched `<a>`, wrapped in a `pointer-events-none` span so plain
|
|
15
|
+
# content (the common case -- a name, a badge) doesn't sit in the
|
|
16
|
+
# way of clicks meant for the row-wide overlay underneath it. Pass
|
|
17
|
+
# `interactive: true` on a cell that renders a real, more specific
|
|
18
|
+
# link/button of its own (e.g. an "Edit" action) to flip that span
|
|
19
|
+
# back to `pointer-events-auto`, so that cell's own content takes
|
|
20
|
+
# the click instead of falling through to the row link.
|
|
18
21
|
class CellComponent < AtomicView::Component
|
|
19
|
-
attr_reader :path, :label, :first, :align, :clickable
|
|
22
|
+
attr_reader :path, :label, :first, :align, :clickable, :interactive
|
|
20
23
|
|
|
21
|
-
def initialize(path:, label:, first:, align: :left, clickable: true, **options)
|
|
24
|
+
def initialize(path:, label:, first:, align: :left, clickable: true, interactive: false, **options)
|
|
22
25
|
super()
|
|
23
26
|
@path = path
|
|
24
27
|
@label = label
|
|
25
28
|
@first = first
|
|
26
29
|
@align = align
|
|
27
30
|
@clickable = clickable
|
|
31
|
+
@interactive = interactive
|
|
28
32
|
@options = options
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -40,6 +44,10 @@ module AtomicView
|
|
|
40
44
|
@options.except(:class)
|
|
41
45
|
end
|
|
42
46
|
|
|
47
|
+
def content_class
|
|
48
|
+
interactive ? "relative pointer-events-auto" : "relative pointer-events-none"
|
|
49
|
+
end
|
|
50
|
+
|
|
43
51
|
private
|
|
44
52
|
|
|
45
53
|
def base_classes
|
|
@@ -24,32 +24,37 @@ module AtomicView
|
|
|
24
24
|
#
|
|
25
25
|
# `with_column` is an opt-in alternative to hand-building the <thead>:
|
|
26
26
|
# give it at least one column and TableComponent renders the header row
|
|
27
|
-
# itself, from `<th>`s built by TableComponent::ColumnComponent --
|
|
28
|
-
#
|
|
29
|
-
#
|
|
27
|
+
# itself, from `<th>`s built by TableComponent::ColumnComponent -- and
|
|
28
|
+
# wraps the block's return value in a <tbody> for you too, so the block
|
|
29
|
+
# only has to render rows (typically TableComponent::RowComponent),
|
|
30
|
+
# same as it would for a turbo_stream append/update elsewhere:
|
|
30
31
|
#
|
|
31
32
|
# <%= render AtomicView::Components::TableComponent.new(model: Booking) do |table| %>
|
|
32
33
|
# <% table.with_column(attribute: :camper_name) %>
|
|
33
34
|
# <% table.with_column(attribute: :status, align: :center) %>
|
|
34
35
|
# <% table.with_column(label: "") %>
|
|
35
36
|
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# <% row.with_cell { render(LinkComponent.new(edit_booking_path(booking), variant: :outline)) { "Edit" } } %>
|
|
42
|
-
# <% end %>
|
|
37
|
+
# <% @bookings.each do |booking| %>
|
|
38
|
+
# <%= render(AtomicView::Components::TableComponent::RowComponent.new(record: booking, label: booking.camper_name, path: booking_path(booking))) do |row| %>
|
|
39
|
+
# <% row.with_cell { booking.camper_name } %>
|
|
40
|
+
# <% row.with_cell(align: :center) { render(BadgeComponent.new) { booking.status } } %>
|
|
41
|
+
# <% row.with_cell(interactive: true) { render(LinkComponent.new(edit_booking_path(booking), variant: :outline)) { "Edit" } } %>
|
|
43
42
|
# <% end %>
|
|
44
|
-
#
|
|
43
|
+
# <% end %>
|
|
45
44
|
# <% end %>
|
|
46
45
|
#
|
|
46
|
+
# The <tbody>'s `id` defaults to `model.model_name.plural` (e.g.
|
|
47
|
+
# "bookings") so a turbo_stream response can target it directly with
|
|
48
|
+
# no extra bookkeeping -- pass `body_id:` to override it (a nested
|
|
49
|
+
# resource sharing a differently-named frame, say).
|
|
50
|
+
#
|
|
47
51
|
# A table with no columns given renders exactly as before -- `with_column`
|
|
48
|
-
# is opt-in, not a second required API
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
52
|
+
# is opt-in, not a second required API, and in that raw mode you still
|
|
53
|
+
# own the whole `<thead>`/`<tbody>` yourself. `model:` is passed through
|
|
54
|
+
# to each column so `with_column(attribute: :camper_name)` can default
|
|
55
|
+
# its label via `model.human_attribute_name(:camper_name)`; a column
|
|
56
|
+
# with no backing attribute (like the blank "" header above, for an
|
|
57
|
+
# actions column) just passes `label:` directly instead.
|
|
53
58
|
class TableComponent < AtomicView::Component
|
|
54
59
|
HEAD_ROW_CLASSES = "text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b border-border"
|
|
55
60
|
BODY_ROW_CLASSES = "border-b border-border hover:bg-offset"
|
|
@@ -58,15 +63,20 @@ module AtomicView
|
|
|
58
63
|
|
|
59
64
|
attr_reader :model
|
|
60
65
|
|
|
61
|
-
def initialize(model: nil, **options)
|
|
66
|
+
def initialize(model: nil, body_id: nil, **options)
|
|
62
67
|
super()
|
|
63
68
|
@model = model
|
|
69
|
+
@body_id = body_id
|
|
64
70
|
@options = options
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
def call
|
|
68
74
|
tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class])) do
|
|
69
|
-
|
|
75
|
+
if columns?
|
|
76
|
+
safe_join([columns_header, tag.tbody(content, id: body_id)])
|
|
77
|
+
else
|
|
78
|
+
content
|
|
79
|
+
end
|
|
70
80
|
end
|
|
71
81
|
end
|
|
72
82
|
|
|
@@ -89,6 +99,10 @@ module AtomicView
|
|
|
89
99
|
columns.any?
|
|
90
100
|
end
|
|
91
101
|
|
|
102
|
+
def body_id
|
|
103
|
+
@body_id || model&.model_name&.plural
|
|
104
|
+
end
|
|
105
|
+
|
|
92
106
|
def base_classes
|
|
93
107
|
"w-full text-sm"
|
|
94
108
|
end
|
data/lib/atomic_view/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atomic_view
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joel Warrington
|
|
@@ -140,12 +140,14 @@ files:
|
|
|
140
140
|
- README.md
|
|
141
141
|
- Rakefile
|
|
142
142
|
- app/assets/config/atomic_view_manifest.js
|
|
143
|
+
- app/assets/javascripts/atomic_view/controllers/auto_submit_controller.js
|
|
143
144
|
- app/assets/javascripts/atomic_view/controllers/chip_controller.js
|
|
144
145
|
- app/assets/javascripts/atomic_view/controllers/command_palette_controller.js
|
|
145
146
|
- app/assets/javascripts/atomic_view/controllers/dropdown_controller.js
|
|
146
147
|
- app/assets/javascripts/atomic_view/controllers/gantt_controller.js
|
|
147
148
|
- app/assets/javascripts/atomic_view/controllers/hotkey_controller.js
|
|
148
149
|
- app/assets/javascripts/atomic_view/controllers/modal_controller.js
|
|
150
|
+
- app/assets/javascripts/atomic_view/controllers/searchable_select_controller.js
|
|
149
151
|
- app/assets/javascripts/atomic_view/controllers/theme_toggle_controller.js
|
|
150
152
|
- app/assets/javascripts/atomic_view/controllers/toast_controller.js
|
|
151
153
|
- app/assets/javascripts/atomic_view/controllers/tooltip_controller.js
|
|
@@ -192,6 +194,7 @@ files:
|
|
|
192
194
|
- lib/atomic_view/components/command_palette_component/section_component.rb
|
|
193
195
|
- lib/atomic_view/components/concerns/button_variants.rb
|
|
194
196
|
- lib/atomic_view/components/concerns/field_chrome.rb
|
|
197
|
+
- lib/atomic_view/components/concerns/searchable.rb
|
|
195
198
|
- lib/atomic_view/components/concerns/section_support.rb
|
|
196
199
|
- lib/atomic_view/components/date_field_component.rb
|
|
197
200
|
- lib/atomic_view/components/date_select_component.rb
|
|
@@ -204,6 +207,8 @@ files:
|
|
|
204
207
|
- lib/atomic_view/components/empty_state_component.rb
|
|
205
208
|
- lib/atomic_view/components/field_component.html.erb
|
|
206
209
|
- lib/atomic_view/components/field_component.rb
|
|
210
|
+
- lib/atomic_view/components/filters_component.html.erb
|
|
211
|
+
- lib/atomic_view/components/filters_component.rb
|
|
207
212
|
- lib/atomic_view/components/gantt_component.html.erb
|
|
208
213
|
- lib/atomic_view/components/gantt_component.rb
|
|
209
214
|
- lib/atomic_view/components/gantt_component/date_header_component.html.erb
|