avo 4.0.0.beta.4 → 4.0.0.beta.6

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +12 -12
  3. data/app/assets/builds/avo/application.css +45 -65
  4. data/app/assets/builds/avo/application.js +113 -113
  5. data/app/assets/builds/avo/application.js.map +4 -4
  6. data/app/assets/stylesheets/application.css +1 -0
  7. data/app/assets/stylesheets/css/components/button.css +5 -0
  8. data/app/assets/stylesheets/css/components/color_scheme_switcher.css +8 -23
  9. data/app/assets/stylesheets/css/components/input.css +12 -3
  10. data/app/assets/stylesheets/css/components/ui/card.css +2 -0
  11. data/app/assets/stylesheets/css/fields/code.css +3 -4
  12. data/app/assets/stylesheets/css/resource-controls.css +3 -3
  13. data/app/assets/stylesheets/css/sidebar.css +2 -6
  14. data/app/assets/stylesheets/css/table.css +4 -0
  15. data/app/assets/stylesheets/css/typography.css +3 -1
  16. data/app/components/avo/actions_component.html.erb +2 -1
  17. data/app/components/avo/actions_component.rb +1 -0
  18. data/app/components/avo/base_component.rb +36 -5
  19. data/app/components/avo/button_component.rb +1 -1
  20. data/app/components/avo/keyboard_shortcuts_component.rb +23 -2
  21. data/app/components/avo/modal_component.html.erb +1 -0
  22. data/app/components/avo/resource_component.rb +33 -4
  23. data/app/components/avo/sidebar/heading_component.html.erb +24 -0
  24. data/app/components/avo/sidebar/heading_component.rb +10 -0
  25. data/app/components/avo/sidebar/link_component.rb +1 -1
  26. data/app/components/avo/sidebar/section_component.html.erb +6 -26
  27. data/app/components/avo/sidebar_component.html.erb +16 -10
  28. data/app/components/avo/u_i/search_input_component.html.erb +8 -3
  29. data/app/components/avo/u_i/search_input_component.rb +1 -1
  30. data/app/components/avo/views/resource_index_component.html.erb +3 -2
  31. data/app/javascript/application.js +2 -1
  32. data/app/javascript/js/controllers/color_scheme_switcher_controller.js +1 -11
  33. data/app/javascript/js/controllers/dropdown_menu_controller.js +59 -3
  34. data/app/javascript/js/controllers/index_row_navigator_controller.js +185 -0
  35. data/app/javascript/js/controllers/item_select_all_controller.js +10 -0
  36. data/app/javascript/js/controllers/resource_search_controller.js +4 -0
  37. data/app/javascript/js/controllers.js +2 -0
  38. data/app/javascript/js/global_hotkeys.js +44 -9
  39. data/app/views/avo/actions/show.html.erb +2 -1
  40. data/app/views/avo/partials/_color_scheme_switcher.html.erb +42 -47
  41. data/app/views/avo/partials/_view_toggle_button.html.erb +6 -1
  42. data/lib/avo/concerns/row_controls_configuration.rb +1 -1
  43. data/lib/avo/version.rb +1 -1
  44. metadata +4 -1
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
2
2
  import Cookies from 'js-cookie'
3
3
 
