mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Add stimulus tooltip controller (#1065)
* Add Tooltip Stimulus controller * Add test for tooltip * Remove comma * Normalize translations * Use floating-ui instead popper * Use component classes * Increase cross axis value * Cleanup * Update app/views/accounts/show.html.erb Use correct tailwind class Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com> Signed-off-by: Tony Vincent <tonyvince7@gmail.com> * Use default values for options * Remove tooltip global variable * Add arrow target * Remove unused method --------- Signed-off-by: Tony Vincent <tonyvince7@gmail.com> Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
This commit is contained in:
parent
6e74414cb2
commit
f315370512
11 changed files with 164 additions and 4 deletions
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
/* Reset rules, default styles applied to plain HTML */
|
/* Reset rules, default styles applied to plain HTML */
|
||||||
@layer base {
|
@layer base {
|
||||||
details > summary::-webkit-details-marker {
|
details>summary::-webkit-details-marker {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
details > summary {
|
details>summary {
|
||||||
@apply list-none;
|
@apply list-none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + label + .toggle-switch-dot {
|
input:checked+label+.toggle-switch-dot {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,6 +90,10 @@
|
||||||
@apply font-bold;
|
@apply font-bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
@apply hidden absolute;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small, single purpose classes that should take precedence over other styles */
|
/* Small, single purpose classes that should take precedence over other styles */
|
||||||
|
|
74
app/javascript/controllers/tooltip_controller.js
Normal file
74
app/javascript/controllers/tooltip_controller.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import {
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
shift,
|
||||||
|
offset,
|
||||||
|
arrow
|
||||||
|
} from '@floating-ui/dom';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["arrow", "tooltip"];
|
||||||
|
static values = {
|
||||||
|
placement: { type: String, default: "top" },
|
||||||
|
offset: { type: Number, default: 10 },
|
||||||
|
crossAxis: { type: Number, default: 0 },
|
||||||
|
alignmentAxis: { type: Number, default: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.element.addEventListener("mouseenter", this.showTooltip);
|
||||||
|
this.element.addEventListener("mouseleave", this.hideTooltip);
|
||||||
|
this.element.addEventListener("focus", this.showTooltip);
|
||||||
|
this.element.addEventListener("blur", this.hideTooltip);
|
||||||
|
};
|
||||||
|
|
||||||
|
showTooltip = () => {
|
||||||
|
this.tooltipTarget.style.display = 'block';
|
||||||
|
this.#update();
|
||||||
|
};
|
||||||
|
|
||||||
|
hideTooltip = () => {
|
||||||
|
this.tooltipTarget.style.display = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.element.removeEventListener("mouseenter", this.showTooltip);
|
||||||
|
this.element.removeEventListener("mouseleave", this.hideTooltip);
|
||||||
|
this.element.removeEventListener("focus", this.showTooltip);
|
||||||
|
this.element.removeEventListener("blur", this.hideTooltip);
|
||||||
|
};
|
||||||
|
|
||||||
|
#update() {
|
||||||
|
computePosition(this.element, this.tooltipTarget, {
|
||||||
|
placement: this.placementValue,
|
||||||
|
middleware: [
|
||||||
|
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 5 }),
|
||||||
|
arrow({ element: this.arrowTarget }),
|
||||||
|
],
|
||||||
|
}).then(({ x, y, placement, middlewareData }) => {
|
||||||
|
Object.assign(this.tooltipTarget.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { x: arrowX, y: arrowY } = middlewareData.arrow;
|
||||||
|
const staticSide = {
|
||||||
|
top: 'bottom',
|
||||||
|
right: 'left',
|
||||||
|
bottom: 'top',
|
||||||
|
left: 'right',
|
||||||
|
}[placement.split('-')[0]];
|
||||||
|
|
||||||
|
Object.assign(this.arrowTarget.style, {
|
||||||
|
left: arrowX != null ? `${arrowX}px` : '',
|
||||||
|
top: arrowY != null ? `${arrowY}px` : '',
|
||||||
|
right: '',
|
||||||
|
bottom: '',
|
||||||
|
[staticSide]: '-4px',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
26
app/views/accounts/_tooltip.html.erb
Normal file
26
app/views/accounts/_tooltip.html.erb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<%# locals: (account:) -%>
|
||||||
|
<div data-controller="tooltip" data-tooltip-target="element" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
|
||||||
|
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
|
||||||
|
<div id="tooltip" role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
|
||||||
|
<div class="text-white">
|
||||||
|
<%= t(".total_value_tooltip") %>
|
||||||
|
</div>
|
||||||
|
<div class="flex pt-3">
|
||||||
|
<div class="text-gray-300">
|
||||||
|
<%= t(".holdings") %>
|
||||||
|
</div>
|
||||||
|
<div class="text-white ml-auto">
|
||||||
|
<%= tag.p format_money(account.investment.holdings_value, precision: 0) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="text-gray-300">
|
||||||
|
<%= t(".cash") %>
|
||||||
|
</div>
|
||||||
|
<div class="text-white ml-auto">
|
||||||
|
<%= tag.p format_money(account.balance_money, precision: 0) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-tooltip-target="arrow"></div>
|
||||||
|
</div>
|
|
@ -52,7 +52,12 @@
|
||||||
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
||||||
<div class="p-4 flex justify-between">
|
<div class="p-4 flex justify-between">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div>
|
||||||
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
|
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
|
||||||
|
</div>
|
||||||
|
<%= render "tooltip", account: @account if @account.investment? %>
|
||||||
|
</div>
|
||||||
<%= tag.p format_money(@account.value, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
|
<%= tag.p format_money(@account.value, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
|
||||||
<div>
|
<div>
|
||||||
<% if @series.trend.direction.flat? %>
|
<% if @series.trend.direction.flat? %>
|
||||||
|
|
|
@ -46,3 +46,7 @@ pin "d3-zoom" # @3.0.0
|
||||||
pin "delaunator" # @5.0.1
|
pin "delaunator" # @5.0.1
|
||||||
pin "internmap" # @2.0.3
|
pin "internmap" # @2.0.3
|
||||||
pin "robust-predicates" # @3.0.2
|
pin "robust-predicates" # @3.0.2
|
||||||
|
pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.6.9
|
||||||
|
pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.6.6
|
||||||
|
pin "@floating-ui/utils", to: "@floating-ui--utils.js" # @0.2.6
|
||||||
|
pin "@floating-ui/utils/dom", to: "@floating-ui--utils--dom.js" # @0.2.6
|
||||||
|
|
|
@ -71,5 +71,10 @@ en:
|
||||||
new: New account
|
new: New account
|
||||||
sync_all:
|
sync_all:
|
||||||
success: Successfully queued accounts for syncing.
|
success: Successfully queued accounts for syncing.
|
||||||
|
tooltip:
|
||||||
|
cash: Cash
|
||||||
|
holdings: Holdings
|
||||||
|
total_value_tooltip: The total value is the sum of cash balance and your holdings
|
||||||
|
value, minus margin loans.
|
||||||
update:
|
update:
|
||||||
success: Account updated
|
success: Account updated
|
||||||
|
|
26
test/system/tooltips_test.rb
Normal file
26
test/system/tooltips_test.rb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
require "application_system_test_case"
|
||||||
|
|
||||||
|
class TooltipsTest < ApplicationSystemTestCase
|
||||||
|
include ActionView::Helpers::NumberHelper
|
||||||
|
include ApplicationHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
@account = accounts(:investment)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can see account information tooltip" do
|
||||||
|
visit account_path(@account)
|
||||||
|
find('[data-controller="tooltip"]').hover
|
||||||
|
assert find("#tooltip", visible: true)
|
||||||
|
within "#tooltip" do
|
||||||
|
assert_text I18n.t("accounts.tooltip.total_value_tooltip")
|
||||||
|
assert_text I18n.t("accounts.tooltip.holdings")
|
||||||
|
assert_text format_money(@account.investment.holdings_value, precision: 0)
|
||||||
|
assert_text I18n.t("accounts.tooltip.cash")
|
||||||
|
assert_text format_money(@account.balance_money, precision: 0)
|
||||||
|
end
|
||||||
|
find("body").click
|
||||||
|
assert find("#tooltip", visible: false)
|
||||||
|
end
|
||||||
|
end
|
2
vendor/javascript/@floating-ui--core.js
vendored
Normal file
2
vendor/javascript/@floating-ui--core.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
vendor/javascript/@floating-ui--dom.js
vendored
Normal file
10
vendor/javascript/@floating-ui--dom.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
vendor/javascript/@floating-ui--utils--dom.js
vendored
Normal file
2
vendor/javascript/@floating-ui--utils--dom.js
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
function getNodeName(e){return isNode(e)?(e.nodeName||"").toLowerCase():"#document"}function getWindow(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function getDocumentElement(e){var t;return(t=(isNode(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function isNode(e){return e instanceof Node||e instanceof getWindow(e).Node}function isElement(e){return e instanceof Element||e instanceof getWindow(e).Element}function isHTMLElement(e){return e instanceof HTMLElement||e instanceof getWindow(e).HTMLElement}function isShadowRoot(e){return typeof ShadowRoot!=="undefined"&&(e instanceof ShadowRoot||e instanceof getWindow(e).ShadowRoot)}function isOverflowElement(e){const{overflow:t,overflowX:n,overflowY:o,display:r}=getComputedStyle(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&!["inline","contents"].includes(r)}function isTableElement(e){return["table","td","th"].includes(getNodeName(e))}function isTopLayer(e){return[":popover-open",":modal"].some((t=>{try{return e.matches(t)}catch(e){return false}}))}function isContainingBlock(e){const t=isWebKit();const n=isElement(e)?getComputedStyle(e):e;return n.transform!=="none"||n.perspective!=="none"||!!n.containerType&&n.containerType!=="normal"||!t&&!!n.backdropFilter&&n.backdropFilter!=="none"||!t&&!!n.filter&&n.filter!=="none"||["transform","perspective","filter"].some((e=>(n.willChange||"").includes(e)))||["paint","layout","strict","content"].some((e=>(n.contain||"").includes(e)))}function getContainingBlock(e){let t=getParentNode(e);while(isHTMLElement(t)&&!isLastTraversableNode(t)){if(isContainingBlock(t))return t;if(isTopLayer(t))return null;t=getParentNode(t)}return null}function isWebKit(){return!(typeof CSS==="undefined"||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function isLastTraversableNode(e){return["html","body","#document"].includes(getNodeName(e))}function getComputedStyle(e){return getWindow(e).getComputedStyle(e)}function getNodeScroll(e){return isElement(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function getParentNode(e){if(getNodeName(e)==="html")return e;const t=e.assignedSlot||e.parentNode||isShadowRoot(e)&&e.host||getDocumentElement(e);return isShadowRoot(t)?t.host:t}function getNearestOverflowAncestor(e){const t=getParentNode(e);return isLastTraversableNode(t)?e.ownerDocument?e.ownerDocument.body:e.body:isHTMLElement(t)&&isOverflowElement(t)?t:getNearestOverflowAncestor(t)}function getOverflowAncestors(e,t,n){var o;t===void 0&&(t=[]);n===void 0&&(n=true);const r=getNearestOverflowAncestor(e);const i=r===((o=e.ownerDocument)==null?void 0:o.body);const l=getWindow(r);if(i){const e=getFrameElement(l);return t.concat(l,l.visualViewport||[],isOverflowElement(r)?r:[],e&&n?getOverflowAncestors(e):[])}return t.concat(r,getOverflowAncestors(r,[],n))}function getFrameElement(e){return Object.getPrototypeOf(e.parent)?e.frameElement:null}export{getComputedStyle,getContainingBlock,getDocumentElement,getFrameElement,getNearestOverflowAncestor,getNodeName,getNodeScroll,getOverflowAncestors,getParentNode,getWindow,isContainingBlock,isElement,isHTMLElement,isLastTraversableNode,isNode,isOverflowElement,isShadowRoot,isTableElement,isTopLayer,isWebKit};
|
||||||
|
|
2
vendor/javascript/@floating-ui--utils.js
vendored
Normal file
2
vendor/javascript/@floating-ui--utils.js
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
const t=["top","right","bottom","left"];const e=["start","end"];const n=t.reduce(((t,n)=>t.concat(n,n+"-"+e[0],n+"-"+e[1])),[]);const i=Math.min;const o=Math.max;const g=Math.round;const c=Math.floor;const createCoords=t=>({x:t,y:t});const s={left:"right",right:"left",bottom:"top",top:"bottom"};const r={start:"end",end:"start"};function clamp(t,e,n){return o(t,i(e,n))}function evaluate(t,e){return typeof t==="function"?t(e):t}function getSide(t){return t.split("-")[0]}function getAlignment(t){return t.split("-")[1]}function getOppositeAxis(t){return t==="x"?"y":"x"}function getAxisLength(t){return t==="y"?"height":"width"}function getSideAxis(t){return["top","bottom"].includes(getSide(t))?"y":"x"}function getAlignmentAxis(t){return getOppositeAxis(getSideAxis(t))}function getAlignmentSides(t,e,n){n===void 0&&(n=false);const i=getAlignment(t);const o=getAlignmentAxis(t);const g=getAxisLength(o);let c=o==="x"?i===(n?"end":"start")?"right":"left":i==="start"?"bottom":"top";e.reference[g]>e.floating[g]&&(c=getOppositePlacement(c));return[c,getOppositePlacement(c)]}function getExpandedPlacements(t){const e=getOppositePlacement(t);return[getOppositeAlignmentPlacement(t),e,getOppositeAlignmentPlacement(e)]}function getOppositeAlignmentPlacement(t){return t.replace(/start|end/g,(t=>r[t]))}function getSideList(t,e,n){const i=["left","right"];const o=["right","left"];const g=["top","bottom"];const c=["bottom","top"];switch(t){case"top":case"bottom":return n?e?o:i:e?i:o;case"left":case"right":return e?g:c;default:return[]}}function getOppositeAxisPlacements(t,e,n,i){const o=getAlignment(t);let g=getSideList(getSide(t),n==="start",i);if(o){g=g.map((t=>t+"-"+o));e&&(g=g.concat(g.map(getOppositeAlignmentPlacement)))}return g}function getOppositePlacement(t){return t.replace(/left|right|bottom|top/g,(t=>s[t]))}function expandPaddingObject(t){return{top:0,right:0,bottom:0,left:0,...t}}function getPaddingObject(t){return typeof t!=="number"?expandPaddingObject(t):{top:t,right:t,bottom:t,left:t}}function rectToClientRect(t){const{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}export{e as alignments,clamp,createCoords,evaluate,expandPaddingObject,c as floor,getAlignment,getAlignmentAxis,getAlignmentSides,getAxisLength,getExpandedPlacements,getOppositeAlignmentPlacement,getOppositeAxis,getOppositeAxisPlacements,getOppositePlacement,getPaddingObject,getSide,getSideAxis,o as max,i as min,n as placements,rectToClientRect,g as round,t as sides};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue