avo 4.0.22 → 4.0.24
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/Gemfile.lock +1 -1
- data/app/assets/builds/avo/application.css +44 -0
- data/app/assets/builds/avo/application.js +1 -1
- data/app/assets/builds/avo/application.js.map +3 -3
- data/app/assets/stylesheets/css/components/ui/dropdown.css +55 -0
- data/app/components/avo/u_i/dropdown_component.html.erb +15 -0
- data/app/components/avo/u_i/dropdown_component.rb +8 -0
- data/app/javascript/js/controllers/popover_menu_controller.js +51 -11
- data/lib/avo/engine.rb +17 -0
- data/lib/avo/plugin_manager.rb +40 -0
- data/lib/avo/version.rb +1 -1
- metadata +1 -1
|
@@ -60,6 +60,61 @@
|
|
|
60
60
|
@apply flex flex-col items-start self-stretch px-1;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/* ==========================================================================
|
|
64
|
+
Inline search (DropdownComponent searchable:)
|
|
65
|
+
A filter input pinned above the list; the popover-menu controller hides
|
|
66
|
+
non-matching items and reveals the empty state.
|
|
67
|
+
|
|
68
|
+
Styled as a command-palette header row (the familiar Linear/Raycast shape):
|
|
69
|
+
full-bleed input, the bottom border is the only field chrome, and the icon,
|
|
70
|
+
text, and empty state sit on the same column grid as the menu items below.
|
|
71
|
+
========================================================================== */
|
|
72
|
+
|
|
73
|
+
.dropdown-menu__search {
|
|
74
|
+
@apply self-stretch border-b border-tertiary;
|
|
75
|
+
/* Line the search row up with the items: icon starts where item icons do
|
|
76
|
+
(list px + item px), same icon size, same icon-to-text gap. The
|
|
77
|
+
search-input prefix position and input padding both derive from these. */
|
|
78
|
+
--input-icon-offset: --spacing(3);
|
|
79
|
+
--input-icon-size: --spacing(4);
|
|
80
|
+
--input-icon-gap: --spacing(1.5);
|
|
81
|
+
min-inline-size: 14rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.dropdown-menu__search .search-input__input {
|
|
85
|
+
@apply w-full h-9 rounded-none border-none bg-transparent text-sm text-content;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.dropdown-menu__search .search-input__input::placeholder {
|
|
89
|
+
color: var(--color-content-secondary);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* The input is auto-focused on open and the caret shows position — a boxy
|
|
93
|
+
ring around a full-bleed row reads as noise, not focus. */
|
|
94
|
+
.dropdown-menu__search .search-input__input:focus-visible {
|
|
95
|
+
outline: none;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Forced-colors mode still needs a visible focus indicator. */
|
|
99
|
+
@media (forced-colors: active) {
|
|
100
|
+
.dropdown-menu__search .search-input__input:focus-visible {
|
|
101
|
+
outline: 2px solid Highlight;
|
|
102
|
+
outline-offset: -2px;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Empty state text sits on the item-label column, under the input text. */
|
|
107
|
+
.dropdown-menu__search-empty {
|
|
108
|
+
@apply m-0 py-2 text-sm text-content-secondary;
|
|
109
|
+
padding-inline: calc(--spacing(3) + --spacing(4) + --spacing(1.5)) --spacing(3);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Filtered-out items are hidden with the `hidden` attribute; the item rules
|
|
113
|
+
below apply `display: flex`, which would otherwise win over the UA style. */
|
|
114
|
+
.dropdown-menu__list :is(a, button)[hidden] {
|
|
115
|
+
display: none;
|
|
116
|
+
}
|
|
117
|
+
|
|
63
118
|
/* The `a`/`button` selectors style real menu items, but a dropdown can also host
|
|
64
119
|
a `.dropdown-card` (see the `:has(.dropdown-card)` block above). Controls inside
|
|
65
120
|
that card — e.g. a footer action button — are not menu items, so exclude them and
|
|
@@ -9,11 +9,26 @@
|
|
|
9
9
|
class: class_names("popover-menu__panel", @classes),
|
|
10
10
|
data: {controller: "popover-menu"} do %>
|
|
11
11
|
<div class="dropdown-menu">
|
|
12
|
+
<% if @searchable %>
|
|
13
|
+
<div class="dropdown-menu__search search-input">
|
|
14
|
+
<input type="search"
|
|
15
|
+
class="search-input__input"
|
|
16
|
+
placeholder="<%= search_placeholder %>"
|
|
17
|
+
aria-label="<%= search_placeholder %>"
|
|
18
|
+
autocomplete="off"
|
|
19
|
+
data-popover-menu-target="searchInput"
|
|
20
|
+
data-action="input->popover-menu#filter">
|
|
21
|
+
<span class="search-input__prefix" aria-hidden="true"><%= helpers.svg "tabler/outline/search" %></span>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
12
24
|
<div class="dropdown-menu__group">
|
|
13
25
|
<div class="dropdown-menu__list">
|
|
14
26
|
<%= items %>
|
|
15
27
|
</div>
|
|
16
28
|
</div>
|
|
29
|
+
<% if @searchable %>
|
|
30
|
+
<p class="dropdown-menu__search-empty" data-popover-menu-target="empty" hidden>No matching options</p>
|
|
31
|
+
<% end %>
|
|
17
32
|
</div>
|
|
18
33
|
<% end %>
|
|
19
34
|
<% end %>
|
|
@@ -7,6 +7,10 @@ class Avo::UI::DropdownComponent < Avo::BaseComponent
|
|
|
7
7
|
prop :open, default: false
|
|
8
8
|
prop :dropdown_menu_classes, default: ""
|
|
9
9
|
prop :popover_mode, default: false
|
|
10
|
+
# Renders a filter input above the items that narrows them as the user types
|
|
11
|
+
# (client-side, over the rendered items). Popover mode only.
|
|
12
|
+
prop :searchable, default: false
|
|
13
|
+
prop :search_placeholder
|
|
10
14
|
|
|
11
15
|
renders_one :trigger
|
|
12
16
|
renders_one :items
|
|
@@ -21,6 +25,10 @@ class Avo::UI::DropdownComponent < Avo::BaseComponent
|
|
|
21
25
|
@popover_id ||= "popover-#{SecureRandom.hex(3)}"
|
|
22
26
|
end
|
|
23
27
|
|
|
28
|
+
def search_placeholder
|
|
29
|
+
@search_placeholder || I18n.t("avo.search.placeholder", default: "Search")
|
|
30
|
+
end
|
|
31
|
+
|
|
24
32
|
def data
|
|
25
33
|
return {} if items.blank?
|
|
26
34
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus'
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
+
static targets = ['searchInput', 'empty']
|
|
5
|
+
|
|
4
6
|
// Used by both onToggle (auto-focus) and handleKeydown (arrow navigation).
|
|
5
7
|
get focusableItems() {
|
|
6
8
|
return [...this.element.querySelectorAll('a, button')].filter(
|
|
@@ -25,9 +27,19 @@ export default class extends Controller {
|
|
|
25
27
|
onToggle(event) {
|
|
26
28
|
if (event.newState !== 'open') return
|
|
27
29
|
|
|
30
|
+
// Each open starts unfiltered — a leftover query from the last visit would
|
|
31
|
+
// silently hide items.
|
|
32
|
+
if (this.hasSearchInputTarget) this.resetFilter()
|
|
33
|
+
|
|
28
34
|
// requestAnimationFrame ensures the popover is fully rendered before we focus.
|
|
29
35
|
// Focusing before the browser paints causes the scroll to jump in some cases.
|
|
30
36
|
requestAnimationFrame(() => {
|
|
37
|
+
// A searchable menu hands focus to its filter input so the user can just type.
|
|
38
|
+
if (this.hasSearchInputTarget) {
|
|
39
|
+
this.searchInputTarget.focus()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
const items = this.focusableItems
|
|
32
44
|
if (items.length === 0) return
|
|
33
45
|
|
|
@@ -38,24 +50,46 @@ export default class extends Controller {
|
|
|
38
50
|
})
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
// Narrows the rendered items to those whose text matches the query. Items are
|
|
54
|
+
// queried at call time (not cached) so lazily loaded content — e.g. a
|
|
55
|
+
// turbo-frame inside the list — is filterable as soon as it arrives. An item
|
|
56
|
+
// carrying data-filter-text matches on that instead of its full text, so
|
|
57
|
+
// decorations (timestamps, tags) don't count as matches.
|
|
58
|
+
filter() {
|
|
59
|
+
const query = this.searchInputTarget.value.trim().toLowerCase()
|
|
60
|
+
let anyVisible = false
|
|
61
|
+
|
|
62
|
+
this.element.querySelectorAll('.dropdown-menu__list a, .dropdown-menu__list button').forEach((item) => {
|
|
63
|
+
const text = item.dataset.filterText ?? item.textContent
|
|
64
|
+
const match = query === '' || text.replace(/\s+/g, ' ').trim().toLowerCase().includes(query)
|
|
65
|
+
item.hidden = !match
|
|
66
|
+
if (match) anyVisible = true
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = anyVisible || query === ''
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
resetFilter() {
|
|
73
|
+
this.searchInputTarget.value = ''
|
|
74
|
+
this.filter()
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
handleKeydown(event) {
|
|
42
78
|
// The keydown listener is always attached, so guard against firing when closed.
|
|
43
79
|
if (!this.element.matches(':popover-open')) return
|
|
44
80
|
|
|
45
|
-
const items = this.focusableItems
|
|
46
|
-
if (items.length === 0) return
|
|
47
|
-
|
|
48
|
-
const idx = items.indexOf(document.activeElement)
|
|
49
|
-
|
|
50
81
|
switch (event.key) {
|
|
51
82
|
case 'ArrowDown':
|
|
83
|
+
case 'ArrowUp': {
|
|
84
|
+
const items = this.focusableItems
|
|
85
|
+
if (items.length === 0) return
|
|
86
|
+
|
|
52
87
|
event.preventDefault()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
event.preventDefault()
|
|
57
|
-
items[idx > 0 ? idx - 1 : items.length - 1].focus()
|
|
88
|
+
const idx = items.indexOf(document.activeElement)
|
|
89
|
+
if (event.key === 'ArrowDown') items[idx < items.length - 1 ? idx + 1 : 0].focus()
|
|
90
|
+
else items[idx > 0 ? idx - 1 : items.length - 1].focus()
|
|
58
91
|
break
|
|
92
|
+
}
|
|
59
93
|
case 'Escape':
|
|
60
94
|
// Close on Escape ourselves. Native popover light-dismiss is unreliable
|
|
61
95
|
// here because other document-level keydown handlers (e.g. the index-row
|
|
@@ -63,7 +97,13 @@ export default class extends Controller {
|
|
|
63
97
|
// also reacting; hidePopover restores focus to the trigger.
|
|
64
98
|
event.preventDefault()
|
|
65
99
|
event.stopPropagation()
|
|
66
|
-
|
|
100
|
+
// With a non-empty search query the first Escape only clears it; the next closes.
|
|
101
|
+
if (this.hasSearchInputTarget && this.searchInputTarget.value !== '') {
|
|
102
|
+
this.resetFilter()
|
|
103
|
+
this.searchInputTarget.focus()
|
|
104
|
+
} else {
|
|
105
|
+
this.element.hidePopover()
|
|
106
|
+
}
|
|
67
107
|
break
|
|
68
108
|
default:
|
|
69
109
|
}
|
data/lib/avo/engine.rb
CHANGED
|
@@ -144,6 +144,23 @@ module Avo
|
|
|
144
144
|
if defined?(::Sprockets)
|
|
145
145
|
# Tell sprockets where your assets are located
|
|
146
146
|
app.config.assets.precompile += %w[avo_manifest.js]
|
|
147
|
+
|
|
148
|
+
# Listed after avo_manifest.js on purpose. When the Tailwind integration is on, the
|
|
149
|
+
# app builds its own app/assets/builds/avo/application.css to replace the stock one
|
|
150
|
+
# we ship — but our avo_manifest.js and the app's manifest.js both `link_tree
|
|
151
|
+
# ../builds`, so two different files compile under the logical path
|
|
152
|
+
# avo/application.css and Sprockets keeps whichever it wrote last. Since we append
|
|
153
|
+
# our manifest after the app's, ours used to win and production served a stylesheet
|
|
154
|
+
# with none of the app's utilities in it. Silently: the build succeeded, the file was
|
|
155
|
+
# right there, and only the precompiled manifest pointed elsewhere.
|
|
156
|
+
#
|
|
157
|
+
# Naming the logical path here compiles it once more, last, and Sprockets resolves it
|
|
158
|
+
# through the load path — which picks the app's build when it has one (its
|
|
159
|
+
# app/assets/builds precedes ours) and our stock file when it doesn't. That also
|
|
160
|
+
# covers apps whose manifest.js doesn't link ../builds at all.
|
|
161
|
+
#
|
|
162
|
+
# Propshaft resolves through the load path to begin with, so it was never affected.
|
|
163
|
+
app.config.assets.precompile += %w[avo/application.css]
|
|
147
164
|
end
|
|
148
165
|
end
|
|
149
166
|
end
|
data/lib/avo/plugin_manager.rb
CHANGED
|
@@ -79,6 +79,46 @@ module Avo
|
|
|
79
79
|
Avo::Menu::Builder.register_item(name, &block)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Register a plugin's configuration namespace on Avo::Configuration so host
|
|
83
|
+
# apps configure the plugin from config/initializers/avo.rb:
|
|
84
|
+
#
|
|
85
|
+
# Avo.plugin_manager.register_configuration :intelligence, Avo::Intelligence::Configuration
|
|
86
|
+
#
|
|
87
|
+
# Avo.configure do |config|
|
|
88
|
+
# config.intelligence.thinking_effort = "medium"
|
|
89
|
+
# end
|
|
90
|
+
#
|
|
91
|
+
# Call it from an engine initializer ordered `before: :load_config_initializers`
|
|
92
|
+
# (or at require time) — the host's avo.rb reads the accessor, so it must be
|
|
93
|
+
# defined before config/initializers run. An :avo_boot hook is too late.
|
|
94
|
+
# Instances are memoized per Configuration object, so replacing
|
|
95
|
+
# Avo.configuration resets plugin configs along with everything else.
|
|
96
|
+
def register_configuration(name, klass)
|
|
97
|
+
name = name.to_sym
|
|
98
|
+
@configuration_namespaces ||= {}
|
|
99
|
+
|
|
100
|
+
# Guard against a plugin silently shadowing a core option or another
|
|
101
|
+
# plugin's namespace. Re-registering the same namespace with the same
|
|
102
|
+
# class is fine — boot runs more than once.
|
|
103
|
+
existing = @configuration_namespaces[name]
|
|
104
|
+
if existing.nil? && Avo::Configuration.method_defined?(name)
|
|
105
|
+
raise ArgumentError, "Avo::Configuration already defines ##{name}; pick a different configuration namespace."
|
|
106
|
+
end
|
|
107
|
+
if !existing.nil? && existing != klass
|
|
108
|
+
raise ArgumentError, "Configuration namespace :#{name} is already registered with #{existing}; pick a different namespace."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@configuration_namespaces[name] = klass
|
|
112
|
+
|
|
113
|
+
Avo::Configuration.define_method(name) do
|
|
114
|
+
(@plugin_configurations ||= {})[name] ||= klass.new
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
Avo::Configuration.define_method(:"#{name}=") do |value|
|
|
118
|
+
(@plugin_configurations ||= {})[name] = value
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
82
122
|
def register_resource_tool
|
|
83
123
|
end
|
|
84
124
|
|
data/lib/avo/version.rb
CHANGED