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.
@@ -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
- items[idx < items.length - 1 ? idx + 1 : 0].focus()
54
- break
55
- case 'ArrowUp':
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
- this.element.hidePopover()
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Avo
2
- VERSION = "4.0.22" unless const_defined?(:VERSION)
2
+ VERSION = "4.0.24" unless const_defined?(:VERSION)
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: avo
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.22
4
+ version: 4.0.24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Marin