Skip to content

Instantly share code, notes, and snippets.

@vbiamitr
Last active January 29, 2023 10:43
Show Gist options
  • Save vbiamitr/f39f26dc93d95251912e817d6c266ed6 to your computer and use it in GitHub Desktop.
Save vbiamitr/f39f26dc93d95251912e817d6c266ed6 to your computer and use it in GitHub Desktop.
SVG based Select / Dropdown element using D3
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://d3js.org/d3.v4.min.js">
</script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js">
</script>
<style>
body {
margin: 0;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.root {
position: absolute;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
font-family: "Calibri,Candara,Segoe,Segoe UI,Optima,Arial,sans-serif"
}
.input-container {
float: left;
width: 50%;
height: 100%;
}
</style>
<title></title>
</head>
<body>
<div class="root">
<div class="input-container">
<svg width="50%" height="100%"></svg>
</div>
<div class="input-container">
<p> Selected Value gets populated in the input box in "changeHandler" </p>
<input type="text" id="selectedInput">
</div>
</div>
<script type="text/babel">
var svg = d3.select("svg");
var members = [{
label: "BMW",
value: 1
},
{
label: "Audi",
value: 2
},
{
label: "Mercedes",
value: 3
},
{
label: "This is long text to check text overflow",
value: 4
}
];
var config = {
width: 200,
container: svg,
members,
fontSize: 14,
color: "#333",
fontFamily: "calibri",
x: 20,
y: 45,
changeHandler: function(option) {
// "this" refers to the option group
// Change handler code goes here
document.getElementById("selectedInput").value = option.label;
}
};
svgDropDown(config);
/** svg-dropdown.js - svg dropdown library */
function svgDropDown(options) {
if (typeof options !== 'object' || options === null || !options.container) {
console.error(new Error("Container not provided"));
return;
}
const defaultOptions = {
width: 200,
members: [],
fontSize: 12,
color: "#333",
fontFamily: "Calibri,Candara,Segoe,Segoe UI,Optima,Arial,sans-serif",
x: 0,
y: 0,
changeHandler: function() {}
};
options = { ...defaultOptions,
...options
};
options.optionHeight = options.fontSize * 2;
options.height = options.fontSize + 8;
options.padding = 5;
options.hoverColor = "#0c56f5";
options.hoverTextColor = "#fff";
options.bgColor = "#fff";
options.width = options.width - 2;
const g = options.container
.append("svg")
.attr("x", options.x)
.attr("y", options.y)
.attr("shape-rendering", "crispEdges")
.append("g")
.attr("transform", "translate(1,1)")
.attr("font-family", options.fontFamily);
let selectedOption =
options.members.length === 0 ? {
label: "",
value: ""
} :
options.members[0];
/** Rendering Select Field */
const selectField = g.append("g");
// background
selectField
.append("rect")
.attr("width", options.width)
.attr("height", options.height)
.attr("class", "option select-field")
.attr("fill", options.bgColor)
.style("stroke", "#a0a0a0")
.style("stroke-width", "1");
// text
const activeText = selectField
.append("text")
.text(selectedOption.label)
.attr("x", options.padding)
.attr("y", options.height / 2 + options.fontSize / 3)
.attr("font-size", options.fontSize)
.attr("fill", options.color);
// arrow symbol at the end of the select box
selectField
.append("text")
.text("▼")
.attr("x", options.width - options.fontSize - options.padding)
.attr("y", options.height / 2 + (options.fontSize - 2) / 3)
.attr("font-size", options.fontSize - 2)
.attr("fill", options.color);
// transparent surface to capture actions
selectField
.append("rect")
.attr("width", options.width)
.attr("height", options.height)
.style("fill", "transparent")
.on("click", handleSelectClick);
/** rendering options */
const optionGroup = g
.append("g")
.attr("transform", `translate(0, ${options.height})`)
.attr("opacity", 0); //.attr("display", "none"); Issue in IE/Firefox: Unable to calculate textLength when display is none.
// Rendering options group
const optionEnter = optionGroup
.selectAll("g")
.data(options.members)
.enter()
.append("g")
.on("click", handleOptionClick);
// Rendering background
optionEnter
.append("rect")
.attr("width", options.width)
.attr("height", options.optionHeight)
.attr("y", function(d, i) {
return i * options.optionHeight;
})
.attr("class", "option")
.style("stroke", options.hoverColor)
.style("stroke-dasharray", (d, i) => {
let stroke = [
0,
options.width,
options.optionHeight,
options.width,
options.optionHeight
];
if (i === 0) {
stroke = [
options.width + options.optionHeight,
options.width,
options.optionHeight
];
} else if (i === options.members.length - 1) {
stroke = [0, options.width, options.optionHeight * 2 + options.width];
}
return stroke.join(" ");
})
.style("stroke-width", 1)
.style("fill", options.bgColor);
// Rendering option text
optionEnter
.append("text")
.attr("x", options.padding)
.attr("y", function(d, i) {
return (
i * options.optionHeight +
options.optionHeight / 2 +
options.fontSize / 3
);
})
.text(function(d) {
return d.label;
})
.attr("font-size", options.fontSize)
.attr("fill", options.color)
.each(wrap);
// Rendering option surface to take care of events
optionEnter
.append("rect")
.attr("width", options.width)
.attr("height", options.optionHeight)
.attr("y", function(d, i) {
return i * options.optionHeight;
})
.style("fill", "transparent")
.on("mouseover", handleMouseOver)
.on("mouseout", handleMouseOut);
//once the textLength gets calculated, change opacity to 1 and display to none
optionGroup.attr("display", "none").attr("opacity", 1);
d3.select("body").on("click", function() {
optionGroup.attr("display", "none");
});
// Utility Methods
function handleMouseOver() {
d3.select(d3.event.target.parentNode)
.select(".option")
.style("fill", options.hoverColor);
d3.select(d3.event.target.parentNode)
.select("text")
.style("fill", options.hoverTextColor);
}
function handleMouseOut() {
d3.select(d3.event.target.parentNode)
.select(".option")
.style("fill", options.bgColor);
d3.select(d3.event.target.parentNode)
.select("text")
.style("fill", options.color);
}
function handleOptionClick(d) {
d3.event.stopPropagation();
selectedOption = d;
activeText.text(selectedOption.label).each(wrap);
typeof options.changeHandler === 'function' && options.changeHandler.call(this, d);
optionGroup.attr("display", "none");
}
function handleSelectClick() {
d3.event.stopPropagation();
const visibility = optionGroup.attr("display") === "block" ? "none" : "block";
optionGroup.attr("display", visibility);
}
// wraps words
function wrap() {
const width = options.width;
const padding = options.padding;
const self = d3.select(this);
let textLength = self.node().getComputedTextLength();
let text = self.text();
const textArr = text.split(/\s+/);
let lastWord = "";
while (textLength > width - 2 * padding && text.length > 0) {
lastWord = textArr.pop();
text = textArr.join(" ");
self.text(text);
textLength = self.node().getComputedTextLength();
}
self.text(text + " " + lastWord);
// providing ellipsis to last word in the text
if (lastWord) {
textLength = self.node().getComputedTextLength();
text = self.text();
while (textLength > width - 2 * padding && text.length > 0) {
text = text.slice(0, -1);
self.text(text + "...");
textLength = self.node().getComputedTextLength();
}
}
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment