1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +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:
Syed Bariman Jan 2025-02-24 21:08:05 +05:00 committed by GitHub
parent ac9703031f
commit 95989a6c9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 335 additions and 40 deletions

File diff suppressed because one or more lines are too long

View file

@ -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 {

View 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)`;
}
}

View file

@ -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
}
}

View file

@ -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) }

View file

@ -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)

View 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>

View file

@ -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">
<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->color-avatar#handleColorChange" } %>
<%= 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>

View file

@ -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>

View file

@ -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

View file

@ -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
View file

@ -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

File diff suppressed because one or more lines are too long