diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css
index 615faa67..0e96acec 100644
--- a/app/assets/stylesheets/application.tailwind.css
+++ b/app/assets/stylesheets/application.tailwind.css
@@ -94,6 +94,14 @@
.tooltip {
@apply hidden absolute;
}
+
+ .btn {
+ @apply px-3 py-2 rounded-lg;
+ }
+
+ .btn--primary {
+ @apply bg-gray-900 text-white hover:bg-gray-700;
+ }
}
/* Small, single purpose classes that should take precedence over other styles */
@@ -110,4 +118,4 @@
.scrollbar::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
-}
+}
\ No newline at end of file
diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb
index b5ec8444..74ec8752 100644
--- a/app/helpers/menus_helper.rb
+++ b/app/helpers/menus_helper.rb
@@ -1,6 +1,6 @@
module MenusHelper
def contextual_menu(&block)
- tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
+ tag.div data: { controller: "menu" } do
concat contextual_menu_icon
concat contextual_menu_content(&block)
end
@@ -25,13 +25,14 @@ module MenusHelper
private
def contextual_menu_icon
- tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
+ tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
end
end
def contextual_menu_content(&block)
- tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
+ tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
+ data: { menu_target: "content" } do
capture(&block)
end
end
diff --git a/app/javascript/controllers/merchant_avatar_controller.js b/app/javascript/controllers/color_avatar_controller.js
similarity index 94%
rename from app/javascript/controllers/merchant_avatar_controller.js
rename to app/javascript/controllers/color_avatar_controller.js
index 077c9e9a..7a1ba0d0 100644
--- a/app/javascript/controllers/merchant_avatar_controller.js
+++ b/app/javascript/controllers/color_avatar_controller.js
@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus";
-// Connects to data-controller="merchant-avatar"
+// Connects to data-controller="color-avatar"
// Used by the transaction merchant form to show a preview of what the avatar will look like
export default class extends Controller {
static targets = [
diff --git a/app/javascript/controllers/menu_controller.js b/app/javascript/controllers/menu_controller.js
index 3c31e880..04d23a2c 100644
--- a/app/javascript/controllers/menu_controller.js
+++ b/app/javascript/controllers/menu_controller.js
@@ -1,61 +1,57 @@
import { Controller } from "@hotwired/stimulus";
+import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
- *
- * - If you need a form-enabled "select" element, use the "listbox" controller instead.
*/
export default class extends Controller {
- static targets = [
- "button",
- "content",
- "submenu",
- "submenuButton",
- "submenuContent",
- ];
+ static targets = ["button", "content"];
static values = {
- show: { type: Boolean, default: false },
- showSubmenu: { type: Boolean, default: false },
+ show: Boolean,
+ placement: { type: String, default: "bottom-end" },
+ offset: { type: Number, default: 5 },
};
- initialize() {
+ connect() {
this.show = this.showValue;
- this.showSubmenu = this.showSubmenuValue;
+ this.boundUpdate = this.update.bind(this);
+ this.addEventListeners();
+ this.startAutoUpdate();
}
- connect() {
+ disconnect() {
+ this.removeEventListeners();
+ this.stopAutoUpdate();
+ this.close();
+ }
+
+ addEventListeners() {
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
- disconnect() {
- this.element.removeEventListener("keydown", this.handleKeydown);
+ removeEventListeners() {
this.buttonTarget.removeEventListener("click", this.toggle);
+ this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
- this.close();
}
- // If turbo reloads, we maintain the state of the menu
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
- if (this.show && !this.element.contains(event.target)) {
- this.close();
- }
+ if (this.show && !this.element.contains(event.target)) this.close();
};
handleKeydown = (event) => {
- switch (event.key) {
- case "Escape":
- this.close();
- this.buttonTarget.focus(); // Bring focus back to the button
- break;
+ if (event.key === "Escape") {
+ this.close();
+ this.buttonTarget.focus();
}
};
@@ -63,6 +59,7 @@ export default class extends Controller {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
if (this.show) {
+ this.update();
this.focusFirstElement();
}
};
@@ -73,12 +70,41 @@ export default class extends Controller {
}
focusFirstElement() {
- const focusableElements =
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
- const firstFocusableElement =
- this.contentTarget.querySelectorAll(focusableElements)[0];
+ const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+ const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
}
+
+ startAutoUpdate() {
+ if (!this._cleanup) {
+ this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
+ }
+ }
+
+ stopAutoUpdate() {
+ if (this._cleanup) {
+ this._cleanup();
+ this._cleanup = null;
+ }
+ }
+
+ update() {
+ computePosition(this.buttonTarget, this.contentTarget, {
+ placement: this.placementValue,
+ middleware: [
+ offset(this.offsetValue),
+ flip(),
+ shift({ padding: 5 })
+ ],
+ }).then(({ x, y }) => {
+ Object.assign(this.contentTarget.style, {
+ position: 'fixed',
+ left: `${x}px`,
+ top: `${y}px`,
+ width: 'max-content',
+ });
+ });
+ }
}
diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb
index c89c0a98..2c04d43e 100644
--- a/app/views/categories/_badge.html.erb
+++ b/app/views/categories/_badge.html.erb
@@ -1,10 +1,11 @@
<%# locals: (category:) %>
<% category ||= null_category %>
-
- <%= category.name %>
-
+
+
+ <%= category.name %>
+
+
diff --git a/app/views/categories/_category.html.erb b/app/views/categories/_category.html.erb
new file mode 100644
index 00000000..f25e5c25
--- /dev/null
+++ b/app/views/categories/_category.html.erb
@@ -0,0 +1,21 @@
+<%# locals: (category:) %>
+
+
+
+ <%= render partial: "categories/badge", locals: { category: category } %>
+
+
+ <%= contextual_menu do %>
+
+ <%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
+
+ <%= link_to new_category_deletion_path(category),
+ class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
+ data: { turbo_frame: :modal } do %>
+ <%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
+ <%= t(".delete") %>
+ <% end %>
+
+ <% end %>
+
+
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb
index 0321b2bd..5f36f8d9 100644
--- a/app/views/categories/_form.html.erb
+++ b/app/views/categories/_form.html.erb
@@ -1,38 +1,25 @@
-<%= styled_form_with model: category, data: { turbo: false } do |form| %>
-
-
-
-
diff --git a/app/views/categories/_row.html.erb b/app/views/categories/_row.html.erb
deleted file mode 100644
index ba2aea0b..00000000
--- a/app/views/categories/_row.html.erb
+++ /dev/null
@@ -1,23 +0,0 @@
-
- <%= render partial: "categories/badge", locals: { category: row } %>
-
- <%= contextual_menu do %>
-
- <%= link_to edit_category_path(row),
- class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
-
- <%= t(".edit") %>
- <% end %>
-
- <%= link_to new_category_deletion_path(row),
- class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon "trash-2", class: "w-5 h-5" %>
-
- <%= t(".delete") %>
- <% end %>
-
- <% end %>
-
diff --git a/app/views/categories/_ruler.html.erb b/app/views/categories/_ruler.html.erb
new file mode 100644
index 00000000..fc1d8ce9
--- /dev/null
+++ b/app/views/categories/_ruler.html.erb
@@ -0,0 +1,3 @@
+
diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb
index 0cf2f577..f06a59fe 100644
--- a/app/views/categories/index.html.erb
+++ b/app/views/categories/index.html.erb
@@ -1,24 +1,43 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
+
<%= t(".categories") %>
- <%= link_to new_category_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
+ <%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<%= t(".new") %>
<% end %>
-
-
<%= t(".categories") %> · <%= @categories.size %>
+ <% if @categories.any? %>
+
+
+
<%= t(".categories") %>
+
·
+
<%= @categories.count %>
+
-
- <%= render collection: @categories, partial: "categories/row" %>
+
+
+ <%= render partial: @categories, spacer_template: "categories/ruler" %>
+
+
-
+ <% else %>
+
+
+
<%= t(".empty") %>
+ <%= link_to new_category_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
+ <%= lucide_icon("plus", class: "w-5 h-5") %>
+
<%= t(".new") %>
+ <% end %>
+
+
+ <% end %>