PointsEditor
Editor for a set of points, each one dragged within the same area.
A point is placed by its position in the container, so its value is { x, y }
in the range 0..1 on each axis, with y growing downwards. Points can be
selected and moved together, with ctrl / meta or by dragging a
<PointsEditor.SelectionBox /> over them.
Drag a point, or click one and use the arrow keys. The wheel moves the focused point from anywhere over the editor, and shift switches it to the x axis.
- React
- Svelte
- Vue
function App() { const [points, setPoints] = useState({ 'p-0': { x: 0, y: 0.5 }, 'p-1': { x: 0.35, y: 0.15 }, 'p-2': { x: 0.7, y: 0.85 }, 'p-3': { x: 1, y: 0.5 }, }) return ( <div> <PointsEditor.Root className={pointsEditorTheme.root} selectable> <PointsEditor.Background style={{ background: '#f2f4f5', borderRadius: 4 }} /> <PointsEditor.Container> {Object.entries(points).map(([id, point]) => ( <PointsEditor.Point className={pointsEditorTheme.point} key={id} id={id} value={point} onChange={(value) => setPoints((prev) => ({ ...prev, [id]: value })) } /> ))} <PointsEditor.SelectionBox className={pointsEditorTheme.selectionBox} /> </PointsEditor.Container> </PointsEditor.Root> <p> {Object.entries(points) .map(([id, { x, y }]) => `${id}: (${x}, ${y})`) .join(' / ')} </p> </div> ) }
/** The demo theme for `PointsEditor`, as a CSS Module: one class per part, applied by* the caller. Copy this file into your project and edit it.*/.root {width: 200px;height: 100px;}.point {--color: oklch(59.5% 0.17548 266.373);width: 16px;height: 16px;border-radius: 50%;outline: none;cursor: grab;background-color: var(--color);&[data-dragging] {cursor: inherit;}/* Dragging does nothing while readonly, so do not offer to. */&[data-readonly] {cursor: default;}/* An outline rather than a box-shadow: focus is usually drawn with ashadow, and a selection that looks the same is a selection nobody cantell from focus. */&[data-selected] {outline: 2px solid var(--color);outline-offset: 2px;}&:focus-within {box-shadow: 0 0 0 3px color-mix(in srgb, var(--color) 20%, transparent);}}/* The selection box, while a drag on empty space is running. */.selectionBox {--color: oklch(59.5% 0.17548 266.373);border: 1px solid var(--color);background-color: color-mix(in srgb, var(--color) 12%, transparent);}
<script lang="ts">
import { PointsEditor } from '@tremolo-ui/svelte'
import type { PointPosition } from '@tremolo-ui/dom'
// Copy this file from the Styling page into your own project.
import pointsEditorTheme from './PointsEditor.module.css'
let points: Record<string, PointPosition> = $state({
'p-0': { x: 0, y: 0.5 },
'p-1': { x: 0.35, y: 0.15 },
'p-2': { x: 0.7, y: 0.85 },
'p-3': { x: 1, y: 0.5 },
})
</script>
<PointsEditor.Root class={pointsEditorTheme.root} selectable>
<PointsEditor.Background style="background: #f2f4f5; border-radius: 4px" />
<PointsEditor.Container>
{#each Object.keys(points) as id (id)}
<PointsEditor.Point
class={pointsEditorTheme.point}
{id}
value={points[id]}
onChange={(value) => (points[id] = value)}
/>
{/each}
<PointsEditor.SelectionBox class={pointsEditorTheme.selectionBox} />
</PointsEditor.Container>
</PointsEditor.Root>
<script setup lang="ts">
import { reactive } from 'vue'
import type { PointPosition } from '@tremolo-ui/dom'
import {
PointsEditor,
PointsEditorBackground,
PointsEditorContainer,
PointsEditorPoint,
PointsEditorSelectionBox,
} from '@tremolo-ui/vue'
// Copy this file from the Styling page into your own project.
import pointsEditorTheme from './PointsEditor.module.css'
const points = reactive<Record<string, PointPosition>>({
'p-0': { x: 0, y: 0.5 },
'p-1': { x: 0.35, y: 0.15 },
'p-2': { x: 0.7, y: 0.85 },
'p-3': { x: 1, y: 0.5 },
})
</script>
<template>
<PointsEditor :class="pointsEditorTheme.root" selectable>
<PointsEditorBackground style="background: #f2f4f5; border-radius: 4px" />
<PointsEditorContainer>
<PointsEditorPoint
v-for="(_, id) in points"
:id="id"
:key="id"
v-model="points[id]"
:class="pointsEditorTheme.point"
/>
<PointsEditorSelectionBox :class="pointsEditorTheme.selectionBox" />
</PointsEditorContainer>
</PointsEditor>
</template>
Import
- React
- Svelte
- Vue
import { PointsEditor } from '@tremolo-ui/react'
import { PointsEditor } from '@tremolo-ui/svelte'
import {
PointsEditor,
PointsEditorBackground,
PointsEditorContainer,
PointsEditorPoint,
PointsEditorSelectionBox,
} from '@tremolo-ui/vue'
API Reference
Each part lists the props of its own. It also takes the attributes of the
element it renders, such as className and style.
In Svelte and Vue the props are the same, written their way — class rather
than className, bind:value or v-model, snippets or slots for content. See
Svelte and Vue.
State is on the element as a data attribute, so it is a selector away — see Styling. An attribute is only there while its state is on.
Root
Holds the selection and the settings every point shares.
| Prop | Type | Default |
|---|---|---|
disabled | boolean | — |
readOnly | boolean | — |
dragCursor | Cursor | 'grabbing' |
wheel | ModifierValue<InputEventOption> | null | ['normalized', 0.01] |
keyboard | ModifierValue<InputEventOption> | null | { default: ['normalized', 0.01], shift: ['normalized', 0.001] } |
dragSensitivity | ModifierValue<number> | { default: 1, shift: 0.1 } |
selectable | boolean | false |
selection | string[] | — |
defaultSelection | string[] | — |
onSelectionChange | (selection: string[]) => void | — |
children* | ReactNode | — |
Full reference: PointsEditorProps
Set the editor's width and height with CSS or style.
| Data attribute | Values |
|---|---|
[data-disabled] | Present when disabled is set |
[data-readonly] | Present when readonly is set |
Background
Drawn behind the points. Whatever they are placed over goes inside it: a graph, a canvas, an image.
It has no props of its own and takes the attributes of a <div>.
Container
The area the points are placed in. A drag on its empty space draws a
selection. PointsEditor.Point goes inside it.
It has no props of its own and takes the attributes of a <div>.
Point
One point, moved by dragging, the arrow keys or the wheel. Two visually hidden range inputs inside it, one per axis, take the focus and carry the ARIA. Children are drawn inside the point; the theme's own dot stands when there are none.
| Prop | Type | Default |
|---|---|---|
value* | T | — |
id | string | — |
min | Partial<PointPosition> | — |
max | Partial<PointPosition> | — |
color | string | — |
disabled | boolean | — |
readOnly | boolean | — |
wheel | ModifierValue<InputEventOption> | null | — |
keyboard | ModifierValue<InputEventOption> | null | — |
aria-label | string | Partial<Record<"x" | "y", string>> | — |
aria-valuetext | string | Partial<Record<"x" | "y", string>> | — |
onChange | (value: PointPosition) => void | — |
onChangeStart | (value: PointPosition, source: ChangeSource) => void | — |
onChangeEnd | (value: PointPosition, source: ChangeSource) => void | — |
changeEndDelay | number | 500 |
Full reference: PointsEditorPointProps
| Custom property | Written by | What it sets |
|---|---|---|
--color | color | Colour of the point. |
Set a point's width and height with CSS or style.
| Data attribute | Values |
|---|---|
[data-disabled] | Present when disabled is set, on the point or on Root |
[data-readonly] | Present when readonly is set, on the point or on Root |
[data-dragging] | Present while the point is being dragged |
[data-selected] | Present while the point is in the selection |
SelectionBox
The box a drag on empty space draws; the points it covers are selected. Nothing is rendered while no drag is running, children included.
It has no props of its own and takes the attributes of a <div>.