A compass bearing control that shows the heading in degrees and as a cardinal point (N, NE, E and so on), snaps to 5 degrees and has quick buttons for the four directions.
registry/patterns/angle-slider-heading-compass/angle-slider-heading-compass.tsx
"use client"
import * as React from "react"
import { AngleSlider } from "@/components/ui/angle-slider"
import { Button } from "@/components/ui/button"
export interface AngleSliderHeadingCompassProps {
heading?: number
onChange?: (degrees: number, cardinal: string) => void
}
const POINTS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
/** The eight-point compass name for a bearing in degrees. */
export function cardinal(degrees: number) {
return POINTS[Math.round((((degrees % 360) + 360) % 360) / 45) % 8]
}
/**
* Bearings are spoken as words as often as numbers, so the control prints both
* ("135° SE"). The slider snaps to 5 degrees for a steadier hand; the four
* buttons jump to N, E, S and W for the common case, and the live text is the
* only thing announced, not every intermediate drag value.
*/
export function AngleSliderHeadingCompass({
heading: initial = 0,
onChange,
}: AngleSliderHeadingCompassProps) {
const [heading, setHeading] = React.useState(initial)
const set = (d: number) => {
setHeading(d)
onChange?.(d, cardinal(d))
}
return (
<div className="bg-card flex w-fit flex-col items-center gap-3 rounded-xl border p-5">
<AngleSlider
label="Heading"
value={heading}
step={5}
onValueChange={set}
/>
<p className="text-lg font-semibold tabular-nums">
{heading}° {cardinal(heading)}
</p>
<p role="status" className="sr-only">
Heading {heading} degrees, {cardinal(heading)}
</p>
<div role="group" aria-label="Quick headings" className="flex gap-1.5">
{[
["N", 0],
["E", 90],
["S", 180],
["W", 270],
].map(([label, deg]) => (
<Button
key={label}
type="button"
size="sm"
variant={heading === deg ? "default" : "outline"}
aria-pressed={heading === deg}
onClick={() => set(deg as number)}
>
{label}
</Button>
))}
</div>
</div>
)
}