4
4
  export default class extends Controller {
5
- static targets = ['button', 'accentPanel', 'accentOption', 'themePanel', 'themeLabel', 'themeOption']
5
+ static targets = ['button', 'accentOption', 'themeLabel', 'themeOption']
6
6
 
7
7
  connect() {
8
8
  // Read from cookies (cookie is source of truth)
@@ -60,11 +60,6 @@ export default class extends Controller {
60
60
  this.applyTheme()
61
61
  this.updateThemeLabel()
62
62
  this.updateActiveThemeOption()
63
-
64
- // Close the dropdown
65
- if (this.hasThemePanelTarget) {
66
- this.themePanelTarget.setAttribute('hidden', true)
67
- }
68
63
  }
69
64
 
70
65
  previewTheme(event) {
@@ -89,11 +84,6 @@ export default class extends Controller {
89
84
  this.currentAccentValue = accent
90
85
  this.saveAccent()
91
86
  this.applyAccent()
92
-
93
- // Close the dropdown
94
- if (this.hasAccentPanelTarget) {
95
- this.accentPanelTarget.setAttribute('hidden', true)
96
- }
97
87
  }
98
88
 
99
89
  previewAccent(event) {
@@ -18,25 +18,81 @@ export default class extends Controller {
18
18
  return this.menuTarget.hasAttribute('open')
19
19
  }
20
20
 
21
+ get focusableItems() {
22
+ return [...this.menuTarget.querySelectorAll('a, button')].filter(
23
+ (el) => !el.closest('[hidden]') && el.dataset.disabled !== 'true',
24
+ )
25
+ }
26
+
21
27
  clickOutside(e) {
22
28
  if (this.hasMenuTarget) {
23
29
  const isInExemptionContainer = this.hasExemptionContainersValue && this.exemptionContainerTargets.some((container) => container.contains(e.target))
24
30
 
25
31
  if (!isInExemptionContainer && this.isOpen) {
26
- this.menuTarget.close()
32
+ this.close()
27
33
  }
28
34
  }
29
35
  }
30
36
 
31
37
  connect() {
32
38
  useClickOutside(this)
39
+ this.boundHandleKeydown = this.handleKeydown.bind(this)
40
+ }
41
+
42
+ disconnect() {
43
+ this.element.removeEventListener('keydown', this.boundHandleKeydown)
33
44
  }
34
45
 
35
46
  toggle() {
36
47
  if (this.isOpen) {
37
- this.menuTarget.close()
48
+ this.close()
38
49
  } else {
39
- this.menuTarget.show()
50
+ this.open()
51
+ }
52
+ }
53
+
54
+ open() {
55
+ this.menuTarget.show()
56
+ this.element.addEventListener('keydown', this.boundHandleKeydown)
57
+ document.body.classList.add('dropdown-open')
58
+ this.dispatch('open', { bubbles: true })
59
+ requestAnimationFrame(() => {
60
+ const items = this.focusableItems
61
+ if (items.length === 0) return
62
+
63
+ const activeItem = items.find((el) =>
64
+ [...el.classList].some((cls) => cls.endsWith('--active')) || el.getAttribute('aria-selected') === 'true',
65
+ )
66
+ ;(activeItem || items[0]).focus()
67
+ })
68
+ }
69
+
70
+ close() {
71
+ this.menuTarget.close()
72
+ this.element.removeEventListener('keydown', this.boundHandleKeydown)
73
+ document.body.classList.remove('dropdown-open')
74
+ }
75
+
76
+ handleKeydown(event) {
77
+ const items = this.focusableItems
78
+ if (items.length === 0) return
79
+
80
+ const currentIndex = items.indexOf(document.activeElement)
81
+
82
+ switch (event.key) {
83
+ case 'ArrowDown':
84
+ event.preventDefault()
85
+ items[currentIndex < items.length - 1 ? currentIndex + 1 : 0].focus()
86
+ break
87
+ case 'ArrowUp':
88
+ event.preventDefault()
89
+ items[currentIndex > 0 ? currentIndex - 1 : items.length - 1].focus()
90
+ break
91
+ case 'Escape':
92
+ event.preventDefault()
93
+ this.close()
94
+ break
95
+ default:
40
96
  }
41
97
  }
42
98
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Index Row Navigator Controller
3
+ *
4
+ * Enables keyboard-driven navigation and hotkeys on table rows.
5
+ *
6
+ * FEATURES:
7
+ * - Arrow keys (↑↓) to cycle through rows with visual focus indicator
8
+ * - Enter key to navigate to the focused row's detail page
9
+ * - Space bar to toggle row selection checkbox
10
+ * - Escape to clear focus (or deselect all if no row is focused)
11
+ * - Row hotkeys: when a row is focused, hotkeys work for that row's controls
12
+ *
13
+ * ROW HOTKEY HANDLING:
14
+ * The @github/hotkey library scans data-hotkey attributes on page load.
15
+ * To avoid the "last-registered wins" problem with multiple row controls
16
+ * sharing the same hotkey, we:
17
+ * 1. Remove data-hotkey from all row controls before the library scans
18
+ * 2. Store original values in data-hotkey-original
19
+ * 3. When a row is focused, add data-hotkey back ONLY to that row
20
+ * 4. When a hotkey fires, prevent other handlers via stopImmediatePropagation
21
+ *
22
+ * KEY DESIGN DECISIONS:
23
+ * - currentIndex = -1 when no row is focused (safe default)
24
+ * - Guards prevent keyboard handling in modals, dropdowns, and input fields
25
+ */
26
+
27
+ import { Controller } from '@hotwired/stimulus'
28
+
29
+ const TYPING_SELECTOR = 'input, textarea, select, [contenteditable]'
30
+
31
+ export default class extends Controller {
32
+ connect() {
33
+ this.currentIndex = -1
34
+ this.handleKeydown = this.handleKeydown.bind(this)
35
+ this.handleDropdownOpen = this.handleDropdownOpen.bind(this)
36
+ document.addEventListener('keydown', this.handleKeydown)
37
+ document.addEventListener('dropdown-menu:open', this.handleDropdownOpen)
38
+
39
+ // Remove data-hotkey from row controls before @github/hotkey library scans
40
+ // Store the original values so we can restore them for the focused row only
41
+ const controls = this.element.querySelectorAll('tr[data-visit-path] [data-hotkey]')
42
+ controls.forEach((control) => {
43
+ const hotkey = control.getAttribute('data-hotkey')
44
+ control.setAttribute('data-hotkey-original', hotkey)
45
+ control.removeAttribute('data-hotkey')
46
+ })
47
+ }
48
+
49
+ disconnect() {
50
+ document.removeEventListener('keydown', this.handleKeydown)
51
+ document.removeEventListener('dropdown-menu:open', this.handleDropdownOpen)
52
+ }
53
+
54
+ handleDropdownOpen() {
55
+ const rows = Array.from(this.element.querySelectorAll('tr[data-visit-path]'))
56
+ if (rows.length) this.clearFocus(rows)
57
+ }
58
+
59
+ handleKeydown(event) {
60
+ if (event.defaultPrevented) return
61
+ if (document.body.classList.contains('modal-open')) return
62
+ if (document.body.classList.contains('dropdown-open')) return
63
+ if (event.target.closest(TYPING_SELECTOR)) return
64
+ if (event.repeat && (event.key === 'Enter' || event.key === 'Escape' || event.key === ' ')) return
65
+
66
+ const rows = Array.from(this.element.querySelectorAll('tr[data-visit-path]'))
67
+ if (!rows.length) return
68
+
69
+ // Check for row hotkeys when a row is focused
70
+ if (this.currentIndex !== -1) {
71
+ if (this.handleRowHotkey(event, rows)) {
72
+ return
73
+ }
74
+ }
75
+
76
+ // Only handle navigation keys below
77
+ if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape', ' '].includes(event.key)) return
78
+
79
+ this.currentIndex = this.normalizeCurrentIndex(rows.length)
80
+
81
+ if (event.key === 'Escape') {
82
+ if (this.currentIndex !== -1) {
83
+ event.preventDefault()
84
+ this.clearFocus(rows)
85
+
86
+ return
87
+ }
88
+
89
+ // No keyboard-focused row — clear checkbox selection if any
90
+ const selectAllController = this.application.getControllerForElementAndIdentifier(
91
+ this.element.querySelector('[data-controller~="item-select-all"]'),
92
+ 'item-select-all',
93
+ )
94
+ if (selectAllController) {
95
+ const selected = JSON.parse(selectAllController.element.dataset.selectedResources || '[]')
96
+ if (selected.length > 0) {
97
+ event.preventDefault()
98
+ selectAllController.deselectAll()
99
+ }
100
+ }
101
+
102
+ return
103
+ }
104
+
105
+ if (event.key === 'Enter') {
106
+ if (this.currentIndex === -1) return
107
+ event.preventDefault()
108
+ const row = rows[this.currentIndex]
109
+ if (row?.dataset.visitPath) window.Turbo.visit(row.dataset.visitPath)
110
+
111
+ return
112
+ }
113
+
114
+ if (event.key === ' ') {
115
+ if (this.currentIndex === -1) return
116
+ event.preventDefault()
117
+ const checkbox = rows[this.currentIndex].querySelector('[data-item-select-all-target="itemCheckbox"]')
118
+ if (checkbox) checkbox.click()
119
+
120
+ return
121
+ }
122
+
123
+ // ArrowDown / ArrowUp
124
+ event.preventDefault()
125
+ if (event.key === 'ArrowDown') {
126
+ this.currentIndex = this.currentIndex < rows.length - 1 ? this.currentIndex + 1 : 0
127
+ } else {
128
+ this.currentIndex = this.currentIndex > 0 ? this.currentIndex - 1 : rows.length - 1
129
+ }
130
+
131
+ rows.forEach((r, i) => r.classList.toggle('is-keyboard-focused', i === this.currentIndex))
132
+ rows[this.currentIndex].scrollIntoView({ block: 'nearest' })
133
+ this.syncRowHotkeys(rows)
134
+ }
135
+
136
+ normalizeCurrentIndex(rowsLength) {
137
+ if (this.currentIndex < -1) return -1
138
+ if (this.currentIndex >= rowsLength) return rowsLength - 1
139
+
140
+ return this.currentIndex
141
+ }
142
+
143
+ syncRowHotkeys(rows) {
144
+ // Add data-hotkey back ONLY for the focused row
145
+ rows.forEach((row, index) => {
146
+ const controls = row.querySelectorAll('[data-hotkey-original]')
147
+ controls.forEach((control) => {
148
+ if (index === this.currentIndex) {
149
+ // Restore hotkey on focused row
150
+ const hotkeyValue = control.getAttribute('data-hotkey-original')
151
+ control.setAttribute('data-hotkey', hotkeyValue)
152
+ } else {
153
+ // Remove hotkey from non-focused rows
154
+ control.removeAttribute('data-hotkey')
155
+ }
156
+ })
157
+ })
158
+ }
159
+
160
+ handleRowHotkey(event, rows) {
161
+ const focusedRow = rows[this.currentIndex]
162
+ if (!focusedRow) return false
163
+
164
+ // The hotkey is now on the focused row (added by syncRowHotkeys)
165
+ const control = focusedRow.querySelector(`[data-hotkey="${event.key}"]`)
166
+ if (!control) return false
167
+
168
+ event.preventDefault()
169
+ event.stopImmediatePropagation() // Prevent @github/hotkey library from firing
170
+ control.click()
171
+
172
+ return true
173
+ }
174
+
175
+ clearFocus(rows) {
176
+ rows.forEach((r) => r.classList.remove('is-keyboard-focused'))
177
+ this.currentIndex = -1
178
+ // Clear all hotkeys when focus is cleared
179
+ rows.forEach((row) => {
180
+ row.querySelectorAll('[data-hotkey-original]').forEach((control) => {
181
+ control.removeAttribute('data-hotkey')
182
+ })
183
+ })
184
+ }
185
+ }
@@ -45,6 +45,16 @@ export default class extends Controller {
45
45
  this.selectedResourcesObserver.stop()
46
46
  }
47
47
 
48
+ deselectAll() {
49
+ this.itemCheckboxTargets.forEach((checkbox) => checkbox.checked && checkbox.click())
50
+ this.checkboxTarget.checked = false
51
+
52
+ if (this.selectAllEnabled()) {
53
+ this.selectAllOverlay(false)
54
+ this.resetUnselected()
55
+ }
56
+ }
57
+
48
58
  toggle(event) {
49
59
  const checked = !!event.target.checked
50
60
  this.itemCheckboxTargets.forEach((checkbox) => checkbox.checked !== checked && checkbox.click())
@@ -19,6 +19,10 @@ export default class extends Controller {
19
19
  this.#removeSpinner()
20
20
  }
21
21
 
22
+ blurOnEscape(event) {
23
+ if (event.key === 'Escape') this.inputTarget.blur()
24
+ }
25
+
22
26
  search() {
23
27
  this.debouncedSearch()
24
28
  }
@@ -21,6 +21,7 @@ import FilterController from './controllers/filter_controller'
21
21
  import FormController from './controllers/form_controller'
22
22
  import GridCoverEmptyStateController from './controllers/grid_cover_empty_state_controller'
23
23
  import HiddenInputController from './controllers/hidden_input_controller'
24
+ import IndexRowNavigatorController from './controllers/index_row_navigator_controller'
24
25
  import InputAutofocusController from './controllers/input_autofocus_controller'
25
26
  import ItemSelectAllController from './controllers/item_select_all_controller'
26
27
  import ItemSelectorController from './controllers/item_selector_controller'
@@ -78,6 +79,7 @@ application.register('filter', FilterController)
78
79
  application.register('form', FormController)
79
80
  application.register('grid-cover-empty-state', GridCoverEmptyStateController)
80
81
  application.register('hidden-input', HiddenInputController)
82
+ application.register('index-row-navigator', IndexRowNavigatorController)
81
83
  application.register('input-autofocus', InputAutofocusController)
82
84
  application.register('item-select-all', ItemSelectAllController)
83
85
  application.register('item-selector', ItemSelectorController)
@@ -9,6 +9,9 @@
9
9
 
10
10
  import { install } from '@github/hotkey'
11
11
 
12
+ const RESOURCE_SEARCH_INPUT_SELECTOR = '[data-resource-search-target="input"]'
13
+ const findResourceSearchInput = () => document.querySelector(RESOURCE_SEARCH_INPUT_SELECTOR)
14
+
12
15
  // Use @github/hotkey for sequences and standard combos.
13
16
  const ELEMENT_HOTKEYS = [
14
17
  {
@@ -27,23 +30,55 @@ const DIRECT_HOTKEYS = [
27
30
  match: (e) => e.key === '?' || (e.shiftKey && e.code === 'Slash'),
28
31
  handle: () => document.dispatchEvent(new Event('persistent-modal:toggle')),
29
32
  },
33
+ {
34
+ // "/" → focus the resource search input on index pages.
35
+ match: (e) => e.key === '/'
36
+ && !e.shiftKey
37
+ && !e.metaKey
38
+ && !e.ctrlKey
39
+ && !e.altKey
40
+ && !!findResourceSearchInput(),
41
+ handle: () => {
42
+ const input = findResourceSearchInput()
43
+ if (input) input.focus()
44
+ },
45
+ },
30
46
  ]
31
47
 
32
48
  const TYPING_SELECTOR = 'input, textarea, select, [contenteditable]'
33
49
 
34
- export function installGlobalHotkeys() {
35
- // When a hotkey fires on a DOM element that contains a <kbd>, mark it cold
36
- // before letting the click proceed. preventDefault + requestAnimationFrame
37
- // gives the browser one frame to paint the cold state before navigation starts.
38
- document.addEventListener('hotkey-fire', (event) => {
39
- const kbd = event.target.querySelector('kbd')
40
- if (!kbd) return
50
+ // hotkey-fire is dispatched with { cancelable: true } but NOT { bubbles: true },
51
+ // so it never reaches a document-level listener. Must be registered on each element.
52
+ function hotkeyFireHandler(event) {
53
+ const el = event.currentTarget
54
+ const hotkey = el.getAttribute('data-hotkey')
55
+
56
+ // Apply feedback to ALL elements sharing this hotkey (e.g. desktop + mobile sidebar).
57
+ // @github/hotkey fires on the last-registered element which may be hidden.
58
+ const kbds = hotkey
59
+ ? document.querySelectorAll(`[data-hotkey="${CSS.escape(hotkey)}"] kbd`)
60
+ : el.querySelectorAll('kbd')
41
61
 
42
- event.preventDefault()
62
+ if (!kbds.length) return
63
+
64
+ event.preventDefault()
65
+ kbds.forEach((kbd) => {
43
66
  kbd.classList.add('kbd--called')
44
- requestAnimationFrame(() => event.target.click())
67
+ kbd.addEventListener('transitionend', () => kbd.classList.remove('kbd--called'), { once: true })
68
+ })
69
+ // Double rAF: the first fires before paint (style committed), the second
70
+ // fires after the browser has actually painted the kbd--called state.
71
+ requestAnimationFrame(() => {
72
+ requestAnimationFrame(() => el.click())
45
73
  })
74
+ }
46
75
 
76
+ export function attachHotkeyFeedback(el) {
77
+ el.removeEventListener('hotkey-fire', hotkeyFireHandler)
78
+ el.addEventListener('hotkey-fire', hotkeyFireHandler)
79
+ }
80
+
81
+ export function installGlobalHotkeys() {
47
82
  document.addEventListener('turbo:load', () => {
48
83
  document.querySelectorAll('kbd.kbd--called').forEach((kbd) => kbd.classList.remove('kbd--called'))
49
84
 
@@ -52,7 +52,7 @@
52
52
 
53
53
  <% c.with_controls do %>
54
54
  <%= a_button type: :button,
55
- data: { action: 'click->modal#close' },
55
+ data: { action: 'click->modal#close', hotkey: "Escape" },
56
56
  size: :md do %>
57
57
  <%= @action.cancel_button_label %>
58
58
  <% end %>
@@ -62,6 +62,7 @@
62
62
  size: :md,
63
63
  data: {
64
64
  target: :submit_action,
65
+ hotkey: "Mod+Enter",
65
66
  },
66
67
  autofocus: @fields.reject { |field| field.is_a?(Avo::Fields::HiddenField) }.empty? do %>
67
68
  <%= @action.confirm_button_label %>
@@ -1,24 +1,22 @@
1
1
  <div class="color-scheme-switcher"
2
2
  data-controller="color-scheme-switcher">
3
- <div class="color-scheme-switcher__theme-wrapper"
4
- data-controller="toggle">
5
- <button type="button"
6
- data-color-scheme-switcher-target="themeTrigger"
7
- data-action="click->toggle#togglePanel"
8
- class="color-scheme-switcher__button color-scheme-switcher__button--theme"
9
- title="Neutral theme">
10
- <span class="color-scheme-switcher__text" data-color-scheme-switcher-target="themeLabel">Brand</span>
11
- </button>
12
-
13
- <div class="color-scheme-switcher__theme-panel"
14
- hidden
15
- data-toggle-target="panel"
16
- data-color-scheme-switcher-target="themePanel"
17
- data-action="mouseleave->color-scheme-switcher#revertTheme">
18
- <div class="color-scheme-switcher__theme-options">
3
+ <%= render Avo::UI::DropdownComponent.new(
4
+ classes: "color-scheme-switcher__theme-popover"
5
+ ) do |component| %>
6
+ <% component.with_trigger do %>
7
+ <button type="button"
8
+ data-action="<%= component.action %>"
9
+ class="color-scheme-switcher__button color-scheme-switcher__button--theme"
10
+ title="Neutral theme">
11
+ <span class="color-scheme-switcher__text" data-color-scheme-switcher-target="themeLabel">Brand</span>
12
+ </button>
13
+ <% end %>
14
+ <% component.with_items do %>
15
+ <div class="color-scheme-switcher__theme-options"
16
+ data-action="mouseleave->color-scheme-switcher#revertTheme">
19
17
  <% %w[brand slate stone gray zinc neutral taupe mauve mist olive].each do |theme| %>
20
18
  <button type="button"
21
- data-action="click->color-scheme-switcher#setTheme mouseenter->color-scheme-switcher#previewTheme"
19
+ data-action="click->color-scheme-switcher#setTheme click->dropdown-menu#close mouseenter->color-scheme-switcher#previewTheme"
22
20
  data-theme="<%= theme %>"
23
21
  data-color-scheme-switcher-target="themeOption"
24
22
  class="color-scheme-switcher__theme-option">
@@ -27,32 +25,31 @@
27
25
  </button>
28
26
  <% end %>
29
27
  </div>
30
- </div>
31
- </div>
32
-
33
- <div class="color-scheme-switcher__accent-wrapper"
34
- data-controller="toggle">
35
- <button type="button"
36
- data-color-scheme-switcher-target="button"
37
- data-action="click->toggle#togglePanel"
38
- class="color-scheme-switcher__button color-scheme-switcher__button--accent"
39
- title="Accent color">
40
- <%= svg "tabler/outline/color-swatch", class: "color-scheme-switcher__icon" %>
41
- <span class="color-scheme-switcher__accent-badge">
42
- <span class="color-scheme-switcher__accent-badge-preview color-scheme-switcher__accent-badge-preview--neutral"></span>
43
- <% accent_colors.each do |accent| %>
44
- <span class="color-scheme-switcher__accent-badge-preview color-scheme-switcher__accent-badge-preview--<%= accent %> bg-<%= accent %>-500"></span>
45
- <% end %>
46
- </span>
47
- <span class="sr-only">Accent color</span>
48
- </button>
28
+ <% end %>
29
+ <% end %>
49
30
 
50
- <div class="color-scheme-switcher__accent-panel"
51
- hidden
52
- data-toggle-target="panel"
53
- data-color-scheme-switcher-target="accentPanel"
54
- data-action="mouseleave->color-scheme-switcher#revertAccent">
55
- <div class="color-scheme-switcher__accent-options">
31
+ <%= render Avo::UI::DropdownComponent.new(
32
+ classes: "color-scheme-switcher__accent-popover"
33
+ ) do |component| %>
34
+ <% component.with_trigger do %>
35
+ <button type="button"
36
+ data-color-scheme-switcher-target="button"
37
+ data-action="<%= component.action %>"
38
+ class="color-scheme-switcher__button color-scheme-switcher__button--accent"
39
+ title="Accent color">
40
+ <%= svg "tabler/outline/color-swatch", class: "color-scheme-switcher__icon" %>
41
+ <span class="color-scheme-switcher__accent-badge">
42
+ <span class="color-scheme-switcher__accent-badge-preview color-scheme-switcher__accent-badge-preview--neutral"></span>
43
+ <% accent_colors.each do |accent| %>
44
+ <span class="color-scheme-switcher__accent-badge-preview color-scheme-switcher__accent-badge-preview--<%= accent %> bg-<%= accent %>-500"></span>
45
+ <% end %>
46
+ </span>
47
+ <span class="sr-only">Accent color</span>
48
+ </button>
49
+ <% end %>
50
+ <% component.with_items do %>
51
+ <div class="color-scheme-switcher__accent-options"
52
+ data-action="mouseleave->color-scheme-switcher#revertAccent">
56
53
  <% (['neutral'] + accent_colors).each do |accent| %>
57
54
  <%
58
55
  preview_class = if accent == "neutral"
@@ -62,19 +59,17 @@
62
59
  end
63
60
  %>
64
61
  <button type="button"
65
- data-action="click->color-scheme-switcher#setAccent mouseenter->color-scheme-switcher#previewAccent"
62
+ data-action="click->color-scheme-switcher#setAccent click->dropdown-menu#close mouseenter->color-scheme-switcher#previewAccent"
66
63
  data-accent="<%= accent %>"
67
64
  data-color-scheme-switcher-target="accentOption"
68
- data-tippy="tooltip"
69
- title="<%= accent.capitalize %>"
70
65
  class="color-scheme-switcher__accent-option">
71
66
  <span class="<%= preview_class %>"></span>
72
67
  <span class="sr-only"><%= accent.capitalize %></span>
73
68
  </button>
74
69
  <% end %>
75
70
  </div>
76
- </div>
77
- </div>
71
+ <% end %>
72
+ <% end %>
78
73
 
79
74
  <button type="button"
80
75
  data-color-scheme-switcher-target="button"
@@ -1,9 +1,13 @@
1
1
  <% if available_view_types.count > 1 %>
2
+ <%
3
+ view_type_hotkeys = { table: "v t", grid: "v g", map: "v m" }
4
+ %>
2
5
  <div class="flex">
3
6
  <div class="button-group">
4
7
  <% available_view_types.each do |type| %>
5
8
  <% view_info = Avo.view_type_manager.find(type) %>
6
9
  <% is_active_view = view_type.to_s == type.to_s %>
10
+ <% hotkey = view_type_hotkeys[type.to_sym] %>
7
11
 
8
12
  <%= a_link url_for(params.permit!.merge(view_type: type)).to_s,
9
13
  icon: is_active_view ? view_info&.[](:active_icon) : view_info&.[](:icon),
@@ -14,7 +18,8 @@
14
18
  tippy: 'tooltip',
15
19
  'turbo-frame': turbo_frame,
16
20
  control: "view-type-toggle-#{type}",
17
- is_active: is_active_view
21
+ is_active: is_active_view,
22
+ **(hotkey ? { hotkey: hotkey, show_hotkey_badge: false } : {})
18
23
  }
19
24
  %>
20
25
  <% end %>
@@ -45,7 +45,7 @@ module Avo
45
45
  class_names(
46
46
  "text-end whitespace-nowrap px-3",
47
47
  "w-px": render_row_controls_on_the_left?,
48
- "*:opacity-0 group-hover:*:opacity-100": row_controls_configurations[:show_on_hover],
48
+ "bg-transparent before:opacity-0 group-hover:before:opacity-100 *:opacity-0 group-hover:*:opacity-100": row_controls_configurations[:show_on_hover],
49
49
  "#{float_classes}": row_controls_configurations[:float]
50
50
  )
51
51
  end
data/lib/avo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Avo
2
- VERSION = "4.0.0.beta.4" unless const_defined?(:VERSION)
2
+ VERSION = "4.0.0.beta.6" 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.0.beta.4
4
+ version: 4.0.0.beta.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Marin
@@ -645,6 +645,8 @@ files:
645
645
  - app/components/avo/sidebar/base_item_component.rb
646
646
  - app/components/avo/sidebar/group_component.html.erb
647
647
  - app/components/avo/sidebar/group_component.rb
648
+ - app/components/avo/sidebar/heading_component.html.erb
649
+ - app/components/avo/sidebar/heading_component.rb
648
650
  - app/components/avo/sidebar/link_component.html.erb
649
651
  - app/components/avo/sidebar/link_component.rb
650
652
  - app/components/avo/sidebar/section_component.html.erb
@@ -753,6 +755,7 @@ files:
753
755
  - app/javascript/js/controllers/form_controller.js
754
756
  - app/javascript/js/controllers/grid_cover_empty_state_controller.js
755
757
  - app/javascript/js/controllers/hidden_input_controller.js
758
+ - app/javascript/js/controllers/index_row_navigator_controller.js
756
759
  - app/javascript/js/controllers/input_autofocus_controller.js
757
760
  - app/javascript/js/controllers/item_select_all_controller.js
758
761
  - app/javascript/js/controllers/item_selector_controller.js