components/elements/SelectWidget/SelectWidget.jsxjavascript
import { useRef } from "react";
import { useEffect } from "react";
import { useElementSize } from "../../../hooks/elementSize";
import "./SelectWidget.scss";
/**
* Circular story picker ("selectWidget" element type): options are spread
* evenly around a ring; selecting one shows its title in the center and arms
* the page's "next" navigation (it does not navigate by itself).
*
* @param {Object} props
* @param {Array<{id: string, text: string, icon: string}>} [props.options] - Selectable options; `id` is a view id.
* @param {"portrait"|"landscape"} [props.orientation="portrait"] - Ring/item sizing preset (from the content JSON).
* @param {string|null} props.selectedId - Currently selected option id.
* @param {function(string=): void} props.onSelect - Called with the tapped option's id, or with no argument on an outside click (deselects).
*/
export const SelectWidget = ({ options = [], orientation = "portrait", selectedId, onSelect }) => {
const selectedOption = options.find((option) => option.id === selectedId);
const btnRef = useRef([]);
// Button positions are measured off the buttons-ring's own rendered size
// rather than a hardcoded em constant - SelectWidget.scss's `--ring-size:
// min(<em>, <N>cqmin)` can shrink that ring below its em value when space
// is tight (e.g. the iPad Air's letterboxed portrait reference), and a
// fixed em radius here would then place buttons outside the now-smaller
// ring instead of on it. Measuring the actual box is the single source of
// truth instead of duplicating that min() formula in JS.
const [buttonsRingRef, { width: ringWidth, height: ringHeight }] = useElementSize();
const ringDiameter = Math.min(ringWidth, ringHeight);
// Figma spec's item diameter as a fraction of its buttons-ring diameter
// (34.375/76.25 em portrait, 28/67 em landscape) rather than its own fixed
// em value - same reasoning as ringDiameter above: an item sized in em
// wouldn't shrink when the ring's `min(<em>, <N>cqmin)` cap shrinks it, so
// buttons would end up too big for their own ring on a small enough
// container (e.g. the iPad).
const ITEM_DIAMETER_RATIO = orientation === "portrait" ? 34.375 / 76.25 : 28 / 67;
const itemDiameter = ringDiameter * ITEM_DIAMETER_RATIO;
useEffect(() => {
const handleClickOutside = (e) => {
const isClickOnAnyButton = btnRef.current.some((btn) => btn && btn.contains(e.target));
if (!isClickOnAnyButton) {
onSelect();
}
};
// console.log(preview);
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, [onSelect]);
return (
<div className="select-widget">
<div className="select-widget__ring select-widget__ring--outer" />
<div className="select-widget__ring select-widget__ring--dashed" />
<div className="select-widget__ring select-widget__ring--buttons" ref={buttonsRingRef} />
{selectedOption && <div className="select-widget__title">{selectedOption.text}</div>}
{options.map((option, index) => {
// spread options evenly around the button ring, starting at the top
// (-90deg) and going clockwise
const angle = -90 + index * (360 / options.length);
const radians = (angle * Math.PI) / 180;
const x = (ringDiameter / 2) * Math.cos(radians);
const y = (ringDiameter / 2) * Math.sin(radians);
return (
<button
ref={(el) => (btnRef.current[index] = el)}
key={option.id}
type="button"
className={`select-widget__item${
selectedId === option.id ? " select-widget__item--selected" : ""
}`}
style={{
"--item-diameter": `${itemDiameter}px`,
"--x": `${x}px`,
"--y": `${y}px`,
}}
onClick={() => onSelect(option.id)}>
{option.icon ? (
<img className="select-widget__icon" src={option.icon} alt={option.text} />
) : (
<span className="select-widget__label">{option.text}</span>
)}
</button>
);
})}
</div>
);
};