mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Add new category flow (#1857)
* resolve git issue * Add new category flow * Improve contrast checker * make error message small * update ui to match figma design * realign color picker * changes * rename color picker controller to new category controller * cleanup code * cleanup code * resize and realign icon avatar * Fix js lint errors Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com> --------- Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>
This commit is contained in:
parent
ac9703031f
commit
95989a6c9b
14 changed files with 335 additions and 40 deletions
2
app/assets/stylesheets/simonweb_pickr.css
Normal file
2
app/assets/stylesheets/simonweb_pickr.css
Normal file
File diff suppressed because one or more lines are too long
|
@ -8,6 +8,35 @@
|
|||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "../stylesheets/simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.pcr-color-palette{
|
||||
height: 12em !important;
|
||||
width: 21.5rem !important;
|
||||
}
|
||||
.pcr-palette{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-palette:before{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-color-chooser{
|
||||
height: 1.5em !important;
|
||||
}
|
||||
.pcr-picker{
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper,
|
||||
.hw-combobox__input {
|
||||
|
|
206
app/javascript/controllers/category_controller.js
Normal file
206
app/javascript/controllers/category_controller.js
Normal file
|
@ -0,0 +1,206 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import Pickr from '@simonwep/pickr'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"];
|
||||
static values = {
|
||||
presetColors: Array,
|
||||
};
|
||||
|
||||
initialize() {
|
||||
this.pickerBtnTarget.addEventListener('click', () => {
|
||||
this.showPaletteSection();
|
||||
});
|
||||
|
||||
this.colorInputTarget.addEventListener('input', (e) => {
|
||||
this.picker.setColor(e.target.value);
|
||||
});
|
||||
|
||||
this.detailsTarget.addEventListener('toggle', (e) => {
|
||||
if (!this.colorInputTarget.checkValidity()) {
|
||||
e.preventDefault();
|
||||
this.colorInputTarget.reportValidity();
|
||||
e.target.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.selectedIcon = null;
|
||||
|
||||
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
|
||||
this.colorPickerRadioBtnTarget.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
initPicker() {
|
||||
const pickerContainer = document.createElement("div");
|
||||
pickerContainer.classList.add("pickerContainer");
|
||||
this.pickerSectionTarget.append(pickerContainer);
|
||||
|
||||
this.picker = Pickr.create({
|
||||
el: this.pickerBtnTarget,
|
||||
theme: 'monolith',
|
||||
container: ".pickerContainer",
|
||||
useAsButton: true,
|
||||
showAlways: true,
|
||||
default: this.colorInputTarget.value,
|
||||
components: {
|
||||
hue: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.picker.on('change', (color) => {
|
||||
const hexColor = color.toHEXA().toString();
|
||||
const rgbacolor = color.toRGBA();
|
||||
|
||||
this.updateAvatarColors(hexColor);
|
||||
this.updateSelectedIconColor(hexColor);
|
||||
|
||||
const backgroundColor = this.backgroundColor(rgbacolor, 10);
|
||||
const contrastRatio = this.contrast(rgbacolor, backgroundColor);
|
||||
|
||||
this.colorInputTarget.value = hexColor;
|
||||
this.colorInputTarget.dataset.colorPickerColorValue = hexColor;
|
||||
this.colorPreviewTarget.style.backgroundColor = hexColor;
|
||||
|
||||
this.handleContrastValidation(contrastRatio);
|
||||
});
|
||||
}
|
||||
|
||||
updateAvatarColors(color) {
|
||||
this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleIconColorChange(e) {
|
||||
const selectedIcon = e.target;
|
||||
this.selectedIcon = selectedIcon;
|
||||
|
||||
const currentColor = this.colorInputTarget.value;
|
||||
|
||||
this.iconTargets.forEach(icon => {
|
||||
const iconWrapper = icon.nextElementSibling;
|
||||
iconWrapper.style.removeProperty("background-color")
|
||||
iconWrapper.style.color = "black";
|
||||
});
|
||||
|
||||
this.updateSelectedIconColor(currentColor);
|
||||
}
|
||||
|
||||
handleIconChange(e) {
|
||||
const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true);
|
||||
this.avatarTarget.innerHTML = '';
|
||||
iconSVG.style.padding = "0px"
|
||||
iconSVG.classList.add("w-8","h-8")
|
||||
this.avatarTarget.appendChild(iconSVG);
|
||||
}
|
||||
|
||||
updateSelectedIconColor(color) {
|
||||
if (this.selectedIcon) {
|
||||
const iconWrapper = this.selectedIcon.nextElementSibling;
|
||||
iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
iconWrapper.style.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.colorInputTarget.value = color;
|
||||
this.colorPreviewTarget.style.backgroundColor = color;
|
||||
this.updateAvatarColors(color);
|
||||
this.updateSelectedIconColor(color);
|
||||
}
|
||||
|
||||
handleContrastValidation(contrastRatio) {
|
||||
if (contrastRatio < 4.5) {
|
||||
this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust.");
|
||||
|
||||
this.validationMessageTarget.classList.remove("hidden");
|
||||
} else {
|
||||
this.colorInputTarget.setCustomValidity("");
|
||||
this.validationMessageTarget.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
autoAdjust(e){
|
||||
const currentRGBA = this.picker.getColor();
|
||||
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
|
||||
this.picker.setColor(adjustedRGBA);
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const display = typeof parent === "string" && parent !== "" ? "none" : "flex";
|
||||
this.selectionTarget.style.display = display;
|
||||
}
|
||||
|
||||
backgroundColor([r,g,b,a], percentage) {
|
||||
const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
return [mixedR, mixedG, mixedB];
|
||||
}
|
||||
|
||||
luminance([r,g,b]) {
|
||||
const toLinear = c => {
|
||||
const scaled = c / 255;
|
||||
return scaled <= 0.04045
|
||||
? scaled / 12.92
|
||||
: ((scaled + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
contrast(foregroundColor, backgroundColor) {
|
||||
const fgLum = this.luminance(foregroundColor);
|
||||
const bgLum = this.luminance(backgroundColor);
|
||||
const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)];
|
||||
return (l1 + 0.05) / (l2 + 0.05);
|
||||
}
|
||||
|
||||
darkenColor(color) {
|
||||
let darkened = color.toRGBA();
|
||||
const backgroundColor = this.backgroundColor(darkened, 10);
|
||||
let contrastRatio = this.contrast(darkened, backgroundColor);
|
||||
|
||||
while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) {
|
||||
darkened = [
|
||||
Math.max(0, darkened[0] - 10),
|
||||
Math.max(0, darkened[1] - 10),
|
||||
Math.max(0, darkened[2] - 10),
|
||||
darkened[3]
|
||||
];
|
||||
contrastRatio = this.contrast(darkened, backgroundColor);
|
||||
}
|
||||
|
||||
return `rgba(${darkened.join(", ")})`;
|
||||
}
|
||||
|
||||
showPaletteSection() {
|
||||
this.initPicker();
|
||||
this.colorsSectionTarget.classList.add('hidden');
|
||||
this.paletteSectionTarget.classList.remove('hidden');
|
||||
this.pickerSectionTarget.classList.remove('hidden');
|
||||
this.picker.show();
|
||||
}
|
||||
|
||||
showColorsSection() {
|
||||
this.colorsSectionTarget.classList.remove('hidden');
|
||||
this.paletteSectionTarget.classList.add('hidden');
|
||||
this.pickerSectionTarget.classList.add('hidden');
|
||||
if (this.picker) {
|
||||
this.picker.destroyAndRemove();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSections() {
|
||||
if (this.colorsSectionTarget.classList.contains('hidden')) {
|
||||
this.showColorsSection();
|
||||
} else {
|
||||
this.showPaletteSection();
|
||||
}
|
||||
}
|
||||
|
||||
#backgroundColor(color) {
|
||||
return `color-mix(in oklab, ${color} 10%, transparent)`;
|
||||
}
|
||||
}
|
|
@ -21,14 +21,8 @@ export default class extends Controller {
|
|||
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
|
||||
this.selectionTarget.style.visibility = visibility
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,13 +8,13 @@ class Category < ApplicationRecord
|
|||
has_many :subcategories, class_name: "Category", foreign_key: :parent_id, dependent: :nullify
|
||||
belongs_to :parent, class_name: "Category", optional: true
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, :color, :lucide_icon, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family_id }
|
||||
|
||||
validate :category_level_limit
|
||||
validate :nested_category_matches_parent_classification
|
||||
|
||||
before_create :inherit_color_from_parent
|
||||
before_save :inherit_color_from_parent
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :roots, -> { where(parent_id: nil) }
|
||||
|
|
|
@ -142,9 +142,9 @@ class Demo::Generator
|
|||
family.categories.bootstrap_defaults
|
||||
|
||||
food = family.categories.find_by(name: "Food & Drink")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, lucide_icon: "shopping-cart", classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense")
|
||||
end
|
||||
|
||||
def create_merchants!(family)
|
||||
|
|
8
app/views/categories/_color_avatar.html.erb
Normal file
8
app/views/categories/_color_avatar.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
|||
<%# locals: (category:) %>
|
||||
|
||||
<span
|
||||
data-category-target="avatar"
|
||||
class="w-14 h-14 flex items-center justify-center rounded-full"
|
||||
style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>">
|
||||
<%= lucide_icon(category.lucide_icon, class: "w-8 h-8") %>
|
||||
</span>
|
|
@ -1,42 +1,70 @@
|
|||
<%# locals: (category:, categories:) %>
|
||||
|
||||
<div data-controller="color-avatar">
|
||||
<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>">
|
||||
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
||||
<%= render partial: "color_avatar", locals: { category: category } %>
|
||||
</div>
|
||||
<details data-category-target="details">
|
||||
<summary class="cursor-pointer absolute top-23 left-58.5 flex justify-center items-center bg-gray-50 border-2 w-7 h-7 border-white rounded-full text-gray-500">
|
||||
<%= icon("pen", size: "sm") %>
|
||||
</summary>
|
||||
|
||||
<div class="flex gap-2 items-center justify-center" data-color-avatar-target="selection">
|
||||
<% Category::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class=" absolute z-50 bg-white p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit left-66 top-24">
|
||||
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
|
||||
<div data-category-target="pickerSection"></div>
|
||||
<h4 class="text-gray-500 text-sm">Color</h4>
|
||||
<div class="flex gap-2 items-center" data-category-target="colorsSection">
|
||||
<% Category::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-category-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)" ></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center hidden flex-col" data-category-target="paletteSection">
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-category-target="colorPreview"></div>
|
||||
<%= f.text_field :color , data: { category_target: "colorInput"}, class: "form-field__input blah", inline: true %>
|
||||
<%= lucide_icon "palette", class: "w-8 h-8 cursor-pointer hover:bg-gray-100 p-1", data: { action: "click->category#toggleSections" } %>
|
||||
</div>
|
||||
<div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
|
||||
<span>Poor contrast, choose darker color or</span>
|
||||
<button type="button" class="underline cursor-pointer" data-action="category#autoAdjust">auto-adjust.</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
|
||||
<h4 class="text-gray-500 text-sm">Icon</h4>
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
|
||||
<div class="w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent" >
|
||||
<%= lucide_icon icon, class: "w-6 h-6 p-1" %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<% if category.errors.any? %>
|
||||
<%= render "shared/form_errors", model: category %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-4">
|
||||
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
|
||||
<div class="p-1 rounded cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent peer-checked:border-gray-500">
|
||||
<%= lucide_icon icon, class: "w-5 h-5" %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %>
|
||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||
<% unless category.parent? %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->color-avatar#handleParentChange" } %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%# locals: (content:, classes:) -%>
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<dialog class="m-auto bg-white shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<dialog class="m-auto bg-white shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit overflow-visible <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<div class="flex flex-col">
|
||||
<%= content %>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
|||
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||
pin_all_from "app/javascript/services", under: "services", to: "services"
|
||||
pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.0
|
||||
pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1
|
||||
|
||||
# D3 packages
|
||||
pin "d3" # @7.8.5
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
class AddDefaultLucideIconToCategories < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
execute <<-SQL
|
||||
UPDATE categories
|
||||
SET lucide_icon = 'shapes'
|
||||
WHERE lucide_icon IS NULL
|
||||
SQL
|
||||
|
||||
change_column_null :categories, :lucide_icon, false
|
||||
change_column_default :categories, :lucide_icon, 'shapes'
|
||||
end
|
||||
|
||||
def down
|
||||
change_column_default :categories, :lucide_icon, nil
|
||||
change_column_null :categories, :lucide_icon, true
|
||||
|
||||
execute <<-SQL
|
||||
UPDATE categories
|
||||
SET lucide_icon = NULL
|
||||
WHERE lucide_icon = 'shapes'
|
||||
SQL
|
||||
end
|
||||
end
|
4
db/schema.rb
generated
4
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_02_20_153958) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_02_20_200735) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -192,7 +192,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_20_153958) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.uuid "parent_id"
|
||||
t.string "classification", default: "expense", null: false
|
||||
t.string "lucide_icon"
|
||||
t.string "lucide_icon", default: "shapes", null: false
|
||||
t.index ["family_id"], name: "index_categories_on_family_id"
|
||||
end
|
||||
|
||||
|
|
4
vendor/javascript/@simonwep--pickr.js
vendored
Normal file
4
vendor/javascript/@simonwep--pickr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue