diff --git a/Gemfile b/Gemfile index 42d78dd4..7a2b13c0 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,7 @@ gem "bootsnap", require: false gem "importmap-rails" gem "propshaft" gem "tailwindcss-rails" +gem "lucide-rails", github: "maybe-finance/lucide-rails" # Hotwire gem "stimulus-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 48fb1ef6..3f0464be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/maybe-finance/lucide-rails.git + revision: 6170b3a0eceb43a8af6552638e9526673c356d0d + specs: + lucide-rails (0.2.0) + railties (>= 4.1.0) + GIT remote: https://github.com/rails/rails.git revision: bab4aa7cb25112846e04cea907361a1de0f126ef @@ -325,7 +332,7 @@ GEM rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) smart_properties (1.17.0) - sorbet-runtime (0.5.11226) + sorbet-runtime (0.5.11234) stimulus-rails (1.3.3) railties (>= 6.0.0) stringio (3.1.0) @@ -388,6 +395,7 @@ DEPENDENCIES inline_svg jbuilder letter_opener + lucide-rails! money-rails (~> 1.12) pg (~> 1.1) propshaft diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bd65adc7..1904451f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,4 +10,10 @@ module ApplicationHelper def permitted_accountable_partial(name) name.underscore end + + # Wrap view with <%= modal do %> ... <% end %> to have it open in a modal + # Make sure to add data-turbo-frame="modal" to the link/button that opens the modal + def modal(&block) + render "shared/modal", &block + end end diff --git a/app/javascript/controllers/hotkey_controller.js b/app/javascript/controllers/hotkey_controller.js new file mode 100644 index 00000000..88df7f9d --- /dev/null +++ b/app/javascript/controllers/hotkey_controller.js @@ -0,0 +1,13 @@ +import { Controller } from "@hotwired/stimulus" +import { install, uninstall } from "@github/hotkey" + +// Connects to data-controller="hotkey" +export default class extends Controller { + connect() { + install(this.element) + } + + disconnect() { + uninstall(this.element) + } +} diff --git a/app/javascript/controllers/list_keyboard_navigation_controller.js b/app/javascript/controllers/list_keyboard_navigation_controller.js new file mode 100644 index 00000000..9a019e86 --- /dev/null +++ b/app/javascript/controllers/list_keyboard_navigation_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="list-keyboard-navigation" +export default class extends Controller { + focusPrevious() { + this.focusLinkTargetInDirection(-1) + } + + focusNext() { + this.focusLinkTargetInDirection(1) + } + + focusLinkTargetInDirection(direction) { + const element = this.getLinkTargetInDirection(direction) + element?.focus() + } + + getLinkTargetInDirection(direction) { + const indexOfLastFocus = this.indexOfLastFocus() + return this.focusableLinks[indexOfLastFocus + direction] + } + + indexOfLastFocus(targets = this.focusableLinks) { + const indexOfActiveElement = targets.indexOf(document.activeElement) + + if (indexOfActiveElement !== -1) { + return indexOfActiveElement + } else { + return targets.findIndex(target => target.getAttribute("tabindex") === "0") + } + } + + get focusableLinks() { + return Array.from(this.element.querySelectorAll("a[href]")) + } +} diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 00000000..e6df3a19 --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="modal" +export default class extends Controller { + connect() { + this.element.showModal(); + } + + // Hide the dialog when the user clicks outside of it + click_outside(e) { + if (e.target === this.element) { + this.element.close(); + } + } +} diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index 746e1f72..d09ca7cf 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,9 +1,6 @@ -
- <%= link_to new_account_path(type: type.class.name.demodulize), class: "flex flex-col items-center justify-center w-full text-center focus:outline-none" do %> - - - <%= inline_svg_tag(icon, class: "#{text_color} stroke-current") %> - - <%= type.model_name.human %> - <% end %> -
+<%= link_to new_account_path(step: 'method', type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-50 rounded-lg p-2" do %> + + <%= lucide_icon(icon, class: "#{text_color} w-5 h-5") %> + + <%= type.model_name.human %> +<% end %> diff --git a/app/views/accounts/_entry_method.html.erb b/app/views/accounts/_entry_method.html.erb new file mode 100644 index 00000000..31277d4f --- /dev/null +++ b/app/views/accounts/_entry_method.html.erb @@ -0,0 +1,15 @@ +<% if local_assigns[:disabled] && disabled %> + + + <%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %> + + <%= text %> + +<% else %> + <%= link_to new_account_path(type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> + + <%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %> + + <%= text %> + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/accounts/account/_depository.html.erb b/app/views/accounts/account/_depository.html.erb index 11933f8d..a6f1670c 100644 --- a/app/views/accounts/account/_depository.html.erb +++ b/app/views/accounts/account/_depository.html.erb @@ -1,4 +1,4 @@ -
- - <%= f.select :subtype, options_for_select([["Checking", "checking"], ["Savings", "savings"]], selected: ""), {}, class: "block w-full p-0 mt-1 bg-transparent border-none focus:outline-none focus:ring-0" %> -
+
+ + <%= f.select :subtype, options_for_select([["Checking", "checking"], ["Savings", "savings"]], selected: ""), {}, class: "block w-full p-0 mt-1 bg-transparent border-none focus:outline-none focus:ring-0" %> +
diff --git a/app/views/accounts/account/_investment.html.erb b/app/views/accounts/account/_investment.html.erb index 6310d930..41b46a78 100644 --- a/app/views/accounts/account/_investment.html.erb +++ b/app/views/accounts/account/_investment.html.erb @@ -1,4 +1,4 @@ -
+
<%= f.select :subtype, options_for_select(Account::Investment::SUBTYPES, selected: ""), {}, class: "block w-full p-0 mt-1 bg-transparent border-none focus:outline-none focus:ring-0" %>
diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 58ba7ff9..23d8e8b7 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -1,47 +1,94 @@

<%= t('.title')%>

-<% if params[:type].blank? || Account.accountable_types.include?("Account::#{params[:type]}") == false %> -
- <%= render "account_type", type: Account::Credit.new, bg_color: "bg-[#E6F6FA]", text_color: "text-[#189FC7]", icon: "icon-credit-card.svg" %> - <%= render "account_type", type: Account::Depository.new, bg_color: "bg-[#EAF4FF]", text_color: "text-[#3492FB]", icon: "icon-bank-accounts.svg" %> - <%= render "account_type", type: Account::Investment.new, bg_color: "bg-[#EDF7F4]", text_color: "text-[#1BD5A1]", icon: "icon-bank-accounts.svg" %> - <%= render "account_type", type: Account::Loan.new, bg_color: "bg-[#EDF7F4]", text_color: "text-[#1BD5A1]", icon: "icon-bank-accounts.svg" %> - <%= render "account_type", type: Account::OtherAsset.new, bg_color: "bg-[#EDF7F4]", text_color: "text-[#1BD5A1]", icon: "icon-bank-accounts.svg" %> - <%= render "account_type", type: Account::OtherLiability.new, bg_color: "bg-[#EDF7F4]", text_color: "text-[#1BD5A1]", icon: "icon-bank-accounts.svg" %> - <%= render "account_type", type: Account::Property.new, bg_color: "bg-[#FEF0F7]", text_color: "text-[#F03695]", icon: "icon-real-estate.svg" %> - <%= render "account_type", type: Account::Vehicle.new, bg_color: "bg-[#EDF7F4]", text_color: "text-[#1BD5A1]", icon: "icon-bank-accounts.svg" %> -
-<% else %> -
- <%= link_to new_account_path, class: "" do %> - <%= inline_svg_tag('icon-arrow-left.svg', class: 'text-gray-500 fill-current') %> - <% end %> -

<%= t('.enter_type_account', type: @account.accountable_class.model_name.human.downcase ) %>

-
- - <%= form_with model: @account, url: accounts_path, scope: :account, html: { class: "space-y-4" } do |f| %> - <%= f.hidden_field :accountable_type %> - -
- <%# Optional %> - <%= f.label :name, class: 'block text-sm font-medium opacity-50 focus-within:opacity-100' %> - <%= f.text_field :name, placeholder: t('.account_name_placeholder'), required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +<%= modal do %> + <% if params[:type].blank? || Account.accountable_types.include?("Account::#{params[:type]}") == false %> +
+ What would you like to add?
+
+ + - <%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %> - -
- - <%= f.label :balance, class: 'block text-sm font-medium opacity-50 focus-within:opacity-100' %> -
- <%= f.number_field :balance, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> + <%= render "account_type", type: Account::Depository.new, bg_color: "bg-[#EFF8FF]", text_color: "text-[#2E90FA]", icon: "landmark" %> + <%= render "account_type", type: Account::Investment.new, bg_color: "bg-[#ECFDF3]", text_color: "text-[#32D583]", icon: "line-chart" %> + <%= render "account_type", type: Account::Property.new, bg_color: "bg-[#FCF5F9]", text_color: "text-[#F23E94]", icon: "home" %> + <%= render "account_type", type: Account::Vehicle.new, bg_color: "bg-[#EEF4FF]", text_color: "text-[#6172F3]", icon: "car-front" %> + <%= render "account_type", type: Account::Credit.new, bg_color: "bg-[#F0F9FF]", text_color: "text-[#36BFFA]", icon: "credit-card" %> + <%= render "account_type", type: Account::Loan.new, bg_color: "bg-[#FEF6EE]", text_color: "text-[#F38744]", icon: "hand-coins" %> + <%= render "account_type", type: Account::OtherAsset.new, bg_color: "bg-[#ECFDF3]", text_color: "text-[#12B76A]", icon: "plus" %> + <%= render "account_type", type: Account::OtherLiability.new, bg_color: "bg-[#FEF3F2]", text_color: "text-[#F04438]", icon: "minus" %> +
+
+
+
+ Select <%= lucide_icon('corner-down-left', class: 'inline w-3 h-3')%> +
+
+ Navigate <%= lucide_icon('arrow-up', class: 'inline w-3 h-3')%> <%= lucide_icon('arrow-down', class: 'inline w-3 h-3')%> +
+
+
+ Close ESC
- -
- + <% elsif params[:step] == 'method' && params[:type].present? %> +
+ <%= link_to new_account_path, class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-[#141414]/5" do %> + <%= lucide_icon('arrow-left', class: 'text-gray-500 w-5 h-5') %> + <% end %> + How would you like to add it?
+
+ + + + <%= render "entry_method", type: Account::Depository.new, text: 'Enter account balance manually', icon: "keyboard" %> + <%= render "entry_method", type: Account::Depository.new, text: 'Securely link bank account with data provider (coming soon)', icon: "link-2", disabled: true %> + <%= render "entry_method", type: Account::Depository.new, text: 'Upload spreadsheet (coming soon)', icon: "sheet", disabled: true %> +
+
+
+
+ Select <%= lucide_icon('corner-down-left', class: 'inline w-3 h-3')%> +
+
+ Navigate <%= lucide_icon('arrow-up', class: 'inline w-3 h-3')%> <%= lucide_icon('arrow-down', class: 'inline w-3 h-3')%> +
+
+
+ Close ESC +
+
+ <% else %> +
+ <%= link_to new_account_path(step: 'method', type: params[:type]), class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-[#141414]/5" do %> + <%= lucide_icon('arrow-left', class: 'text-gray-500 w-5 h-5') %> + <% end %> + Add account +
+ + <%= form_with model: @account, url: accounts_path, scope: :account, html: { class: "space-y-4 m-5 mt-1", data: { turbo: false } } do |f| %> + <%= f.hidden_field :accountable_type %> + +
+ <%= f.label :name, 'Account name', class: 'block text-sm font-medium opacity-50 focus-within:opacity-100' %> + <%= f.text_field :name, placeholder: 'Example account name', required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
+ + <%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %> + +
+ <%= f.label :balance, class: 'block text-sm font-medium opacity-50 focus-within:opacity-100' %> +
+ <%= f.number_field :balance, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100 w-full" %> +
+
+ +
+ +
+ <% end %> <% end %> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index fd46221a..553132a6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -59,10 +59,11 @@ <%= link_to accounts_path, class: 'text-xs' do%> <%= t('.accounts') %> <% end %> - <%= link_to new_account_path, class: 'block hover:bg-gray-100 p-2 text-sm font-semibold text-gray-900 flex items-center rounded', title: t('.new_account') do %> + <%= link_to new_account_path, class: 'block hover:bg-gray-100 p-2 text-sm font-semibold text-gray-900 flex items-center rounded', title: t('.new_account'), data: { turbo_frame: "modal" } do %> <%= inline_svg_tag('icon-add.svg', class: 'text-gray-500 fill-current') %> <% end %>
+

<%= t('.cash') %>

@@ -83,5 +84,6 @@ <%= yield %>
+ <%= turbo_frame_tag "modal" %> diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb new file mode 100644 index 00000000..c274b671 --- /dev/null +++ b/app/views/shared/_modal.html.erb @@ -0,0 +1,7 @@ +<%= turbo_frame_tag "modal" do %> + +
+ <%= yield %> +
+
+<% end %> diff --git a/config/importmap.rb b/config/importmap.rb index bc060edb..43c52ce7 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,3 +5,4 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" +pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.0 diff --git a/config/locales/models/account/en.yml b/config/locales/models/account/en.yml index 4da21b3e..81090ddf 100644 --- a/config/locales/models/account/en.yml +++ b/config/locales/models/account/en.yml @@ -13,7 +13,7 @@ en: account: Account account/credit: Credit Card account/depository: Bank Accounts - account/investiment: Investments + account/investment: Investments account/loan: Loan account/other_asset: Other Asset account/other_liability: Other Liability diff --git a/config/locales/views/account/en.yml b/config/locales/views/account/en.yml index 7b3f9c2c..c97bda51 100644 --- a/config/locales/views/account/en.yml +++ b/config/locales/views/account/en.yml @@ -6,6 +6,4 @@ en: index: title: Cash new: - account_name_placeholder: Account name - enter_type_account: Enter %{type} account title: Add an account diff --git a/config/tailwind.config.js b/config/tailwind.config.js index 4850d1b4..99854d6a 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -22,6 +22,9 @@ module.exports = { }, dropShadow: { 'form': '0px 4px 10px rgba(52, 54, 60, 0.08)', + }, + boxShadow: { + 'xs': '0px 1px 2px 0px #1018280D' } }, }, diff --git a/vendor/javascript/@github--hotkey.js b/vendor/javascript/@github--hotkey.js new file mode 100644 index 00000000..dd60ca25 --- /dev/null +++ b/vendor/javascript/@github--hotkey.js @@ -0,0 +1,2 @@ +class Leaf{constructor(e){this.children=[];this.parent=e}delete(e){const t=this.children.indexOf(e);if(t===-1)return false;this.children=this.children.slice(0,t).concat(this.children.slice(t+1));this.children.length===0&&this.parent.delete(this);return true}add(e){this.children.push(e);return this}}class RadixTrie{constructor(e){this.parent=null;this.children={};this.parent=e||null}get(e){return this.children[e]}insert(e){let t=this;for(let n=0;n","¿":"?"};const t={"`":"~",1:"!",2:"@",3:"#",4:"$",5:"%",6:"^",7:"&",8:"*",9:"(",0:")","-":"_","=":"+","[":"{","]":"}","\\":"|",";":":","'":'"',",":"<",".":">","/":"?",q:"Q",w:"W",e:"E",r:"R",t:"T",y:"Y",u:"U",i:"I",o:"O",p:"P",a:"A",s:"S",d:"D",f:"F",g:"G",h:"H",j:"J",k:"K",l:"L",z:"Z",x:"X",c:"C",v:"V",b:"B",n:"N",m:"M"};const n={" ":"Space","+":"Plus"};function eventToHotkeyString(s,o=navigator.platform){var l,c,a;const{ctrlKey:h,altKey:d,metaKey:u,shiftKey:f,key:p}=s;const m=[];const k=[h,d,u,f];for(const[e,t]of k.entries())t&&m.push(i[e]);if(!i.includes(p)){const i=m.includes("Alt")&&r.test(o)&&(l=e[p])!==null&&l!==void 0?l:p;const s=m.includes("Shift")&&r.test(o)&&(c=t[i])!==null&&c!==void 0?c:i;const h=(a=n[s])!==null&&a!==void 0?a:s;m.push(h)}return m.join("+")}const i=["Control","Alt","Meta","Shift"];function normalizeHotkey(e,t){let n;n=localizeMod(e,t);n=sortModifiers(n);return n}const r=/Mac|iPod|iPhone|iPad/i;function localizeMod(e,t=navigator.platform){const n=r.test(t)?"Meta":"Control";return e.replace("Mod",n)}function sortModifiers(e){const t=e.split("+").pop();const n=[];for(const t of["Control","Alt","Meta","Shift"])e.includes(t)&&n.push(t);t&&n.push(t);return n.join("+")}const s=" ";class SequenceTracker{constructor({onReset:e}={}){this._path=[];this.timer=null;this.onReset=e}get path(){return this._path}get sequence(){return this._path.join(s)}registerKeypress(e){this._path=[...this._path,eventToHotkeyString(e)];this.startTimer()}reset(){var e;this.killTimer();this._path=[];(e=this.onReset)===null||e===void 0?void 0:e.call(this)}killTimer(){this.timer!=null&&window.clearTimeout(this.timer);this.timer=null}startTimer(){this.killTimer();this.timer=window.setTimeout((()=>this.reset()),SequenceTracker.CHORD_TIMEOUT)}}SequenceTracker.CHORD_TIMEOUT=1500;function normalizeSequence(e){return e.split(s).map((e=>normalizeHotkey(e))).join(s)}function isFormField(e){if(!(e instanceof HTMLElement))return false;const t=e.nodeName.toLowerCase();const n=(e.getAttribute("type")||"").toLowerCase();return t==="select"||t==="textarea"||t==="input"&&n!=="submit"&&n!=="reset"&&n!=="checkbox"&&n!=="radio"&&n!=="file"||e.isContentEditable}function fireDeterminedAction(e,t){const n=new CustomEvent("hotkey-fire",{cancelable:true,detail:{path:t}});const i=!e.dispatchEvent(n);i||(isFormField(e)?e.focus():e.click())}function expandHotkeyToEdges(e){const t=[];let n=[""];let i=false;for(let r=0;re.map((e=>normalizeHotkey(e))).filter((e=>e!=="")))).filter((e=>e.length>0))}const o=new RadixTrie;const l=new WeakMap;let c=o;const a=new SequenceTracker({onReset(){c=o}});function keyDownHandler(e){if(e.defaultPrevented)return;if(!(e.target instanceof Node))return;if(isFormField(e.target)){const t=e.target;if(!t.id)return;if(!t.ownerDocument.querySelector(`[data-hotkey-scope="${t.id}"]`))return}const t=c.get(eventToHotkeyString(e));if(t){a.registerKeypress(e);c=t;if(t instanceof Leaf){const n=e.target;let i=false;let r;const s=isFormField(n);for(let e=t.children.length-1;e>=0;e-=1){r=t.children[e];const o=r.getAttribute("data-hotkey-scope");if(!s&&!o||s&&n.id===o){i=true;break}}if(r&&i){fireDeterminedAction(r,a.path);e.preventDefault()}a.reset()}}else a.reset()}function install(e,t){Object.keys(o.children).length===0&&document.addEventListener("keydown",keyDownHandler);const n=expandHotkeyToEdges(t||e.getAttribute("data-hotkey")||"");const i=n.map((t=>o.insert(t).add(e)));l.set(e,i)}function uninstall(e){const t=l.get(e);if(t&&t.length)for(const n of t)n&&n.delete(e);Object.keys(o.children).length===0&&document.removeEventListener("keydown",keyDownHandler)}export{Leaf,RadixTrie,SequenceTracker,eventToHotkeyString,install,normalizeHotkey,normalizeSequence,uninstall}; +