Interaction
A chart is made of HTML elements, so everything you already do on a page works here: hover states in CSS, click handlers, focus, keyboard keys.
Hover with CSS only
Most hover effects need no JavaScript at all: style :hover in the slat’s CSS and the browser does the rest.
import { Chart, Plot, Bar, Label, slat } from "@bezda/rhp";
// Point at a slat: its band lights up and its number appears. Only CSS.
const City = slat(
{
css: `
.slat:hover {
background: color-mix(in srgb, var(--rhp-ink) 8%, transparent);
}
.tip { opacity: 0; transition: opacity .15s; }
.slat:hover .tip { opacity: 1; }
`,
},
(d) => (
<div class="slat">
<Label edge="start">{d.city}</Label>
<Bar to={d.rain} />
<Label at={d.rain}>
<span class="tip">{d.rain} mm</span>
</Label>
</div>
),
);
export default function Rain() {
return (
<Chart scale={[0, 200]}>
<Plot
city={["Bergen", "Glasgow", "Dublin", "London"]}
rain={[184, 124, 81, 56]}
>
{City}
</Plot>
</Chart>
);
}Notice that the fade is on a span inside the Label, not on the Label itself.
rhp moves a block with a transition of its own, and a transition you set on that block would replace it, so the block would jump to its new position instead of gliding.
Clicks
Blocks take handlers like any element: onClick, onPointerEnter, onKeyDown.
With many slats, one handler on an element around the chart is simpler than one per block: give the blocks a data- attribute, and find it from the event with closest.
What the handler decides goes back into the Plot as data.
Here the picked city becomes d.faded for every other row, and the CSS fades those slats.
import { createSignal } from "solid-js";
import { Chart, Plot, Bar, Label, slat } from "@bezda/rhp";
// Click a bar to pick its city; the others fade.
const City = slat(
{
css: `
.bar { cursor: pointer; }
.faded .bar { opacity: .3; }
`,
},
(d) => (
<div class={d.faded ? "faded" : ""}>
<Label edge="start">{d.city}</Label>
<Bar to={d.rain} class="bar" data-city={d.city} />
<Label at={d.rain}>{d.rain} mm</Label>
</div>
),
);
export default function Rain() {
const [picked, setPicked] = createSignal();
const onClick = (e) => {
const city = e.target.closest("[data-city]")?.dataset.city;
if (city) setPicked(picked() === city ? undefined : city);
};
return (
<div onClick={onClick}>
<Chart scale={[0, 200]}>
<Plot
city={["Bergen", "Glasgow", "Dublin", "London"]}
rain={[184, 124, 81, 56]}
faded={(d) => picked() !== undefined && d.city !== picked()}
>
{City}
</Plot>
</Chart>
</div>
);
}Keyboard
Add keyboard to a Plot, and its slats take focus.
Tab stops at one slat of the chart, the arrow keys go to the slat shown before or after it, and Home and End to the first and the last.
Style :focus-visible next to :hover, and what the pointer shows, the keyboard shows too.
import { Chart, Plot, Bar, Label, slat } from "@bezda/rhp";
// Tab to the chart, then use the arrow keys: the slat with focus shows its number.
const City = slat(
{
css: `
.slat:is(:hover, :focus-visible) {
background: color-mix(in srgb, var(--rhp-ink) 8%, transparent);
}
.tip { opacity: 0; transition: opacity .15s; }
.slat:is(:hover, :focus-visible) .tip { opacity: 1; }
`,
},
(d) => (
<div class="slat">
<Label edge="start">{d.city}</Label>
<Bar to={d.rain} />
<Label at={d.rain}>
<span class="tip">{d.rain} mm</span>
</Label>
</div>
),
);
export default function Rain() {
return (
<Chart scale={[0, 200]} label="Rain in October">
<Plot
keyboard
city={["Bergen", "Glasgow", "Dublin", "London"]}
rain={[184, 124, 81, 56]}
>
{City}
</Plot>
</Chart>
);
}- Tab comes back to the slat focused last, so a reader who leaves the chart picks up where they were.
- The keys follow the order on screen, also when the rows are sorted, and a slat keeps its focus when it moves.
- For code, use
onFocuson the slat, as you wouldonPointerEnter. - A key that something inside the slat uses stays with it: a field, or a handler that calls
preventDefault().
A <button> inside a Label (a name you can click, for example) gets focus and keyboard presses on its own, in a Plot with or without keyboard.
One element instead of one per slat
The hover demo above keeps a tip in every slat and shows one of them with CSS. For a number or a highlight that is the right thing: no state, nothing to wire, and the browser does all of it.
It stops being right when what shows is more than a number. A card, a menu of actions, an image, a small chart of its row’s own data: one in every slat is a subtree per slat so that a single one can be seen, and in a chart of hundreds of slats most are never pointed at. Keep the row the reader is on in a signal instead, and draw the card in that slat alone.
import { createSelector, createSignal, Show } from "solid-js";
import { Chart, Plot, Bar, Label, slat } from "@bezda/rhp";
// Point at a slat, or Tab in and use the arrow keys: one card, drawn in that slat only.
const City = slat(
{
room: { start: 88, end: 124 },
css: `
.slat:is(:hover, :focus-visible) .bar {
filter: brightness(1.15);
}
.card {
display: flex;
gap: 7px;
align-items: baseline;
padding: 3px 8px;
border-radius: 4px;
background: var(--rhp-ink);
color: var(--rhp-surface);
}
.card b { font-weight: 700; }
.card span { opacity: .75; }
`,
},
(d) => (
<div class="slat" data-row={d.index}>
<Label edge="start">{d.city}</Label>
<Bar to={d.rain} class="bar" />
<Show when={d.on}>
<Label at={d.rain} class="card">
<b>{d.rain} mm</b>
<span>{d.days} wet days</span>
</Label>
</Show>
</div>
),
);
export default function Rain() {
// The row the reader is on. Only the two slats that change redraw.
const [on, setOn] = createSignal(null);
const isOn = createSelector(on);
const rowAt = (e) => {
const el = e.target.closest("[data-row]");
return el ? +el.dataset.row : null;
};
return (
<div
onPointerMove={(e) => setOn(rowAt(e))}
onPointerDown={(e) => setOn(rowAt(e))}
onPointerLeave={(e) => e.pointerType !== "touch" && setOn(null)}
onFocusIn={(e) => setOn(rowAt(e))}
onFocusOut={(e) =>
!e.currentTarget.contains(e.relatedTarget) && setOn(null)
}
>
<Chart scale={[0, 200]}>
<Plot
keyboard
city={["Bergen", "Glasgow", "Dublin", "London", "Madrid", "Cairo"]}
rain={[184, 124, 81, 56, 37, 5]}
days={[19, 17, 13, 11, 6, 1]}
on={(d) => isOn(d.index)}
>
{City}
</Plot>
</Chart>
</div>
);
}Showis what saves the nodes. The card exists in the slat the reader is on and nothing is in the page for the others, so the chart holds one card rather than one per slat. Whatever is expensive belongs inside it: an image, a fetch, a Plot of its own.createSelectorkeeps the work to two slats. Moving from one slat to the next draws those two again, however many there are. Passing the row in as a value (on={on()}) has every slat read it instead, which is the cost this pattern is here to avoid. A data group that is a function of the row, likeon={(d) => isOn(d.index)}above, is worked out per slat and cached, so it stays at two.- The row comes from the event.
data-row={d.index}on the slat’s root names its row, andclosestfinds it from whatever the pointer is actually on, a block or an element inside one. An edge label is drawn in the gutter, outside the plot, and is still inside the slat’s root, so pointing at a name counts as pointing at its row. When rows come and go, hold the row’s key rather than its number (data-row={d.city}), because removing a row moves the numbers of the rows after it. - The keyboard gets the same card.
focusinandfocusoutsit next to the pointer handlers, and withkeyboardon the Plot, Tab and the arrow keys move the card exactly as the pointer does. A card that only follows:hoveris nothing to a reader who doesn’t use a pointer. pointerdowncovers a tap, and letting apointerleavewhosepointerTypeis"touch"pass keeps what the tap chose once the finger lifts.- The state is yours.
A legend, a click that pins a row (
on() ?? pinned()), a readout above the chart or a second chart can all read it and write it. rhp keeps no interaction state of its own to get in the way. - What isn’t per slat is a Plot of its own.
A crosshair, a line marking today or a band across the whole plot is one Plot over the other, fed with the picked row’s values:
<Plot overlap slats={on() == null ? 0 : 1} …>. It draws one set of elements, and none at all while nothing is picked.
Good to know
- Follow the pointer with
pointermove, notpointerover. When a hover changes the layout (a badge appears), the browser can firepointeroveragain under a pointer that hasn’t moved. - A Plot drawn over another one covers it. Give an overlay, such as a line marking today,
style={{ "pointer-events": "none" }}so the pointer reaches the Plot under it. - Page listeners and tests work as usual. Charts are plain elements in the page:
querySelector, testing libraries and click listeners all see them.