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| %> -
-
- - <%= form.text_field :name, - value: category.name, - autofocus: "", - required: true, - placeholder: "Enter Category name", - class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %> -
- -
- <%= form.hidden_field :color, data: { color_select_target: "input" } %> - - -
+
+
+ <%= f.text_field :name, placeholder: t(".placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { color_avatar_target: "name" } %> +
+
<%= hidden_field_tag :transaction_id, params[:transaction_id] %> - - <% if category.persisted? %> - <%= form.submit t(".update") %> - <% else %> - <%= form.submit t(".create") %> - <% end %> + <%= f.submit %>
- -<% end %> + <% end %> + 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 %>
+
diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb index 2c0d823e..dda0e9db 100644 --- a/app/views/settings/_nav.html.erb +++ b/app/views/settings/_nav.html.erb @@ -50,7 +50,7 @@ <%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
  • - <%= sidebar_link_to t(".categories_label"), categories_path, icon: "tags" %> + <%= sidebar_link_to t(".categories_label"), categories_path, icon: "shapes" %>
  • <%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %> diff --git a/app/views/shared/_color_avatar.html.erb b/app/views/shared/_color_avatar.html.erb new file mode 100644 index 00000000..be59ad7d --- /dev/null +++ b/app/views/shared/_color_avatar.html.erb @@ -0,0 +1,11 @@ +<%# locals: (name: nil, color: "#000") %> + +<% letter = name&.first || "?" %> + +<% background_color = "color-mix(in srgb, #{color} 5%, white)" %> +<% border_color = "color-mix(in srgb, #{color} 10%, white)" %> + + <%= letter.upcase %> + diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index 4ee675d5..99b1c706 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -1,38 +1,25 @@ -<%= styled_form_with model: tag, data: { turbo: false } do |form| %> -
    -
    - - <%= form.text_field :name, - value: tag.name, - autofocus: "", - required: true, - placeholder: "Enter tag name", - class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %> -
    - -
    - <%= form.hidden_field :color, data: { color_select_target: "input" } %> - -
      +
      + <%= styled_form_with model: tag, class: "space-y-4", data: { turbo: false } do |f| %> +
      +
      + <%= render partial: "shared/color_avatar", locals: { name: tag.name, color: tag.color } %> +
      +
      <% Tag::COLORS.each do |color| %> - + <% end %> -
    -
    +
    +
    + <%= f.text_field :name, placeholder: t(".placeholder"), class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-3 w-full border-none rounded-lg", required: true, data: { color_avatar_target: "name" } %> +
    +
    <%= hidden_field_tag :tag_id, params[:tag_id] %> - - <% if tag.persisted? %> - <%= form.submit t(".update") %> - <% else %> - <%= form.submit t(".create") %> - <% end %> + <%= f.submit %>
    - -<% end %> + <% end %> + diff --git a/app/views/tags/_ruler.html.erb b/app/views/tags/_ruler.html.erb new file mode 100644 index 00000000..fc1d8ce9 --- /dev/null +++ b/app/views/tags/_ruler.html.erb @@ -0,0 +1,3 @@ +
    +
    +
    diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb index 03ba11b5..5a65a341 100644 --- a/app/views/tags/_tag.html.erb +++ b/app/views/tags/_tag.html.erb @@ -1,23 +1,24 @@ -
    - <%= render "badge", tag: tag %> +<%# locals: (tag:) %> - <%= contextual_menu do %> -
    - <%= link_to edit_tag_path(tag), - 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" %> +
    +
    + <%= render partial: "shared/color_avatar", locals: { name: tag.name, color: tag.color } %> +

    + <%= tag.name %> +

    +
    +
    + <%= contextual_menu do %> +
    + <%= contextual_menu_modal_action_item t(".edit"), edit_tag_path(tag) %> - <%= t(".edit") %> - <% end %> - - <%= link_to new_tag_deletion_path(tag), - 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 %> + <%= link_to new_tag_deletion_path(tag), + 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/tags/index.html.erb b/app/views/tags/index.html.erb index 6f28d6f5..610939bf 100644 --- a/app/views/tags/index.html.erb +++ b/app/views/tags/index.html.erb @@ -6,28 +6,28 @@

    <%= t(".tags") %>

    - <%= link_to new_tag_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_tag_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 %>
    - <% if @tags.any? %> +
    +
    +

    <%= t(".tags") %>

    + · +

    <%= @tags.count %>

    +
    -
    -

    <%= t(".tags") %> · <%= @tags.size %>

    - -
    - - <%= render @tags %> - +
    +
    + <%= render partial: @tags, spacer_template: "tags/ruler" %> +
    - <% else %> -

    <%= t(".empty") %>

    @@ -37,9 +37,7 @@ <% end %>
    - <% end %> -