mirror of
https://github.com/we-promise/sure.git
synced 2026-05-31 08:19:03 +00:00
Merge origin/main into feat/goals-v2-architecture
Resolved conflicts:
- db/schema.rb: take main's schema version (later migration timestamp);
goals + debug_log_entries tables both present.
- app/views/categories/_form.html.erb: keep branch's shared
color-icon-picker controller action; adopt main's t('.auto_adjust') i18n.
This commit is contained in:
@@ -13,6 +13,8 @@ export default class extends Controller {
|
||||
static values = {
|
||||
singularLabel: String,
|
||||
pluralLabel: String,
|
||||
selectedLabel: { type: String, default: "selected" },
|
||||
editLabel: { type: String, default: "Edit" },
|
||||
selectedIds: { type: Array, default: [] },
|
||||
};
|
||||
|
||||
@@ -28,7 +30,7 @@ export default class extends Controller {
|
||||
|
||||
bulkEditDrawerHeaderTargetConnected(element) {
|
||||
const headingTextEl = element.querySelector("h2");
|
||||
headingTextEl.innerText = `Edit ${
|
||||
headingTextEl.innerText = `${this.editLabelValue} ${
|
||||
this.selectedIdsValue.length
|
||||
} ${this._pluralizedResourceName()}`;
|
||||
}
|
||||
@@ -132,7 +134,7 @@ export default class extends Controller {
|
||||
|
||||
_updateSelectionBar() {
|
||||
const count = this.selectedIdsValue.length;
|
||||
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;
|
||||
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} ${this.selectedLabelValue}`;
|
||||
this.selectionBarTarget.classList.toggle("hidden", count === 0);
|
||||
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
|
||||
count > 0;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
import { sankey } from "d3-sankey";
|
||||
import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom";
|
||||
|
||||
// Connects to data-controller="sankey-chart"
|
||||
export default class extends Controller {
|
||||
static targets = ["chart", "zoomOutButton"];
|
||||
|
||||
static values = {
|
||||
data: Object,
|
||||
nodeWidth: { type: Number, default: 15 },
|
||||
nodePadding: { type: Number, default: 20 },
|
||||
currencySymbol: { type: String, default: "$" }
|
||||
currencySymbol: { type: String, default: "$" },
|
||||
};
|
||||
|
||||
// Visual constants
|
||||
@@ -18,64 +21,158 @@ export default class extends Controller {
|
||||
static MIN_NODE_PADDING = 4;
|
||||
static MAX_PADDING_RATIO = 0.4;
|
||||
static CORNER_RADIUS = 8;
|
||||
static ZOOM_TRANSITION_MS = 220;
|
||||
static DEFAULT_COLOR = "var(--color-gray-400)";
|
||||
static CSS_VAR_MAP = {
|
||||
"var(--color-success)": "#10A861",
|
||||
"var(--color-destructive)": "#EC2222",
|
||||
"var(--color-gray-400)": "#9E9E9E",
|
||||
"var(--color-gray-500)": "#737373"
|
||||
"var(--color-gray-500)": "#737373",
|
||||
};
|
||||
static MIN_LABEL_SPACING = 28; // Minimum vertical space needed for labels (2 lines)
|
||||
|
||||
connect() {
|
||||
this.connected = true;
|
||||
this.zoomRootId = null;
|
||||
this.resizeObserver = new ResizeObserver(() => this.#draw());
|
||||
this.resizeObserver.observe(this.element);
|
||||
this.resizeObserver.observe(this.#chartElement());
|
||||
this.tooltip = null;
|
||||
this.#createTooltip();
|
||||
this.#syncZoomControls();
|
||||
this.#draw();
|
||||
}
|
||||
|
||||
dataValueChanged() {
|
||||
if (!this.connected) return;
|
||||
|
||||
this.zoomRootId = null;
|
||||
this.#syncZoomControls();
|
||||
this.#draw();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
this.resizeObserver?.disconnect();
|
||||
clearTimeout(this.drawTimeout);
|
||||
this.tooltip?.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const { nodes = [], links = [] } = this.dataValue || {};
|
||||
zoomOut() {
|
||||
if (!this.zoomRootId) return;
|
||||
|
||||
this.zoomRootId = null;
|
||||
this.#syncZoomControls();
|
||||
this.#draw({ animate: true });
|
||||
}
|
||||
|
||||
#draw({ animate = false } = {}) {
|
||||
const { nodes = [], links = [] } = this.#visibleData();
|
||||
if (!nodes.length || !links.length) return;
|
||||
|
||||
// Hide tooltip and reset any hover states before redrawing
|
||||
this.#hideTooltip();
|
||||
|
||||
d3.select(this.element).selectAll("svg").remove();
|
||||
const chartElement = this.#chartElement();
|
||||
const chart = d3.select(chartElement);
|
||||
|
||||
const width = this.element.clientWidth || 600;
|
||||
const height = this.element.clientHeight || 400;
|
||||
clearTimeout(this.drawTimeout);
|
||||
chart.selectAll("svg").interrupt();
|
||||
|
||||
const svg = d3.select(this.element)
|
||||
if (animate) {
|
||||
chart
|
||||
.selectAll("svg")
|
||||
.transition()
|
||||
.duration(this.constructor.ZOOM_TRANSITION_MS / 2)
|
||||
.style("opacity", 0)
|
||||
.remove();
|
||||
|
||||
this.drawTimeout = setTimeout(() => {
|
||||
this.#render(nodes, links, true);
|
||||
}, this.constructor.ZOOM_TRANSITION_MS / 2);
|
||||
} else {
|
||||
chart.selectAll("svg").remove();
|
||||
this.#render(nodes, links, false);
|
||||
}
|
||||
}
|
||||
|
||||
#render(nodes, links, animate) {
|
||||
const chartElement = this.#chartElement();
|
||||
const chart = d3.select(chartElement);
|
||||
|
||||
const width = chartElement.clientWidth || 600;
|
||||
const height = chartElement.clientHeight || 400;
|
||||
|
||||
const svg = chart
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
.attr("height", height)
|
||||
.style("opacity", animate ? 0 : 1);
|
||||
|
||||
const effectivePadding = this.#calculateNodePadding(nodes.length, height);
|
||||
const sankeyData = this.#generateSankeyData(nodes, links, width, height, effectivePadding);
|
||||
const sankeyData = this.#generateSankeyData(
|
||||
nodes,
|
||||
links,
|
||||
width,
|
||||
height,
|
||||
effectivePadding,
|
||||
);
|
||||
|
||||
this.#createGradients(svg, sankeyData.links);
|
||||
|
||||
const linkPaths = this.#drawLinks(svg, sankeyData.links);
|
||||
const { nodeGroups, hiddenLabels } = this.#drawNodes(svg, sankeyData.nodes, width);
|
||||
const { nodeGroups, hiddenLabels } = this.#drawNodes(
|
||||
svg,
|
||||
sankeyData.nodes,
|
||||
width,
|
||||
);
|
||||
|
||||
this.#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels);
|
||||
|
||||
if (animate) {
|
||||
svg
|
||||
.transition()
|
||||
.duration(this.constructor.ZOOM_TRANSITION_MS / 2)
|
||||
.style("opacity", 1);
|
||||
}
|
||||
}
|
||||
|
||||
#chartElement() {
|
||||
return this.hasChartTarget ? this.chartTarget : this.element;
|
||||
}
|
||||
|
||||
#visibleData() {
|
||||
if (!this.zoomRootId) return this.dataValue || {};
|
||||
|
||||
return zoomSankeyData(this.dataValue, this.zoomRootId);
|
||||
}
|
||||
|
||||
#syncZoomControls() {
|
||||
this.zoomOutButtonTargets.forEach((button) => {
|
||||
button.hidden = !this.zoomRootId;
|
||||
});
|
||||
}
|
||||
|
||||
#zoomIn(node) {
|
||||
if (!node.id || !sankeyNodeHasChildren(this.#visibleData(), node.id))
|
||||
return;
|
||||
|
||||
this.zoomRootId = node.id;
|
||||
this.#syncZoomControls();
|
||||
this.#draw({ animate: true });
|
||||
}
|
||||
|
||||
// Dynamic padding prevents padding from dominating when there are many nodes
|
||||
#calculateNodePadding(nodeCount, height) {
|
||||
const margin = this.constructor.EXTENT_MARGIN;
|
||||
const availableHeight = height - (margin * 2);
|
||||
const maxPaddingTotal = availableHeight * this.constructor.MAX_PADDING_RATIO;
|
||||
const availableHeight = height - margin * 2;
|
||||
const maxPaddingTotal =
|
||||
availableHeight * this.constructor.MAX_PADDING_RATIO;
|
||||
const gaps = Math.max(nodeCount - 1, 1);
|
||||
const dynamicPadding = Math.min(this.nodePaddingValue, Math.floor(maxPaddingTotal / gaps));
|
||||
const dynamicPadding = Math.min(
|
||||
this.nodePaddingValue,
|
||||
Math.floor(maxPaddingTotal / gaps),
|
||||
);
|
||||
return Math.max(this.constructor.MIN_NODE_PADDING, dynamicPadding);
|
||||
}
|
||||
|
||||
@@ -84,11 +181,14 @@ export default class extends Controller {
|
||||
const sankeyGenerator = sankey()
|
||||
.nodeWidth(this.nodeWidthValue)
|
||||
.nodePadding(nodePadding)
|
||||
.extent([[margin, margin], [width - margin, height - margin]]);
|
||||
.extent([
|
||||
[margin, margin],
|
||||
[width - margin, height - margin],
|
||||
]);
|
||||
|
||||
return sankeyGenerator({
|
||||
nodes: nodes.map(d => ({ ...d })),
|
||||
links: links.map(d => ({ ...d })),
|
||||
nodes: nodes.map((d) => ({ ...d })),
|
||||
links: links.map((d) => ({ ...d })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,17 +197,20 @@ export default class extends Controller {
|
||||
|
||||
links.forEach((link, i) => {
|
||||
const gradientId = this.#gradientId(link, i);
|
||||
const gradient = defs.append("linearGradient")
|
||||
const gradient = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", gradientId)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("x1", link.source.x1)
|
||||
.attr("x2", link.target.x0);
|
||||
|
||||
gradient.append("stop")
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", this.#colorWithOpacity(link.source.color));
|
||||
|
||||
gradient.append("stop")
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", this.#colorWithOpacity(link.target.color));
|
||||
});
|
||||
@@ -132,32 +235,37 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#drawLinks(svg, links) {
|
||||
return svg.append("g")
|
||||
return svg
|
||||
.append("g")
|
||||
.attr("fill", "none")
|
||||
.selectAll("path")
|
||||
.data(links)
|
||||
.join("path")
|
||||
.attr("class", "sankey-link")
|
||||
.attr("d", d => d3.linkHorizontal()({
|
||||
source: [d.source.x1, d.y0],
|
||||
target: [d.target.x0, d.y1]
|
||||
}))
|
||||
.attr("d", (d) =>
|
||||
d3.linkHorizontal()({
|
||||
source: [d.source.x1, d.y0],
|
||||
target: [d.target.x0, d.y1],
|
||||
}),
|
||||
)
|
||||
.attr("stroke", (d, i) => `url(#${this.#gradientId(d, i)})`)
|
||||
.attr("stroke-width", d => Math.max(1, d.width))
|
||||
.attr("stroke-width", (d) => Math.max(1, d.width))
|
||||
.style("transition", "opacity 0.3s ease");
|
||||
}
|
||||
|
||||
#drawNodes(svg, nodes, width) {
|
||||
const nodeGroups = svg.append("g")
|
||||
const nodeGroups = svg
|
||||
.append("g")
|
||||
.selectAll("g")
|
||||
.data(nodes)
|
||||
.join("g")
|
||||
.style("transition", "opacity 0.3s ease");
|
||||
|
||||
nodeGroups.append("path")
|
||||
.attr("d", d => this.#nodePath(d))
|
||||
.attr("fill", d => d.color || this.constructor.DEFAULT_COLOR)
|
||||
.attr("stroke", d => d.color ? "none" : "var(--color-gray-500)");
|
||||
nodeGroups
|
||||
.append("path")
|
||||
.attr("d", (d) => this.#nodePath(d))
|
||||
.attr("fill", (d) => d.color || this.constructor.DEFAULT_COLOR)
|
||||
.attr("stroke", (d) => (d.color ? "none" : "var(--color-gray-500)"));
|
||||
|
||||
const hiddenLabels = this.#addNodeLabels(nodeGroups, width, nodes);
|
||||
|
||||
@@ -167,10 +275,15 @@ export default class extends Controller {
|
||||
#nodePath(node) {
|
||||
const { x0, y0, x1, y1 } = node;
|
||||
const height = y1 - y0;
|
||||
const radius = Math.max(0, Math.min(this.constructor.CORNER_RADIUS, height / 2));
|
||||
const radius = Math.max(
|
||||
0,
|
||||
Math.min(this.constructor.CORNER_RADIUS, height / 2),
|
||||
);
|
||||
|
||||
const isSourceNode = node.sourceLinks?.length > 0 && !node.targetLinks?.length;
|
||||
const isTargetNode = node.targetLinks?.length > 0 && !node.sourceLinks?.length;
|
||||
const isSourceNode =
|
||||
node.sourceLinks?.length > 0 && !node.targetLinks?.length;
|
||||
const isTargetNode =
|
||||
node.targetLinks?.length > 0 && !node.sourceLinks?.length;
|
||||
|
||||
// Too small for rounded corners
|
||||
if (height < radius * 2) {
|
||||
@@ -215,14 +328,18 @@ export default class extends Controller {
|
||||
const controller = this;
|
||||
const hiddenLabels = this.#calculateHiddenLabels(nodes);
|
||||
|
||||
nodeGroups.append("text")
|
||||
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
|
||||
.attr("y", d => (d.y1 + d.y0) / 2)
|
||||
nodeGroups
|
||||
.append("text")
|
||||
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
|
||||
.attr("y", (d) => (d.y1 + d.y0) / 2)
|
||||
.attr("dy", "-0.2em")
|
||||
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
|
||||
.attr("class", "text-xs font-medium text-primary fill-current select-none")
|
||||
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
|
||||
.attr(
|
||||
"class",
|
||||
"text-xs font-medium text-primary fill-current select-none",
|
||||
)
|
||||
.style("cursor", "default")
|
||||
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
|
||||
.style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1))
|
||||
.style("transition", "opacity 0.2s ease")
|
||||
.each(function (d) {
|
||||
const textEl = d3.select(this);
|
||||
@@ -230,7 +347,8 @@ export default class extends Controller {
|
||||
|
||||
textEl.append("tspan").text(d.name);
|
||||
|
||||
textEl.append("tspan")
|
||||
textEl
|
||||
.append("tspan")
|
||||
.attr("x", textEl.attr("x"))
|
||||
.attr("dy", "1.2em")
|
||||
.attr("class", "font-mono text-secondary")
|
||||
@@ -244,26 +362,28 @@ export default class extends Controller {
|
||||
// Calculate which labels should be hidden to prevent overlap
|
||||
#calculateHiddenLabels(nodes) {
|
||||
const hiddenLabels = new Set();
|
||||
const height = this.element.clientHeight || 400;
|
||||
const height = this.#chartElement().clientHeight || 400;
|
||||
const isLargeGraph = height > 600;
|
||||
const minSpacing = isLargeGraph ? this.constructor.MIN_LABEL_SPACING * 0.7 : this.constructor.MIN_LABEL_SPACING;
|
||||
const minSpacing = isLargeGraph
|
||||
? this.constructor.MIN_LABEL_SPACING * 0.7
|
||||
: this.constructor.MIN_LABEL_SPACING;
|
||||
|
||||
// Group nodes by column (using depth which d3-sankey assigns)
|
||||
const columns = new Map();
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
const depth = node.depth;
|
||||
if (!columns.has(depth)) columns.set(depth, []);
|
||||
columns.get(depth).push(node);
|
||||
});
|
||||
|
||||
// For each column, check for overlapping labels
|
||||
columns.forEach(columnNodes => {
|
||||
columns.forEach((columnNodes) => {
|
||||
// Sort by vertical position
|
||||
columnNodes.sort((a, b) => ((a.y0 + a.y1) / 2) - ((b.y0 + b.y1) / 2));
|
||||
columnNodes.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2);
|
||||
|
||||
let lastVisibleY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
columnNodes.forEach(node => {
|
||||
columnNodes.forEach((node) => {
|
||||
const nodeY = (node.y0 + node.y1) / 2;
|
||||
const nodeHeight = node.y1 - node.y0;
|
||||
|
||||
@@ -284,25 +404,41 @@ export default class extends Controller {
|
||||
#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels) {
|
||||
const applyHover = (targetLinks) => {
|
||||
const targetSet = new Set(targetLinks);
|
||||
const connectedNodes = new Set(targetLinks.flatMap(l => [l.source, l.target]));
|
||||
const connectedNodes = new Set(
|
||||
targetLinks.flatMap((l) => [l.source, l.target]),
|
||||
);
|
||||
|
||||
linkPaths
|
||||
.style("opacity", d => targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY)
|
||||
.style("filter", d => targetSet.has(d) ? this.constructor.HOVER_FILTER : "none");
|
||||
.style("opacity", (d) =>
|
||||
targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY,
|
||||
)
|
||||
.style("filter", (d) =>
|
||||
targetSet.has(d) ? this.constructor.HOVER_FILTER : "none",
|
||||
);
|
||||
|
||||
nodeGroups.style("opacity", d => connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY);
|
||||
nodeGroups.style("opacity", (d) =>
|
||||
connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY,
|
||||
);
|
||||
|
||||
// Show labels for connected nodes (even if normally hidden)
|
||||
nodeGroups.selectAll("text")
|
||||
.style("opacity", d => connectedNodes.has(d) ? 1 : (hiddenLabels.has(d.index) ? 0 : this.constructor.HOVER_OPACITY));
|
||||
nodeGroups
|
||||
.selectAll("text")
|
||||
.style("opacity", (d) =>
|
||||
connectedNodes.has(d)
|
||||
? 1
|
||||
: hiddenLabels.has(d.index)
|
||||
? 0
|
||||
: this.constructor.HOVER_OPACITY,
|
||||
);
|
||||
};
|
||||
|
||||
const resetHover = () => {
|
||||
linkPaths.style("opacity", 1).style("filter", "none");
|
||||
nodeGroups.style("opacity", 1);
|
||||
// Restore hidden labels to hidden state
|
||||
nodeGroups.selectAll("text")
|
||||
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1);
|
||||
nodeGroups
|
||||
.selectAll("text")
|
||||
.style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1));
|
||||
};
|
||||
|
||||
linkPaths
|
||||
@@ -310,33 +446,56 @@ export default class extends Controller {
|
||||
applyHover([d]);
|
||||
this.#showTooltip(event, d.value, d.percentage);
|
||||
})
|
||||
.on("mousemove", event => this.#updateTooltipPosition(event))
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("mouseleave", () => {
|
||||
resetHover();
|
||||
this.#hideTooltip();
|
||||
});
|
||||
|
||||
// Hover on node rectangles (not just text)
|
||||
nodeGroups.selectAll("path")
|
||||
.style("cursor", "default")
|
||||
nodeGroups
|
||||
.selectAll("path")
|
||||
.style("cursor", (d) =>
|
||||
sankeyNodeHasChildren(this.#visibleData(), d.id)
|
||||
? "pointer"
|
||||
: "default",
|
||||
)
|
||||
.on("mouseenter", (event, d) => {
|
||||
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
|
||||
const connectedLinks = sankeyData.links.filter(
|
||||
(l) => l.source === d || l.target === d,
|
||||
);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
})
|
||||
.on("mousemove", event => this.#updateTooltipPosition(event))
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.#zoomIn(d);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
resetHover();
|
||||
this.#hideTooltip();
|
||||
});
|
||||
|
||||
nodeGroups.selectAll("text")
|
||||
nodeGroups
|
||||
.selectAll("text")
|
||||
.style("cursor", (d) =>
|
||||
sankeyNodeHasChildren(this.#visibleData(), d.id)
|
||||
? "pointer"
|
||||
: "default",
|
||||
)
|
||||
.on("mouseenter", (event, d) => {
|
||||
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
|
||||
const connectedLinks = sankeyData.links.filter(
|
||||
(l) => l.source === d || l.target === d,
|
||||
);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
})
|
||||
.on("mousemove", event => this.#updateTooltipPosition(event))
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.#zoomIn(d);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
resetHover();
|
||||
this.#hideTooltip();
|
||||
@@ -347,9 +506,13 @@ export default class extends Controller {
|
||||
|
||||
#createTooltip() {
|
||||
const dialog = this.element.closest("dialog");
|
||||
this.tooltip = d3.select(dialog || document.body)
|
||||
this.tooltip = d3
|
||||
.select(dialog || document.body)
|
||||
.append("div")
|
||||
.attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0")
|
||||
.attr(
|
||||
"class",
|
||||
"bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0",
|
||||
)
|
||||
.style("opacity", 0)
|
||||
.style("pointer-events", "none");
|
||||
}
|
||||
@@ -381,9 +544,7 @@ export default class extends Controller {
|
||||
const x = isInDialog ? event.clientX : event.pageX;
|
||||
const y = isInDialog ? event.clientY : event.pageY;
|
||||
|
||||
this.tooltip
|
||||
?.style("left", `${x + 10}px`)
|
||||
.style("top", `${y - 10}px`);
|
||||
this.tooltip?.style("left", `${x + 10}px`).style("top", `${y - 10}px`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +561,7 @@ export default class extends Controller {
|
||||
#formatCurrency(value) {
|
||||
const formatted = Number.parseFloat(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return this.currencySymbolValue + formatted;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"
|
||||
import { autoUpdate } from "@floating-ui/dom"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "menu", "input", "content"]
|
||||
static targets = ["button", "menu", "input", "content", "option"]
|
||||
static values = {
|
||||
menuPlacement: { type: String, default: "auto" },
|
||||
offset: { type: Number, default: 6 }
|
||||
@@ -70,12 +70,14 @@ export default class extends Controller {
|
||||
const previousSelected = this.menuTarget.querySelector("[aria-selected='true']")
|
||||
if (previousSelected) {
|
||||
previousSelected.setAttribute("aria-selected", "false")
|
||||
previousSelected.setAttribute("tabindex", "-1")
|
||||
previousSelected.classList.remove("bg-container-inset")
|
||||
const prevIcon = previousSelected.querySelector(".check-icon")
|
||||
if (prevIcon) prevIcon.classList.add("hidden")
|
||||
}
|
||||
|
||||
selectedElement.setAttribute("aria-selected", "true")
|
||||
selectedElement.setAttribute("tabindex", "0")
|
||||
selectedElement.classList.add("bg-container-inset")
|
||||
const selectedIcon = selectedElement.querySelector(".check-icon")
|
||||
if (selectedIcon) selectedIcon.classList.remove("hidden")
|
||||
@@ -130,8 +132,66 @@ export default class extends Controller {
|
||||
|
||||
handleKeydown(event) {
|
||||
if (!this.isOpen) return
|
||||
if (event.key === "Escape") { this.close(); this.buttonTarget.focus() }
|
||||
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() }
|
||||
if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); return }
|
||||
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click(); return }
|
||||
|
||||
// WAI-ARIA APG listbox keyboard pattern: ArrowUp/Down moves focus
|
||||
// between options (roving tabindex), Home/End jump to first/last.
|
||||
// From the search input, ArrowDown/Up bridge into the visible
|
||||
// options so users can reach the filtered matches; other keys
|
||||
// (typing, caret movement) stay with the input.
|
||||
const fromSearch = event.target.matches('input[type="search"]')
|
||||
const visibleOptions = this.visibleOptions()
|
||||
if (fromSearch) {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
||||
if (visibleOptions.length === 0) return
|
||||
event.preventDefault()
|
||||
const targetIndex = event.key === "ArrowDown" ? 0 : visibleOptions.length - 1
|
||||
this.rovingFocus(visibleOptions, targetIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleOptions.length === 0) return
|
||||
const currentIndex = visibleOptions.indexOf(event.target)
|
||||
let nextIndex = null
|
||||
switch (event.key) {
|
||||
case "ArrowDown": nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % visibleOptions.length; break
|
||||
case "ArrowUp": nextIndex = currentIndex < 0 ? visibleOptions.length - 1 : (currentIndex - 1 + visibleOptions.length) % visibleOptions.length; break
|
||||
case "Home": nextIndex = 0; break
|
||||
case "End": nextIndex = visibleOptions.length - 1; break
|
||||
default: return
|
||||
}
|
||||
event.preventDefault()
|
||||
this.rovingFocus(visibleOptions, nextIndex)
|
||||
}
|
||||
|
||||
// Roving tabindex helper: makes the target option tabbable (and
|
||||
// focuses it), clears tabindex on every other option in the listbox.
|
||||
rovingFocus(visibleOptions, index) {
|
||||
const all = this.hasOptionTarget ? this.optionTargets : []
|
||||
const target = visibleOptions[index]
|
||||
all.forEach(opt => opt.setAttribute("tabindex", opt === target ? "0" : "-1"))
|
||||
target.focus()
|
||||
}
|
||||
|
||||
// Options the user can currently see — list-filter hides non-matches
|
||||
// by setting `style.display = "none"`. Inline check keeps it cheap.
|
||||
visibleOptions() {
|
||||
const options = this.hasOptionTarget ? this.optionTargets : []
|
||||
return options.filter(opt => opt.style.display !== "none")
|
||||
}
|
||||
|
||||
// After list-filter#filter runs, the option holding tabindex="0" may
|
||||
// be hidden. Promote the first visible option so Tab from the search
|
||||
// input still lands somewhere reachable; if none match, no-op.
|
||||
syncTabindex() {
|
||||
const visible = this.visibleOptions()
|
||||
if (visible.length === 0) return
|
||||
const tabbable = visible.find(opt => opt.getAttribute("tabindex") === "0")
|
||||
if (tabbable) return
|
||||
const all = this.hasOptionTarget ? this.optionTargets : []
|
||||
all.forEach(opt => opt.setAttribute("tabindex", "-1"))
|
||||
visible[0].setAttribute("tabindex", "0")
|
||||
}
|
||||
|
||||
handleTurboLoad() { if (this.isOpen) this.close() }
|
||||
|
||||
@@ -39,15 +39,15 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Sets or removes the data-theme attribute
|
||||
// Sets the data-theme attribute and broadcasts a `theme:change` event so
|
||||
// imperative consumers (D3/SVG/canvas) can repaint without polling.
|
||||
setTheme(isDark) {
|
||||
if (isDark) {
|
||||
localStorage.theme = "dark";
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
localStorage.theme = "light";
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
const theme = isDark ? "dark" : "light";
|
||||
localStorage.theme = theme;
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.dispatchEvent(
|
||||
new CustomEvent("theme:change", { detail: { theme } }),
|
||||
);
|
||||
}
|
||||
|
||||
systemPrefersDark() {
|
||||
|
||||
Reference in New Issue
Block a user