| U[] }
4 | export type InternalSelectSettings = { keys: string[]; values: any[] }
5 |
6 | export type SelectInput = { value?: P } & SelectSettings
7 |
8 | export type SelectProps = LevaInputProps
9 |
--------------------------------------------------------------------------------
/packages/leva/src/components/String/String.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ValueInput, ValueInputProps } from '../ValueInput'
3 | import { Label, Row } from '../UI'
4 | import { useInputContext } from '../../context'
5 | import type { StringProps } from './string-types'
6 | import { styled } from '../../styles'
7 |
8 | type BaseStringProps = Pick &
9 | Omit & { editable?: boolean }
10 |
11 | const NonEditableString = styled('div', {
12 | whiteSpace: 'pre-wrap',
13 | })
14 |
15 | export function String({ displayValue, onUpdate, onChange, editable = true, ...props }: BaseStringProps) {
16 | if (editable) return
17 | return {displayValue}
18 | }
19 |
20 | export function StringComponent() {
21 | const { label, settings, displayValue, onUpdate, onChange } = useInputContext()
22 | return (
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/packages/leva/src/components/String/index.ts:
--------------------------------------------------------------------------------
1 | import * as props from './string-plugin'
2 | import { StringComponent } from './String'
3 | import { createInternalPlugin } from '../../plugin'
4 |
5 | export * from './String'
6 |
7 | export default createInternalPlugin({
8 | component: StringComponent,
9 | ...props,
10 | })
11 |
--------------------------------------------------------------------------------
/packages/leva/src/components/String/string-plugin.ts:
--------------------------------------------------------------------------------
1 | import v8n from 'v8n'
2 | import { StringInput } from './string-types'
3 |
4 | export const schema = (o: any) => v8n().string().test(o)
5 |
6 | export const sanitize = (v: any) => {
7 | if (typeof v !== 'string') throw Error(`Invalid string`)
8 | return v
9 | }
10 |
11 | export const normalize = ({ value, editable = true, rows = false }: StringInput) => {
12 | return {
13 | value,
14 | settings: { editable, rows: typeof rows === 'number' ? rows : rows ? 5 : 0 },
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/leva/src/components/String/string-types.ts:
--------------------------------------------------------------------------------
1 | import type { InputWithSettings, LevaInputProps } from '../../types'
2 |
3 | export type StringSettings = { editable?: boolean; rows?: boolean | number }
4 | export type InternalStringSettings = { editable: boolean; rows: number }
5 | export type StringInput = InputWithSettings
6 | export type StringProps = LevaInputProps
7 |
--------------------------------------------------------------------------------
/packages/leva/src/components/UI/Chevron.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { styled } from '../../styles'
3 |
4 | // TODO remove as any when this is corrected by stitches
5 | const Svg = styled('svg', {
6 | fill: 'currentColor',
7 | transition: 'transform 350ms ease, fill 250ms ease',
8 | }) as any
9 |
10 | export function Chevron({ toggled, ...props }: React.SVGProps & { toggled?: boolean }) {
11 | return (
12 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/packages/leva/src/components/UI/Misc.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import * as P from '@radix-ui/react-portal'
3 | import { ThemeContext } from '../../context'
4 | export { Overlay } from './StyledUI'
5 |
6 | // @ts-ignore
7 | export function Portal({ children, container = globalThis?.document?.body }) {
8 | const { className } = useContext(ThemeContext)!
9 | return (
10 |
11 | {children}
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/packages/leva/src/components/UI/Row.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { StyledRow, StyledInputRow } from './StyledUI'
3 |
4 | type RowProps = React.ComponentProps & { input?: boolean }
5 |
6 | export function Row({ input, ...props }: RowProps) {
7 | if (input) return
8 | return
9 | }
10 |
--------------------------------------------------------------------------------
/packages/leva/src/components/UI/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Misc'
2 | export * from './Label'
3 | export * from './Chevron'
4 | export * from './Row'
5 |
--------------------------------------------------------------------------------
/packages/leva/src/components/ValueInput/StyledInput.ts:
--------------------------------------------------------------------------------
1 | import { styled } from '../../styles'
2 |
3 | export const StyledInput = styled('input', {
4 | /* input reset */
5 | $reset: '',
6 | padding: '0 $sm',
7 | width: 0,
8 | minWidth: 0,
9 | flex: 1,
10 | height: '100%',
11 | variants: {
12 | levaType: { number: { textAlign: 'right' } },
13 | as: { textarea: { padding: '$sm' } },
14 | },
15 | })
16 |
17 | export const InnerLabel = styled('div', {
18 | $draggable: '',
19 | height: '100%',
20 | $flexCenter: '',
21 | position: 'relative',
22 | padding: '0 $xs',
23 | fontSize: '0.8em',
24 | opacity: 0.8,
25 | cursor: 'default',
26 | touchAction: 'none',
27 | [`& + ${StyledInput}`]: { paddingLeft: 0 },
28 | })
29 |
30 | export const InnerNumberLabel = styled(InnerLabel, {
31 | cursor: 'ew-resize',
32 | marginRight: '-$xs',
33 | textTransform: 'uppercase',
34 | opacity: 0.3,
35 | '&:hover': { opacity: 1 },
36 | variants: {
37 | dragging: { true: { backgroundColor: '$accent2', opacity: 1 } },
38 | },
39 | })
40 |
41 | export const InputContainer = styled('div', {
42 | $flex: '',
43 | position: 'relative',
44 | borderRadius: '$sm',
45 | overflow: 'hidden',
46 | color: 'inherit',
47 | height: '$rowHeight',
48 | backgroundColor: '$elevation3',
49 | $inputStyle: '$elevation1',
50 | $hover: '',
51 | $focusWithin: '',
52 | variants: {
53 | textArea: { true: { height: 'auto' } },
54 | },
55 | })
56 |
--------------------------------------------------------------------------------
/packages/leva/src/components/ValueInput/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ValueInput'
2 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Vector'
2 | export * from './vector-plugin'
3 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector/vector-utils.ts:
--------------------------------------------------------------------------------
1 | import { normalize } from '../Number/number-plugin'
2 | import type { NumberSettings } from '../../types'
3 | import type { InternalNumberSettings } from '../Number/number-types'
4 |
5 | export const normalizeKeyedNumberSettings = >(
6 | value: V,
7 | settings: { [key in keyof V]?: NumberSettings }
8 | ) => {
9 | const _settings = {} as { [key in keyof V]: InternalNumberSettings }
10 |
11 | let maxStep = 0
12 | let minPad = Infinity
13 | Object.entries(value).forEach(([key, v]: [keyof V, any]) => {
14 | _settings[key] = normalize({ value: v, ...settings[key] }).settings
15 | maxStep = Math.max(maxStep, _settings[key].step)
16 | minPad = Math.min(minPad, _settings[key].pad)
17 | })
18 |
19 | // makes sure we get a consistent step and pad on all vector components when
20 | // step is not specified in settings.
21 | for (let key in _settings) {
22 | const { step, min, max } = (settings[key] as any) || {}
23 | if (!isFinite(step) && (!isFinite(min) || !isFinite(max))) {
24 | _settings[key].step = maxStep
25 | _settings[key].pad = minPad
26 | }
27 | }
28 |
29 | return _settings
30 | }
31 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector2d/StyledJoystick.ts:
--------------------------------------------------------------------------------
1 | import { styled } from '../../styles'
2 |
3 | export const JoystickTrigger = styled('div', {
4 | $flexCenter: '',
5 | position: 'relative',
6 | backgroundColor: '$elevation3',
7 | borderRadius: '$sm',
8 | cursor: 'pointer',
9 | height: '$rowHeight',
10 | width: '$rowHeight',
11 | touchAction: 'none',
12 | $draggable: '',
13 | $hover: '',
14 |
15 | '&:active': { cursor: 'none' },
16 |
17 | '&::after': {
18 | content: '""',
19 | backgroundColor: '$accent2',
20 | height: 4,
21 | width: 4,
22 | borderRadius: 2,
23 | },
24 | })
25 |
26 | export const JoystickPlayground = styled('div', {
27 | $flexCenter: '',
28 | width: '$joystickWidth',
29 | height: '$joystickHeight',
30 | borderRadius: '$sm',
31 | boxShadow: '$level2',
32 | position: 'fixed',
33 | zIndex: 10000,
34 | overflow: 'hidden',
35 | $draggable: '',
36 | transform: 'translate(-50%, -50%)',
37 |
38 | variants: {
39 | isOutOfBounds: {
40 | true: { backgroundColor: '$elevation1' },
41 | false: { backgroundColor: '$elevation3' },
42 | },
43 | },
44 | '> div': {
45 | position: 'absolute',
46 | $flexCenter: '',
47 | borderStyle: 'solid',
48 | borderWidth: 1,
49 | borderColor: '$highlight1',
50 | backgroundColor: '$elevation3',
51 | width: '80%',
52 | height: '80%',
53 |
54 | '&::after,&::before': {
55 | content: '""',
56 | position: 'absolute',
57 | zindex: 10,
58 | backgroundColor: '$highlight1',
59 | },
60 |
61 | '&::before': {
62 | width: '100%',
63 | height: 1,
64 | },
65 |
66 | '&::after': {
67 | height: '100%',
68 | width: 1,
69 | },
70 | },
71 |
72 | '> span': {
73 | position: 'relative',
74 | zindex: 100,
75 | width: 10,
76 | height: 10,
77 | backgroundColor: '$accent2',
78 | borderRadius: '50%',
79 | },
80 | })
81 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector2d/Vector2d.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { styled } from '../../styles'
3 | import { Vector } from '../Vector'
4 | import { Label, Row } from '../UI'
5 | import { Joystick } from './Joystick'
6 | import { useInputContext } from '../../context'
7 | import type { Vector2dProps } from './vector2d-types'
8 |
9 | export const Container = styled('div', {
10 | display: 'grid',
11 | columnGap: '$colGap',
12 | variants: {
13 | withJoystick: {
14 | true: { gridTemplateColumns: '$sizes$rowHeight auto' },
15 | false: { gridTemplateColumns: 'auto' },
16 | },
17 | },
18 | })
19 |
20 | export function Vector2dComponent() {
21 | const { label, displayValue, onUpdate, settings } = useInputContext()
22 | return (
23 |
24 |
25 |
26 | {settings.joystick && }
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector2d/index.ts:
--------------------------------------------------------------------------------
1 | import { Vector2dComponent } from './Vector2d'
2 | import { getVectorPlugin } from '../Vector'
3 | import { createInternalPlugin } from '../../plugin'
4 | import type { InternalVector2dSettings } from './vector2d-types'
5 |
6 | export * from './Vector2d'
7 |
8 | const plugin = getVectorPlugin(['x', 'y'])
9 | const normalize = ({ joystick = true, ...input }: any) => {
10 | const { value, settings } = plugin.normalize(input)
11 | return { value, settings: { ...settings, joystick } as InternalVector2dSettings }
12 | }
13 |
14 | export default createInternalPlugin({
15 | component: Vector2dComponent,
16 | ...plugin,
17 | normalize,
18 | })
19 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector2d/vector2d-types.ts:
--------------------------------------------------------------------------------
1 | import type { LevaInputProps, Vector2d, VectorObj } from '../../types'
2 | import type { InternalVectorSettings } from '../Vector/vector-types'
3 |
4 | export type InternalVector2dSettings = InternalVectorSettings & {
5 | joystick: boolean | 'invertY'
6 | }
7 | export type Vector2dProps = LevaInputProps
8 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector3d/Vector3d.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Vector } from '../Vector'
3 | import { Label, Row } from '../UI'
4 | import { useInputContext } from '../../context'
5 | import type { Vector3dProps } from './vector3d-types'
6 |
7 | export function Vector3dComponent() {
8 | const { label, displayValue, onUpdate, settings } = useInputContext()
9 | return (
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector3d/index.ts:
--------------------------------------------------------------------------------
1 | import { Vector3dComponent } from './Vector3d'
2 | import { getVectorPlugin } from '../Vector'
3 | import { createInternalPlugin } from '../../plugin'
4 |
5 | export * from './Vector3d'
6 |
7 | export default createInternalPlugin({
8 | component: Vector3dComponent,
9 | ...getVectorPlugin(['x', 'y', 'z']),
10 | })
11 |
--------------------------------------------------------------------------------
/packages/leva/src/components/Vector3d/vector3d-types.ts:
--------------------------------------------------------------------------------
1 | import type { LevaInputProps, Vector3d, VectorObj } from '../../types'
2 | import type { InternalVectorSettings } from '../Vector/vector-types'
3 |
4 | export type InternalVector3dSettings = InternalVectorSettings
5 | export type Vector3dProps = LevaInputProps
6 |
--------------------------------------------------------------------------------
/packages/leva/src/context.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext } from 'react'
2 | import type { FullTheme } from './styles'
3 | import type { StoreType, PanelSettingsType, InputContextProps } from './types'
4 |
5 | export const InputContext = createContext({})
6 |
7 | export function useInputContext() {
8 | return useContext(InputContext) as InputContextProps & T
9 | }
10 |
11 | type ThemeContextProps = { theme: FullTheme; className: string }
12 |
13 | export const ThemeContext = createContext(null)
14 |
15 | export const StoreContext = createContext(null)
16 |
17 | export const PanelSettingsContext = createContext(null)
18 |
19 | export function useStoreContext() {
20 | return useContext(StoreContext)!
21 | }
22 |
23 | export function usePanelSettingsContext() {
24 | return useContext(PanelSettingsContext)!
25 | }
26 |
27 | type ReactChild = React.ReactElement | string | number
28 |
29 | type LevaStoreProviderProps = {
30 | children: ReactChild | ReactChild[] | typeof React.Children
31 | store: StoreType
32 | }
33 |
34 | export function LevaStoreProvider({ children, store }: LevaStoreProviderProps) {
35 | // @ts-expect-error portal JSX types are broken upstream
36 | return {children}
37 | }
38 |
--------------------------------------------------------------------------------
/packages/leva/src/eventEmitter.ts:
--------------------------------------------------------------------------------
1 | type Listener = (...args: Array) => void
2 |
3 | type EventEmitter = {
4 | on: (topic: string, listener: Listener) => void
5 | off: (topic: string, listener: Listener) => void
6 | emit: (event: string, ...args: Array) => void
7 | }
8 |
9 | /**
10 | * Super simple event emitter.
11 | */
12 | export const createEventEmitter = (): EventEmitter => {
13 | const listenerMapping = new Map>()
14 | return {
15 | on: (topic, listener) => {
16 | let listeners = listenerMapping.get(topic)
17 | if (listeners === undefined) {
18 | listeners = new Set()
19 | listenerMapping.set(topic, listeners)
20 | }
21 | listeners.add(listener)
22 | },
23 | off: (topic, listener) => {
24 | const listeners = listenerMapping.get(topic)
25 | if (listeners === undefined) {
26 | return
27 | }
28 | listeners.delete(listener)
29 | if (listeners.size === 0) {
30 | listenerMapping.delete(topic)
31 | }
32 | },
33 | emit: (topic, ...args) => {
34 | const listeners = listenerMapping.get(topic)
35 | if (listeners === undefined) {
36 | return
37 | }
38 | for (const listener of listeners) {
39 | listener(...args)
40 | }
41 | },
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/leva/src/helpers/button.ts:
--------------------------------------------------------------------------------
1 | import { SpecialInputs } from '../types'
2 | import type { ButtonInput, ButtonSettings } from '../types'
3 |
4 | const defaultSettings = { disabled: false }
5 |
6 | /**
7 | *
8 | * @param name button name
9 | * @param onClick function that executes when the button is clicked
10 | */
11 | export function button(onClick: ButtonInput['onClick'], settings?: ButtonSettings): ButtonInput {
12 | return { type: SpecialInputs.BUTTON, onClick, settings: { ...defaultSettings, ...settings } }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/leva/src/helpers/buttonGroup.ts:
--------------------------------------------------------------------------------
1 | import { SpecialInputs } from '../types'
2 | import type { ButtonGroupInput, ButtonGroupInputOpts } from '../types'
3 |
4 | /**
5 | *
6 | * @param name button name
7 | * @param onClick function that executes when the button is clicked
8 | */
9 | export function buttonGroup(opts: ButtonGroupInputOpts): ButtonGroupInput {
10 | return { type: SpecialInputs.BUTTON_GROUP, opts }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/leva/src/helpers/folder.ts:
--------------------------------------------------------------------------------
1 | import { SpecialInputs } from '../types'
2 | import type { FolderInput, Schema, SchemaToValues, FolderSettings } from '../types'
3 |
4 | const defaultSettings = { collapsed: false }
5 |
6 | export function folder(schema: S, settings?: FolderSettings): FolderInput> {
7 | return {
8 | type: SpecialInputs.FOLDER,
9 | schema,
10 | settings: { ...defaultSettings, ...settings },
11 | } as any
12 | }
13 |
--------------------------------------------------------------------------------
/packages/leva/src/helpers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './folder'
2 | export * from './button'
3 | export * from './buttonGroup'
4 | export * from './monitor'
5 |
--------------------------------------------------------------------------------
/packages/leva/src/helpers/monitor.ts:
--------------------------------------------------------------------------------
1 | import { SpecialInputs } from '../types'
2 | import type { MonitorInput, MonitorSettings } from '../types'
3 |
4 | const defaultSettings = { graph: false, interval: 100 }
5 |
6 | export function monitor(objectOrFn: React.MutableRefObject | Function, settings?: MonitorSettings): MonitorInput {
7 | return { type: SpecialInputs.MONITOR, objectOrFn, settings: { ...defaultSettings, ...settings } }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useInputSetters'
2 | export * from './useDeepMemo'
3 | export * from './useShallowMemo'
4 | export * from './useCompareMemoize'
5 | export * from './useDrag'
6 | export * from './useCanvas'
7 | export * from './useTransform'
8 | export * from './useToggle'
9 | export * from './useVisiblePaths'
10 | export * from './useValuesForPath'
11 | export * from './useInput'
12 | export * from './usePopin'
13 | export * from './useValue'
14 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useCanvas.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 | import { debounce } from '../utils'
3 |
4 | export function useCanvas2d(
5 | fn: Function
6 | ): [React.RefObject, React.RefObject] {
7 | const canvas = useRef(null)
8 | const ctx = useRef(null)
9 | const hasFired = useRef(false)
10 |
11 | // TODO this is pretty much useless in 90% of cases since panels
12 | // have a fixed width
13 | useEffect(() => {
14 | const handleCanvas = debounce(() => {
15 | canvas.current!.width = canvas.current!.offsetWidth * window.devicePixelRatio
16 | canvas.current!.height = canvas.current!.offsetHeight * window.devicePixelRatio
17 | fn(canvas.current, ctx.current)
18 | }, 250)
19 | window.addEventListener('resize', handleCanvas)
20 | if (!hasFired.current) {
21 | handleCanvas()
22 | hasFired.current = true
23 | }
24 | return () => window.removeEventListener('resize', handleCanvas)
25 | }, [fn])
26 |
27 | useEffect(() => {
28 | ctx.current = canvas.current!.getContext('2d')
29 | }, [])
30 |
31 | return [canvas, ctx]
32 | }
33 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useCompareMemoize.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react'
2 | import { dequal } from 'dequal/lite'
3 | import shallow from 'zustand/shallow'
4 |
5 | export function useCompareMemoize(value: any, deep: boolean) {
6 | const ref = useRef()
7 | const compare = deep ? dequal : shallow
8 |
9 | if (!compare(value, ref.current)) {
10 | ref.current = value
11 | }
12 |
13 | return ref.current
14 | }
15 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useDeepMemo.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useCompareMemoize } from './useCompareMemoize'
3 |
4 | export function useDeepMemo(fn: () => T, deps: React.DependencyList | undefined) {
5 | // NOTE: useMemo implementation allows undefined, but types do not
6 | // eslint-disable-next-line react-hooks/exhaustive-deps
7 | return useMemo(fn, useCompareMemoize(deps, true)!)
8 | }
9 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useDrag.ts:
--------------------------------------------------------------------------------
1 | import { useInputContext } from '../context'
2 | import { FullGestureState, useDrag as useDragHook, UserDragConfig } from '@use-gesture/react'
3 |
4 | export function useDrag(handler: (state: FullGestureState<'drag'>) => any, config?: UserDragConfig) {
5 | const { emitOnEditStart, emitOnEditEnd } = useInputContext()
6 | return useDragHook((state) => {
7 | if (state.first) {
8 | document.body.classList.add('leva__panel__dragged')
9 | emitOnEditStart?.()
10 | }
11 | const result = handler(state)
12 | if (state.last) {
13 | document.body.classList.remove('leva__panel__dragged')
14 | emitOnEditEnd?.()
15 | }
16 | return result
17 | }, config)
18 | }
19 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useInput.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState, useEffect } from 'react'
2 | import shallow from 'zustand/shallow'
3 | import { useStoreContext } from '../context'
4 | import type { Data, DataItem } from '../types'
5 |
6 | const getInputAtPath = (data: Data, path: string) => {
7 | if (!data[path]) return null
8 | const { __refCount, ...input } = data[path]
9 | return input
10 | }
11 |
12 | type Input = Omit
13 |
14 | /**
15 | * Return all input (value and settings) properties at a given path.
16 | *
17 | * @param path
18 | */
19 | export function useInput(path: string): [
20 | Input | null,
21 | {
22 | set: (value: any, onValueChanged?: (value: any) => void) => void
23 | setSettings: (value: any) => void
24 | disable: (flag: boolean) => void
25 | storeId: string
26 | emitOnEditStart: () => void
27 | emitOnEditEnd: () => void
28 | }
29 | ] {
30 | const store = useStoreContext()
31 | const [state, setState] = useState(getInputAtPath(store.getData(), path))
32 |
33 | const set = useCallback((value: any) => store.setValueAtPath(path, value, true), [path, store])
34 | const setSettings = useCallback((settings: any) => store.setSettingsAtPath(path, settings), [path, store])
35 | const disable = useCallback((flag: boolean) => store.disableInputAtPath(path, flag), [path, store])
36 | const emitOnEditStart = useCallback(() => store.emitOnEditStart(path), [path, store])
37 | const emitOnEditEnd = useCallback(() => store.emitOnEditEnd(path), [path, store])
38 |
39 | useEffect(() => {
40 | setState(getInputAtPath(store.getData(), path))
41 | const unsub = store.useStore.subscribe((s) => getInputAtPath(s.data, path), setState, { equalityFn: shallow })
42 | return () => unsub()
43 | }, [store, path])
44 |
45 | return [state, { set, setSettings, disable, storeId: store.storeId, emitOnEditStart, emitOnEditEnd }]
46 | }
47 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useInputSetters.ts:
--------------------------------------------------------------------------------
1 | import { dequal } from 'dequal/lite'
2 | import { useState, useCallback, useEffect, useRef } from 'react'
3 | import { format } from '../plugin'
4 |
5 | type Props = {
6 | type: string
7 | value: V
8 | settings?: Settings
9 | setValue: (v: V) => void
10 | }
11 |
12 | export function useInputSetters({ value, type, settings, setValue }: Props) {
13 | // the value used by the panel vs the value
14 | const [displayValue, setDisplayValue] = useState(format(type, value, settings))
15 | const previousValueRef = useRef(value)
16 | const settingsRef = useRef(settings)
17 | settingsRef.current = settings
18 | const setFormat = useCallback((v: V) => setDisplayValue(format(type, v, settingsRef.current)), [type])
19 |
20 | const onUpdate = useCallback(
21 | (updatedValue: any) => {
22 | try {
23 | setValue(updatedValue)
24 | } catch (error: any) {
25 | const { type, previousValue } = error
26 | // make sure we throw an error if it's not a sanitization error
27 | if (type !== 'LEVA_ERROR') throw error
28 | setFormat(previousValue)
29 | }
30 | },
31 | [setFormat, setValue]
32 | )
33 |
34 | useEffect(() => {
35 | if (!dequal(value, previousValueRef.current)) {
36 | setFormat(value)
37 | }
38 | previousValueRef.current = value
39 | }, [value, setFormat])
40 |
41 | return { displayValue, onChange: setDisplayValue, onUpdate }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/usePopin.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useLayoutEffect, useCallback } from 'react'
2 |
3 | export function usePopin(margin = 3) {
4 | const popinRef = useRef(null)
5 | const wrapperRef = useRef(null)
6 |
7 | const [shown, setShow] = useState(false)
8 |
9 | const show = useCallback(() => setShow(true), [])
10 | const hide = useCallback(() => setShow(false), [])
11 |
12 | useLayoutEffect(() => {
13 | if (shown) {
14 | const { bottom, top, left } = popinRef.current!.getBoundingClientRect()
15 | const { height } = wrapperRef.current!.getBoundingClientRect()
16 | const direction = bottom + height > window.innerHeight - 40 ? 'up' : 'down'
17 |
18 | wrapperRef.current!.style.position = 'fixed'
19 | wrapperRef.current!.style.zIndex = '10000'
20 | wrapperRef.current!.style.left = left + 'px'
21 |
22 | if (direction === 'down') wrapperRef.current!.style.top = bottom + margin + 'px'
23 | else wrapperRef.current!.style.bottom = window.innerHeight - top + margin + 'px'
24 | }
25 | }, [margin, shown])
26 |
27 | return { popinRef, wrapperRef, shown, show, hide }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useShallowMemo.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useCompareMemoize } from './useCompareMemoize'
3 |
4 | export function useShallowMemo(fn: () => T, deps: React.DependencyList | undefined) {
5 | // NOTE: useMemo implementation allows undefined, but types do not
6 | // eslint-disable-next-line react-hooks/exhaustive-deps
7 | return useMemo(fn, useCompareMemoize(deps, false)!)
8 | }
9 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useToggle.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useLayoutEffect } from 'react'
2 |
3 | export function useToggle(toggled: boolean) {
4 | const wrapperRef = useRef(null)
5 | const contentRef = useRef(null)
6 | const firstRender = useRef(true)
7 |
8 | // this should be fine for SSR since the store is set in useEffect and
9 | // therefore the pane doesn't show on first render.
10 | useLayoutEffect(() => {
11 | if (!toggled) {
12 | wrapperRef.current!.style.height = '0px'
13 | wrapperRef.current!.style.overflow = 'hidden'
14 | }
15 | // we only want to do this once so that's ok to break the rules of hooks.
16 | // eslint-disable-next-line react-hooks/exhaustive-deps
17 | }, [])
18 |
19 | useEffect(() => {
20 | // prevents first animation
21 | if (firstRender.current) {
22 | firstRender.current = false
23 | return
24 | }
25 |
26 | let timeout: number
27 | const ref = wrapperRef.current!
28 |
29 | const fixHeight = () => {
30 | if (toggled) {
31 | ref.style.removeProperty('height')
32 | ref.style.removeProperty('overflow')
33 | contentRef.current!.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
34 | }
35 | }
36 |
37 | ref.addEventListener('transitionend', fixHeight, { once: true })
38 |
39 | const { height } = contentRef.current!.getBoundingClientRect()
40 | ref.style.height = height + 'px'
41 | if (!toggled) {
42 | ref.style.overflow = 'hidden'
43 | timeout = window.setTimeout(() => (ref.style.height = '0px'), 50)
44 | }
45 |
46 | return () => {
47 | ref.removeEventListener('transitionend', fixHeight)
48 | clearTimeout(timeout)
49 | }
50 | }, [toggled])
51 |
52 | return { wrapperRef, contentRef }
53 | }
54 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useTransform.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useCallback } from 'react'
2 |
3 | export function useTransform(): [
4 | React.RefObject,
5 | (point: { x?: number; y?: number }) => void
6 | ] {
7 | const ref = useRef(null)
8 | const local = useRef({ x: 0, y: 0 })
9 |
10 | const set = useCallback((point: { x?: number; y?: number }) => {
11 | Object.assign(local.current, point)
12 | if (ref.current) ref.current.style.transform = `translate3d(${local.current.x}px, ${local.current.y}px, 0)`
13 | }, [])
14 |
15 | return [ref, set]
16 | }
17 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useValue.ts:
--------------------------------------------------------------------------------
1 | import shallow from 'zustand/shallow'
2 | import { useStoreContext } from '../context'
3 |
4 | export const useValue = (path: string) => {
5 | return useValues([path])[path]
6 | }
7 |
8 | export const useValues = (paths: T[]) => {
9 | const store = useStoreContext()
10 | const value = store.useStore(
11 | ({ data }) =>
12 | paths.reduce((acc, path) => {
13 | // @ts-expect-error
14 | if (data[path] && 'value' in data[path]) return Object.assign(acc, { [path]: data[path].value })
15 | return acc
16 | }, {} as { [key in T]: any }),
17 | shallow
18 | )
19 | return value
20 | }
21 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useValuesForPath.ts:
--------------------------------------------------------------------------------
1 | import shallow from 'zustand/shallow'
2 | import { getValuesForPaths } from '../utils/data'
3 | import type { Data, StoreType } from '../types'
4 |
5 | /**
6 | * Hook that returns the values from the zustand store for the given paths.
7 | * @param paths paths for which to return values
8 | * @param initialData
9 | */
10 | export function useValuesForPath(store: StoreType, paths: string[], initialData: Data) {
11 | const valuesForPath = store.useStore((s) => {
12 | const data = { ...initialData, ...s.data }
13 | return getValuesForPaths(data, paths)
14 | }, shallow)
15 |
16 | return valuesForPath
17 | }
18 |
--------------------------------------------------------------------------------
/packages/leva/src/hooks/useVisiblePaths.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import shallow from 'zustand/shallow'
3 | import type { StoreType } from '../types'
4 |
5 | /**
6 | * Hook used by the root component to get all visible inputs.
7 | */
8 | export const useVisiblePaths = (store: StoreType) => {
9 | const [paths, setPaths] = useState(store.getVisiblePaths())
10 |
11 | useEffect(() => {
12 | setPaths(store.getVisiblePaths())
13 | const unsub = store.useStore.subscribe(store.getVisiblePaths, setPaths, { equalityFn: shallow })
14 | return () => unsub()
15 | }, [store])
16 |
17 | return paths
18 | }
19 |
--------------------------------------------------------------------------------
/packages/leva/src/index.ts:
--------------------------------------------------------------------------------
1 | import { register } from './plugin'
2 | import number from './components/Number'
3 | import select from './components/Select'
4 | import color from './components/Color'
5 | import string from './components/String'
6 | import boolean from './components/Boolean'
7 | import vector3d from './components/Vector3d'
8 | import vector2d from './components/Vector2d'
9 | import image from './components/Image'
10 | import interval from './components/Interval'
11 | import { LevaInputs } from './types'
12 |
13 | /**
14 | * Register all the primitive inputs.
15 | * @note could potentially be done elsewhere.
16 | */
17 |
18 | register(LevaInputs.SELECT, select)
19 | register(LevaInputs.IMAGE, image)
20 | register(LevaInputs.NUMBER, number)
21 | register(LevaInputs.COLOR, color)
22 | register(LevaInputs.STRING, string)
23 | register(LevaInputs.BOOLEAN, boolean)
24 | register(LevaInputs.INTERVAL, interval)
25 | register(LevaInputs.VECTOR3D, vector3d)
26 | register(LevaInputs.VECTOR2D, vector2d)
27 |
28 | // main hook
29 | export { useControls } from './useControls'
30 |
31 | // panel components
32 | export { Leva, LevaPanel } from './components/Leva'
33 |
34 | // simplifies passing store as context
35 | export { useStoreContext, LevaStoreProvider } from './context'
36 |
37 | // export the levaStore (default store)
38 | // hook to create custom store
39 | export { levaStore, useCreateStore } from './store'
40 |
41 | // export folder, monitor, button
42 | export * from './helpers'
43 |
44 | export { LevaInputs }
45 |
--------------------------------------------------------------------------------
/packages/leva/src/plugin/index.ts:
--------------------------------------------------------------------------------
1 | // used as entrypoint
2 |
3 | // export all components
4 | import { Row, Label, Portal, Overlay } from '../components/UI'
5 | import { String } from '../components/String'
6 | import { Number } from '../components/Number'
7 | import { Boolean } from '../components/Boolean'
8 | import { Select } from '../components/Select'
9 | import { Vector } from '../components/Vector'
10 | import { InnerLabel } from '../components/ValueInput/StyledInput'
11 |
12 | export const Components = {
13 | Row,
14 | Label,
15 | Portal,
16 | Overlay,
17 | String,
18 | Number,
19 | Boolean,
20 | Select,
21 | Vector,
22 | InnerLabel,
23 | }
24 |
25 | export { colord } from 'colord'
26 | export { dequal } from 'dequal/lite'
27 |
28 | export { debounce, clamp, pad, evaluate, range, invertedRange, mergeRefs } from '../utils'
29 | export { normalizeKeyedNumberSettings } from '../components/Vector/vector-utils'
30 |
31 | export { createPlugin } from '../plugin'
32 |
33 | // export vector utilities
34 | export * from '../components/Vector/vector-plugin'
35 | // export useful hooks
36 | export { useDrag, useCanvas2d, useTransform, useInput, useValue, useValues, useInputSetters } from '../hooks'
37 | export { useInputContext, useStoreContext } from '../context'
38 |
39 | // export styling utilities
40 | export { styled, keyframes, useTh } from '../styles'
41 |
42 | // export types
43 | export * from '../types/public'
44 | export type { InternalVectorSettings } from '../components/Vector/vector-types'
45 | export type { InternalNumberSettings } from '../components/Number/number-types'
46 |
--------------------------------------------------------------------------------
/packages/leva/src/styles/index.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import { getDefaultTheme, FullTheme, LevaCustomTheme, createTheme } from './stitches.config'
3 | import { ThemeContext } from '../context'
4 | import { warn, LevaErrors } from '../utils'
5 |
6 | export function mergeTheme(newTheme?: LevaCustomTheme): { theme: FullTheme; className: string } {
7 | const defaultTheme = getDefaultTheme()
8 | if (!newTheme) return { theme: defaultTheme, className: '' }
9 | Object.keys(newTheme!).forEach((key) => {
10 | // @ts-ignore
11 | Object.assign(defaultTheme![key], newTheme![key])
12 | })
13 | const customTheme = createTheme(defaultTheme)
14 | return { theme: defaultTheme, className: customTheme.className }
15 | }
16 |
17 | export function useTh(category: C, key: keyof FullTheme[C]) {
18 | const { theme } = useContext(ThemeContext)!
19 | if (!(category in theme!) || !(key in theme![category]!)) {
20 | warn(LevaErrors.THEME_ERROR, category, key)
21 | return ''
22 | }
23 |
24 | let _key = key
25 | while (true) {
26 | // @ts-ignore
27 | let value = theme[category][_key]
28 | if (typeof value === 'string' && value.charAt(0) === '$') _key = value.substr(1) as any
29 | else return value
30 | }
31 | }
32 |
33 | export * from './stitches.config'
34 |
--------------------------------------------------------------------------------
/packages/leva/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './internal'
2 | export * from './public'
3 | export * from './utils'
4 |
--------------------------------------------------------------------------------
/packages/leva/src/types/utils.ts:
--------------------------------------------------------------------------------
1 | // Utils from https://github.com/pmndrs/use-tweaks/blob/92561618abbf43c581fc5950fd35c0f8b21047cd/src/types.ts#L70
2 | /**
3 | * It does nothing but beautify union type
4 | *
5 | * ```
6 | * type A = { a: 'a' } & { b: 'b' } // { a: 'a' } & { b: 'b' }
7 | * type B = Id<{ a: 'a' } & { b: 'b' }> // { a: 'a', b: 'b' }
8 | * ```
9 | */
10 | export type BeautifyUnionType = T extends object
11 | ? T extends Function // if T is a function return it as is
12 | ? T
13 | : any[] extends T // if T is a plain array return it as is
14 | ? T
15 | : T extends infer TT // if T is an object beautify it
16 | ? { [k in keyof TT]: TT[k] } & GetIterator
17 | : never
18 | : T
19 |
20 | // adds Iterator to the return type in case it has any
21 | type GetIterator = T extends { [Symbol.iterator]: infer U } ? { [Symbol.iterator]: U } : {}
22 |
23 | export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
24 |
25 | /**
26 | * Gets keys from Record
27 | */
28 | export type GetKeys = V extends Record ? K : never
29 |
--------------------------------------------------------------------------------
/packages/leva/src/types/v8n.d.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/27449/commits/3afbb600c58f0739410b5f882d35eb323976fe80
2 |
3 | declare module 'v8n' {
4 | const v8n: {
5 | (): Validation
6 | extend(item: Record (value: any) => boolean>)
7 | }
8 |
9 | export default v8n
10 |
11 | interface Validation {
12 | chain: Rule[]
13 | every: Validation
14 | invert?: boolean
15 | extend(newRules: { [key: string]: () => boolean }): void
16 | test(value: any): boolean
17 | check(value: any): never
18 | pattern(pattern: RegExp): Validation
19 | equal(expected: any): Validation
20 | exact(expected: any): Validation
21 | string(): Validation
22 | number(): Validation
23 | boolean(): Validation
24 | undefined(): Validation
25 | null(): Validation
26 | array(): Validation
27 | lowercase(): Validation
28 | vowel(): Validation
29 | object(): Validation
30 | consonant(): Validation
31 | first(item: any): Validation
32 | last(item: any): Validation
33 | empty(): Validation
34 | length(min: number, max?: number): Validation
35 | minLength(min: number): Validation
36 | maxLength(max: number): Validation
37 | negative(): Validation
38 | positive(): Validation
39 | between(min: number, max: number): Validation
40 | range(min: number, max: number): Validation
41 | lessThan(bound: number): Validation
42 | lessThanOrEqual(bound: number): Validation
43 | greaterThan(bound: number): Validation
44 | greaterThanOrEqual(bound: number): Validation
45 | even(): Validation
46 | odd(): Validation
47 | includes(expected: any): Validation
48 | integer(): Validation
49 | schema(item: any): Validation
50 | passesAnyOf(...args: Validation[]): Validation
51 | optional(item: Validation): Validation
52 | }
53 | class Rule {
54 | constructor(name: string, fn: () => boolean, args?: any, invert?: boolean)
55 | name: string
56 | fn: () => boolean
57 | args?: any
58 | invert?: boolean
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/data.ts:
--------------------------------------------------------------------------------
1 | import { pick } from '.'
2 | import { Data } from '../types'
3 |
4 | /**
5 | * Takes a data object with { [path.key]: value } and returns { [key]: value }.
6 | * Also warns when two similar keys are being used by the user.
7 | *
8 | * @param data
9 | * @param paths
10 | * @param shouldWarn
11 | */
12 | export function getValuesForPaths(data: Data, paths: string[]) {
13 | return Object.entries(pick(data, paths)).reduce(
14 | // Typescript complains that SpecialInput type doesn't have a value key.
15 | // But getValuesForPath is only called from paths that are inputs,
16 | // so they always have a value key.
17 |
18 | // @ts-expect-error
19 | (acc, [, { value, disabled, key }]) => {
20 | acc[key] = disabled ? undefined : value
21 | return acc
22 | },
23 | {} as { [path: string]: any }
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/event.ts:
--------------------------------------------------------------------------------
1 | export const multiplyStep = (event: any) => (event.shiftKey ? 5 : event.altKey ? 1 / 5 : 1)
2 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/fn.ts:
--------------------------------------------------------------------------------
1 | export const debounce = (callback: F, wait: number, immediate = false) => {
2 | let timeout: number = 0
3 |
4 | return function () {
5 | const args = arguments as any
6 | const callNow = immediate && !timeout
7 | // @ts-expect-error
8 | const next = () => callback.apply(this, args)
9 |
10 | window.clearTimeout(timeout)
11 | timeout = window.setTimeout(next, wait)
12 |
13 | if (callNow) next()
14 | } as F extends (...args: infer A) => infer B ? (...args: A) => B : never
15 | }
16 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './math'
2 | export * from './path'
3 | export * from './object'
4 | export * from './input'
5 | export * from './fn'
6 | export * from './log'
7 | export * from './data'
8 | export * from './event'
9 | export * from './react'
10 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/object.ts:
--------------------------------------------------------------------------------
1 | export function pick(object: T, keys: K[]) {
2 | return keys.reduce((obj, key) => {
3 | if (!!object && object.hasOwnProperty(key)) {
4 | obj[key] = object[key]
5 | }
6 | return obj
7 | }, {} as { [k in K]: T[k] })
8 | }
9 |
10 | export function omit(object: T, keys: K[]) {
11 | const obj = { ...object }
12 | keys.forEach((k) => k in object && delete obj[k])
13 | return obj
14 | }
15 | export function mapArrayToKeys(value: U[], keys: K[]): Record {
16 | return value.reduce((acc, v, i) => Object.assign(acc, { [keys[i]]: v }), {} as any)
17 | }
18 |
19 | export function isObject(variable: any) {
20 | return Object.prototype.toString.call(variable) === '[object Object]'
21 | }
22 |
23 | export const isEmptyObject = (obj: Object) => isObject(obj) && Object.keys(obj).length === 0
24 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/path.ts:
--------------------------------------------------------------------------------
1 | export const join = (...args: (string | undefined)[]) => args.filter(Boolean).join('.')
2 |
3 | export const prefix = (obj: object, p: string) =>
4 | Object.entries(obj).reduce((acc, [key, v]) => ({ ...acc, [join(p, key)]: v }), {})
5 |
6 | export function getKeyPath(path: string): [string, string | undefined] {
7 | const dir = path.split('.')
8 | return [dir.pop()!, dir.join('.') || undefined]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/leva/src/utils/react.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | /*
4 | * https://github.com/gregberge/react-merge-refs
5 | * MIT License
6 | * Copyright (c) 2020 Greg Bergé
7 | *
8 | * Permission is hereby granted, free of charge, to any person obtaining a copy
9 | * of this software and associated documentation files (the "Software"), to deal
10 | * in the Software without restriction, including without limitation the rights
11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 | * copies of the Software, and to permit persons to whom the Software is
13 | * furnished to do so, subject to the following conditions:
14 | *
15 | * The above copyright notice and this permission notice shall be included in all
16 | * copies or substantial portions of the Software.
17 | *
18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 | * SOFTWARE.
25 | */
26 |
27 | export function mergeRefs(
28 | refs: Array | React.RefObject | null | undefined>
29 | ): React.RefCallback {
30 | return (value) => {
31 | refs.forEach((ref) => {
32 | if (typeof ref === 'function') ref(value)
33 | else if (ref != null) {
34 | ;(ref as React.MutableRefObject).current = value
35 | }
36 | })
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/leva/stories/caching.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Reset from './components/decorator-reset'
3 | import { Story, Meta } from '@storybook/react'
4 |
5 | import { useControls } from '../src'
6 |
7 | export default {
8 | title: 'Hook/Caching',
9 | decorators: [Reset],
10 | } as Meta
11 |
12 | const Controls = () => {
13 | const values = useControls({ num: 10, color: '#f00' })
14 |
15 | return (
16 |
17 |
{JSON.stringify(values, null, ' ')}
18 |
19 | )
20 | }
21 |
22 | const Template: Story = () => {
23 | const [mounted, toggle] = React.useState(true)
24 | return (
25 |
26 |
27 | {mounted && }
28 |
29 | )
30 | }
31 |
32 | export const Caching = Template.bind({})
33 |
--------------------------------------------------------------------------------
/packages/leva/stories/components/decorator-reset.tsx:
--------------------------------------------------------------------------------
1 | import { StoryFnReactReturnType } from '@storybook/react/dist/ts3.9/client/preview/types'
2 | import * as React from 'react'
3 | import { levaStore } from '../../src'
4 |
5 | const DefaultStory = (Story: () => StoryFnReactReturnType) => {
6 | const [_, set] = React.useState(false)
7 | React.useEffect(() => {
8 | levaStore.dispose()
9 | set(true)
10 | }, [])
11 | return _ ? : <>>
12 | }
13 |
14 | export default DefaultStory
15 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Boolean.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Boolean',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args) => {
14 | const values = useControls({
15 | foo: args,
16 | })
17 |
18 | return (
19 |
20 |
{JSON.stringify(values, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const Default = Template.bind({})
26 | Default.args = {
27 | value: false,
28 | }
29 |
30 | export const Checked = Template.bind({})
31 | Checked.args = {
32 | value: true,
33 | }
34 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Button.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls, button } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Button',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | export const Button = () => {
14 | const values = useControls({
15 | number: 3,
16 | foo: button((get) => alert(`Number value is ${get('number').toFixed(2)}`)),
17 | })
18 |
19 | return (
20 |
21 |
{JSON.stringify(values, null, ' ')}
22 |
23 | )
24 | }
25 |
26 | export const DisabledButton = () => {
27 | const values = useControls({
28 | number: 3,
29 | foo: button((get) => alert(`Number value is ${get('number')}`), { disabled: true }),
30 | })
31 |
32 | return (
33 |
34 |
{JSON.stringify(values, null, ' ')}
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Image.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Image',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args = undefined) => {
14 | const values = useControls({ foo: args }) as any
15 |
16 | return (
17 |
18 |

19 |
20 | )
21 | }
22 |
23 | export const Image = Template.bind({})
24 | Image.args = { image: undefined }
25 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Interval.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Interval',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args) => {
14 | const values = useControls({
15 | foo: args,
16 | })
17 |
18 | return (
19 |
20 |
{JSON.stringify(values, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const Simple = Template.bind({})
26 | Simple.args = {
27 | value: [10, 15],
28 | min: 1,
29 | max: 20,
30 | }
31 |
32 | export const OverflowingValue = Template.bind({})
33 | OverflowingValue.args = {
34 | value: [-10, 150],
35 | min: 1,
36 | max: 20,
37 | }
38 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Number.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Number',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args) => {
14 | const values = useControls({
15 | foo: args,
16 | })
17 |
18 | return (
19 |
20 |
{JSON.stringify(values, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const Simple = Template.bind({})
26 | Simple.args = {
27 | value: 1,
28 | }
29 |
30 | export const MinMax = Template.bind({})
31 | MinMax.args = {
32 | value: 1,
33 | min: 0,
34 | max: 10,
35 | }
36 |
37 | export const WithValueOverflow = Template.bind({})
38 | WithValueOverflow.args = {
39 | value: 100,
40 | min: 0,
41 | max: 10,
42 | }
43 |
44 | export const Step = Template.bind({})
45 | Step.args = {
46 | value: 10,
47 | step: 0.25,
48 | }
49 |
50 | export const Suffix = Template.bind({})
51 | Suffix.args = { value: '10px' }
52 |
53 | export const Complete = Template.bind({})
54 | Complete.args = {
55 | value: 5,
56 | min: 0,
57 | max: 10,
58 | step: 1,
59 | suffix: 'px',
60 | }
61 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Select.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Select',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args) => {
14 | const values = useControls({
15 | foo: args,
16 | })
17 |
18 | return (
19 |
20 |
{JSON.stringify(values, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const Simple = Template.bind({})
26 | Simple.args = {
27 | value: 'x',
28 | options: ['x', 'y'],
29 | }
30 |
31 | export const CustomLabels = Template.bind({})
32 | CustomLabels.args = {
33 | value: 'helloWorld',
34 | options: {
35 | 'Hello World': 'helloWorld',
36 | 'Leva is awesome!': 'leva',
37 | },
38 | }
39 |
40 | export const InferredValueAsOption = Template.bind({})
41 | InferredValueAsOption.args = {
42 | value: true,
43 | options: [false],
44 | }
45 |
46 | export const DifferentOptionTypes = Template.bind({})
47 | DifferentOptionTypes.args = {
48 | value: undefined,
49 | options: ['x', 'y', ['x', 'y']],
50 | }
51 |
52 | const IconA = () => IconA
53 | const IconB = () => IconB
54 |
55 | export const FunctionAsOptions = () => {
56 | const values = useControls({
57 | foo: { options: { none: '', IconA, IconB } },
58 | })
59 |
60 | return (
61 |
62 |
{values.foo.toString()}
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/String.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/String',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args) => {
14 | const values = useControls({
15 | foo: args,
16 | })
17 |
18 | return (
19 |
20 |
{JSON.stringify(values, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const Simple = Template.bind({})
26 | Simple.args = {
27 | value: 'Leva is awesome',
28 | }
29 |
30 | export const DefaultRows = Template.bind({})
31 | DefaultRows.args = {
32 | value: 'Leva also supports \nAllowing for\nmultiple lines',
33 | rows: true,
34 | }
35 |
36 | export const CustomRows = Template.bind({})
37 | CustomRows.args = {
38 | value: 'You can specify the number of rows you need',
39 | rows: 3,
40 | }
41 |
42 | export const NonEditable = Template.bind({})
43 | NonEditable.args = {
44 | value: 'This text is not editable but still supports\nline\nbreaks.',
45 | editable: false,
46 | }
47 |
--------------------------------------------------------------------------------
/packages/leva/stories/inputs/Vector.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../components/decorator-reset'
5 |
6 | import { useControls } from '../../src'
7 |
8 | export default {
9 | title: 'Inputs/Vector',
10 | decorators: [Reset],
11 | } as Meta
12 |
13 | const Template: Story = (args) => {
14 | const values = useControls({
15 | foo: args,
16 | })
17 |
18 | return (
19 |
20 |
{JSON.stringify(values, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const Vector2 = Template.bind({})
26 | Vector2.args = {
27 | value: { x: 0, y: 0 },
28 | }
29 |
30 | export const Vector2FromArray = Template.bind({})
31 | Vector2FromArray.args = {
32 | value: [1, 10],
33 | }
34 |
35 | export const Vector2WithLock = Template.bind({})
36 | Vector2WithLock.args = {
37 | value: [1, 10],
38 | lock: true,
39 | }
40 |
41 | export const Vector2WithoutJoystick = Template.bind({})
42 | Vector2WithoutJoystick.args = {
43 | value: { x: 0, y: 0 },
44 | joystick: false,
45 | }
46 |
47 | export const Vector2WithInvertedJoystickY = ({ value, invertY }) => (
48 |
49 | )
50 | Vector2WithInvertedJoystickY.args = {
51 | value: [0, 0],
52 | invertY: true,
53 | }
54 |
55 | export const Vector3 = Template.bind({})
56 | Vector3.args = {
57 | value: { x: 0, y: 0, z: 0 },
58 | }
59 |
60 | export const Vector3FromArray = Template.bind({})
61 | Vector3FromArray.args = {
62 | value: [1, 10, 0],
63 | }
64 |
65 | export const Vector3WithLock = Template.bind({})
66 | Vector3WithLock.args = {
67 | value: [1, 10, 0],
68 | lock: true,
69 | }
70 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @leva-ui/plugin-bezier
2 |
3 | ## 0.10.0
4 |
5 | ### Minor Changes
6 |
7 | - 3d4a620: feat!: React 18 and 19 support
8 |
9 | ### Patch Changes
10 |
11 | - Updated dependencies [b9c6376]
12 | - Updated dependencies [3d4a620]
13 | - leva@0.10.0
14 |
15 | ## 0.9.19
16 |
17 | ### Patch Changes
18 |
19 | - 3177e59: style: label alignment
20 | - Updated dependencies [3177e59]
21 | - leva@0.9.23
22 |
23 | ## 0.9.18
24 |
25 | ### Patch Changes
26 |
27 | - e45e9de: Feat: pass `get` function to Button and ButtonGroup
28 | - Updated dependencies [e45e9de]
29 | - leva@0.9.18
30 |
31 | ## 0.9.14
32 |
33 | ### Minor Changes
34 |
35 | - 1001f25: Fix version for stitches before moving to 1.x
36 |
37 | ## 0.9.12
38 |
39 | ### Patch Changes
40 |
41 | - Updated dependencies
42 | - leva@0.9.12
43 |
44 | ## 0.9.10
45 |
46 | ### Patch Changes
47 |
48 | - 16e3c14: feat: add `preview` flag to disable dot preview.
49 | - Updated dependencies [16e3c14]
50 | - leva@0.9.10
51 |
52 | ## 0.9.8
53 |
54 | ### Patch Changes
55 |
56 | - f8f7b57: fix: double render issue when using nested components.
57 | - Updated dependencies [f8f7b57]
58 | - leva@0.9.9
59 |
60 | ## 0.0.4
61 |
62 | ### Patch Changes
63 |
64 | - 0511799: styles: remove manual 'leva\_\_' prefix from stitches styles.
65 | - Updated dependencies [0511799]
66 | - leva@0.9.6
67 |
68 | ## 0.0.3
69 |
70 | ### Patch Changes
71 |
72 | - 26ead12: Feat: add cssEasing to returned prop
73 |
74 | ## 0.0.2
75 |
76 | ### Patch Changes
77 |
78 | - c997410: Plugin: add the Bezier plugin
79 |
80 | ```js
81 | import { bezier } from '@leva-ui/plugin-bezier'
82 | useControls({ curve: bezier([0.25, 0.1, 0.25, 1]) })
83 | ```
84 |
85 | - Updated dependencies [c997410]
86 | - leva@0.8.1
87 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/README.md:
--------------------------------------------------------------------------------
1 | ## Leva Bezier
2 |
3 | ### Installation
4 |
5 | ```bash
6 | npm i @leva-ui/plugin-bezier
7 | ```
8 |
9 | ### Quick start
10 |
11 | ```jsx
12 | import { useControls } from 'leva'
13 | import { bezier } from '@leva-ui/plugin-bezier'
14 |
15 | function MyComponent() {
16 | const { curve } = useControls({ curve: bezier() })
17 | // or
18 | const { curve } = useControls({ curve: bezier([0.54, 0.05, 0.6, 0.98]) })
19 | // or
20 | const { curve } = useControls({ curve: bezier('in-out-quadratic') })
21 | // or
22 | const { curve } = useControls({ curve: bezier({ handles: [0.54, 0.05, 0.6, 0.98], graph: false }) })
23 |
24 | // built-in function evaluation
25 | console.log(curve.evaluate(0.3))
26 |
27 | // inside a css like animation-timing-function
28 | return
29 | }
30 | ```
31 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@leva-ui/plugin-bezier",
3 | "version": "0.10.0",
4 | "main": "dist/leva-ui-plugin-bezier.cjs.js",
5 | "module": "dist/leva-ui-plugin-bezier.esm.js",
6 | "types": "dist/leva-ui-plugin-bezier.cjs.d.ts",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/pmndrs/leva.git",
11 | "directory": "packages/plugin-beziers"
12 | },
13 | "bugs": "https://github.com/pmndrs/leva/issues",
14 | "peerDependencies": {
15 | "leva": ">=0.10.0",
16 | "react": "^18.0.0 || ^19.0.0",
17 | "react-dom": "^18.0.0 || ^19.0.0"
18 | },
19 | "dependencies": {
20 | "react-use-measure": "^2.1.1"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/Bezier.stories.css:
--------------------------------------------------------------------------------
1 | @keyframes bezierStoryScale {
2 | 0% {
3 | transform: scaleX(0);
4 | }
5 |
6 | 100% {
7 | transform: scaleX(1);
8 | }
9 | }
10 |
11 | .bezier-animated {
12 | height: 10px;
13 | width: 200px;
14 | background: indianred;
15 | transform-origin: left;
16 | animation: bezierStoryScale 1000ms infinite alternate both;
17 | }
18 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/Bezier.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from 'leva/stories/components/decorator-reset'
5 | import { useControls } from 'leva/src'
6 |
7 | import { bezier } from './index'
8 | import './Bezier.stories.css'
9 |
10 | export default {
11 | title: 'Plugins/Bezier',
12 | decorators: [Reset],
13 | } as Meta
14 |
15 | const Template: Story = (args) => {
16 | const data = useControls({ curve: args })
17 | return (
18 |
19 |
20 |
{JSON.stringify(data, null, ' ')}
21 |
22 | )
23 | }
24 |
25 | export const DefaultBezier = Template.bind({})
26 | DefaultBezier.args = bezier(undefined)
27 |
28 | export const WithArguments = Template.bind({})
29 | WithArguments.args = bezier([0.54, 0.05, 0.6, 0.98])
30 |
31 | export const WithPreset = Template.bind({})
32 | WithPreset.args = bezier('in-out-quadratic')
33 |
34 | export const WithOptions = Template.bind({})
35 | WithOptions.args = bezier({ handles: [0.54, 0.05, 0.6, 0.98], graph: false, preview: false, label: 'no graph' })
36 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/BezierPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useMemo, useReducer } from 'react'
2 | import { debounce } from 'leva/plugin'
3 | import { PreviewSvg } from './StyledBezier'
4 | import type { BezierProps } from './bezier-types'
5 |
6 | const DebouncedBezierPreview = React.memo(({ value }: Pick) => {
7 | // use to forceUpdate on click
8 | const [, forceUpdate] = useReducer((x) => x + 1, 0)
9 |
10 | const plotPoints = Array(21)
11 | .fill(0)
12 | .map((_, i) => 5 + value.evaluate(i / 20) * 90)
13 | return (
14 |
15 | {plotPoints.map((p, i) => (
16 |
17 | ))}
18 |
26 |
27 | )
28 | })
29 |
30 | export function BezierPreview({ value }: Pick) {
31 | const [debouncedValue, set] = useState(value)
32 | const debounceValue = useMemo(() => debounce((v: typeof value) => set(v), 250), [])
33 | useEffect(() => void debounceValue(value), [value, debounceValue])
34 |
35 | return
36 | }
37 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/StyledBezier.ts:
--------------------------------------------------------------------------------
1 | import { styled, keyframes } from 'leva/plugin'
2 |
3 | export const Svg = styled('svg', {
4 | width: '100%',
5 | height: '$controlWidth',
6 | marginTop: '$rowGap',
7 | overflow: 'visible',
8 | zIndex: 100,
9 | '> path': {
10 | stroke: '$highlight3',
11 | strokeWidth: 2,
12 | },
13 | g: {
14 | color: '$accent1',
15 | '&:hover': { color: '$accent3' },
16 | '&:active': { color: '$vivid1' },
17 | },
18 | circle: {
19 | fill: 'currentColor',
20 | strokeWidth: 10,
21 | stroke: 'transparent',
22 | cursor: 'pointer',
23 | },
24 | '> line': {
25 | stroke: '$highlight1',
26 | strokeWidth: 2,
27 | },
28 | '> g > line': {
29 | stroke: 'currentColor',
30 | },
31 | variants: {
32 | withPreview: { true: { marginBottom: 0 }, false: { marginBottom: '$rowGap' } },
33 | },
34 | })
35 |
36 | const fadeIn = (o: number) =>
37 | keyframes({
38 | '0%': { opacity: 0 },
39 | '10%': { opacity: 0.8 },
40 | '100%': { opacity: o },
41 | })
42 |
43 | const move = keyframes({
44 | '0%': { transform: 'translateX(5%)' },
45 | '100%': { transform: 'translateX(95%)' },
46 | })
47 |
48 | export const PreviewSvg = styled('svg', {
49 | width: '100%',
50 | overflow: 'visible',
51 | height: 6,
52 | '> circle': {
53 | fill: '$vivid1',
54 | cy: '50%',
55 | animation: `${fadeIn(0.3)} 1000ms both`,
56 | '&:first-of-type': { animationName: fadeIn(0.7) },
57 | '&:last-of-type': { animationName: move },
58 | },
59 | })
60 |
61 | export const SyledInnerLabel = styled('div', {
62 | userSelect: 'none',
63 | $flexCenter: '',
64 | height: 14,
65 | width: 14,
66 | borderRadius: 7,
67 | marginRight: '$sm',
68 | cursor: 'pointer',
69 | fontSize: '0.8em',
70 | variants: {
71 | graph: { true: { backgroundColor: '$elevation1' } },
72 | },
73 | })
74 |
75 | export const Container = styled('div', {
76 | display: 'grid',
77 | gridTemplateColumns: 'auto 1fr',
78 | alignItems: 'center',
79 | })
80 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/bezier-plugin.ts:
--------------------------------------------------------------------------------
1 | import { normalizeVector, sanitizeVector } from 'leva/plugin'
2 | import { bezier } from './bezier-utils'
3 | import type { BezierArray, BezierInput, InternalBezierSettings, InternalBezier, BuiltInKeys } from './bezier-types'
4 |
5 | const abscissasSettings = { min: 0, max: 1, step: 0.01 }
6 | const ordinatesSettings = { step: 0.01 }
7 | const defaultSettings = { graph: true, preview: true }
8 |
9 | export const BuiltIn: Record = {
10 | ease: [0.25, 0.1, 0.25, 1],
11 | linear: [0, 0, 1, 1],
12 | 'ease-in': [0.42, 0, 1, 1],
13 | 'ease-out': [0, 0, 0.58, 1],
14 | 'ease-in-out': [0.42, 0, 0.58, 1],
15 | 'in-out-sine': [0.45, 0.05, 0.55, 0.95],
16 | 'in-out-quadratic': [0.46, 0.03, 0.52, 0.96],
17 | 'in-out-cubic': [0.65, 0.05, 0.36, 1],
18 | 'fast-out-slow-in': [0.4, 0, 0.2, 1],
19 | 'in-out-back': [0.68, -0.55, 0.27, 1.55],
20 | }
21 |
22 | export const normalize = (input: BezierInput = [0.25, 0.1, 0.25, 1]) => {
23 | let { handles, ..._settings } = typeof input === 'object' && 'handles' in input ? input : { handles: input }
24 | handles = typeof handles === 'string' ? BuiltIn[handles] : handles
25 |
26 | const mergedSettings = { x1: abscissasSettings, y1: ordinatesSettings, x2: abscissasSettings, y2: ordinatesSettings }
27 |
28 | const { value: _value, settings } = normalizeVector(handles, mergedSettings, ['x1', 'y1', 'x2', 'y2'])
29 | const value = _value as InternalBezier
30 | value.evaluate = bezier(..._value)
31 | value.cssEasing = `cubic-bezier(${_value.join(',')})`
32 | return { value, settings: { ...settings, ...defaultSettings, ..._settings } as InternalBezierSettings }
33 | }
34 |
35 | export const sanitize = (value: any, settings: InternalBezierSettings, prevValue?: any) => {
36 | const _value = sanitizeVector(value, settings, prevValue) as BezierArray
37 | const newValue = _value as InternalBezier
38 | newValue.evaluate = bezier(..._value)
39 | newValue.cssEasing = `cubic-bezier(${_value.join(',')})`
40 | return newValue
41 | }
42 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/bezier-types.ts:
--------------------------------------------------------------------------------
1 | import type { LevaInputProps, InternalVectorSettings, MergedInputWithSettings } from 'leva/plugin'
2 |
3 | export type BuiltInKeys =
4 | | 'ease'
5 | | 'linear'
6 | | 'ease-in'
7 | | 'ease-out'
8 | | 'ease-in-out'
9 | | 'in-out-sine'
10 | | 'in-out-quadratic'
11 | | 'in-out-cubic'
12 | | 'fast-out-slow-in'
13 | | 'in-out-back'
14 |
15 | export type BezierArray = [number, number, number, number]
16 |
17 | export type Bezier = BezierArray | BuiltInKeys
18 |
19 | export type BezierSettings = { graph?: boolean; preview?: boolean }
20 | export type BezierInput = MergedInputWithSettings
21 |
22 | export type InternalBezier = [number, number, number, number] & { evaluate(value: number): number; cssEasing: string }
23 |
24 | export type DisplayValueBezier = { x1: number; y1: number; x2: number; y2: number }
25 |
26 | export type InternalBezierSettings = InternalVectorSettings<
27 | keyof DisplayValueBezier,
28 | (keyof DisplayValueBezier)[],
29 | 'array'
30 | > & { graph: boolean; preview: boolean }
31 |
32 | export type BezierProps = LevaInputProps
33 |
--------------------------------------------------------------------------------
/packages/plugin-bezier/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin, formatVector } from 'leva/plugin'
2 | import { Bezier } from './Bezier'
3 | import { normalize, sanitize } from './bezier-plugin'
4 | import { InternalBezierSettings } from './bezier-types'
5 |
6 | export const bezier = createPlugin({
7 | normalize,
8 | sanitize,
9 | format: (value: any, settings: InternalBezierSettings) => formatVector(value, settings),
10 | component: Bezier,
11 | })
12 |
--------------------------------------------------------------------------------
/packages/plugin-dates/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @leva-ui/plugin-dates
2 |
3 | ## 0.10.1
4 |
5 | ### Patch Changes
6 |
7 | - 89764b0: fix(plugin-dates): update React Calendar
8 |
9 | ## 0.10.0
10 |
11 | ### Minor Changes
12 |
13 | - 3d4a620: feat!: React 18 and 19 support
14 |
15 | ### Patch Changes
16 |
17 | - Updated dependencies [b9c6376]
18 | - Updated dependencies [3d4a620]
19 | - leva@0.10.0
20 |
21 | ## 0.9.32
22 |
23 | ### Patch Changes
24 |
25 | - 8b21a5c: fix: scrolling long panels
26 | - Updated dependencies [8b21a5c]
27 | - leva@0.9.34
28 |
29 | ## 0.9.31
30 |
31 | ### Major Changes
32 |
33 | - 14a5605: feat: new date picker plugin
34 |
--------------------------------------------------------------------------------
/packages/plugin-dates/README.md:
--------------------------------------------------------------------------------
1 | ## Leva Plot
2 |
3 | ### Installation
4 |
5 | ```bash
6 | npm i @leva-ui/plugin-plot
7 | ```
8 |
9 | ### Quick start
10 |
11 | ```jsx
12 | import { useControls } from 'leva'
13 | import { plot } from '@leva-ui/plugin-plot'
14 |
15 | function MyComponent() {
16 | const { y } = useControls({ y: plot({ expression: 'cos(x)', graph: true, boundsX: [-10, 10], boundsY: [0, 100] }) })
17 | return y(Math.PI)
18 | }
19 | ```
20 |
--------------------------------------------------------------------------------
/packages/plugin-dates/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@leva-ui/plugin-dates",
3 | "version": "0.10.1",
4 | "main": "dist/leva-ui-plugin-dates.cjs.js",
5 | "module": "dist/leva-ui-plugin-dates.esm.js",
6 | "types": "dist/leva-ui-plugin-dates.cjs.d.ts",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/pmndrs/leva.git",
11 | "directory": "packages/plugin-dates"
12 | },
13 | "bugs": "https://github.com/pmndrs/leva/issues",
14 | "peerDependencies": {
15 | "@use-gesture/react": "^10.0.0",
16 | "leva": ">=0.10.0",
17 | "react": "^18.0.0 || ^19.0.0",
18 | "react-dom": "^18.0.0 || ^19.0.0"
19 | },
20 | "dependencies": {
21 | "react-datepicker": "^7.6.0"
22 | },
23 | "devDependencies": {
24 | "@types/react-datepicker": "^7.0.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/Date.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../../leva/stories/components/decorator-reset'
5 | import { useControls } from '../../leva/src'
6 |
7 | import { date } from './index'
8 | import { DateInput } from './date-types'
9 |
10 | export default {
11 | title: 'Plugins/Dates',
12 | decorators: [Reset],
13 | } as Meta
14 |
15 | const Template: Story = (args) => {
16 | const { birthday } = useControls({ birthday: date(args) })
17 | return {birthday.formattedDate}
18 | }
19 |
20 | export const DefaultDate = Template.bind({})
21 | DefaultDate.args = { date: new Date() }
22 |
23 | export const CustomLocale = Template.bind({})
24 | CustomLocale.args = { date: new Date(), locale: 'en-US' }
25 |
26 | export const CustomInputFormat = Template.bind({})
27 | CustomInputFormat.args = { date: new Date(), inputFormat: 'yyyy-MM-dd' }
28 |
29 | export const WithOtherFields = () => {
30 | const { birthday, ...values } = useControls({
31 | text: 'text',
32 | birthday: date({ date: new Date() }),
33 | number: 0,
34 | })
35 | return (
36 |
37 | {birthday.formattedDate}
38 |
39 | {JSON.stringify(values)}
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/Date.tsx:
--------------------------------------------------------------------------------
1 | import { Components, useInputContext } from 'leva/plugin'
2 | import React, { forwardRef, useState } from 'react'
3 | import DatePicker, { CalendarContainer } from 'react-datepicker'
4 | import 'react-datepicker/dist/react-datepicker.css'
5 | import { DateCalendarContainerProps, DateInputProps, DateProps } from './date-types'
6 | import { InputContainer, StyledInput, StyledWrapper } from './StyledDate'
7 |
8 | const { Label, Row } = Components
9 |
10 | const DateCalendarContainer = ({ children }: DateCalendarContainerProps) => {
11 | return (
12 |
13 | {/* @ts-expect-error portal JSX types are broken upstream */}
14 | {children}
15 |
16 | )
17 | }
18 |
19 | const DateInput = forwardRef>(({ value, onClick, onChange }, ref) => {
20 | return
21 | })
22 |
23 | export function Date() {
24 | const { label, value, onUpdate, settings } = useInputContext()
25 |
26 | const [isOpen, setIsOpen] = useState(false)
27 |
28 | return (
29 |
30 |
31 |
32 | }
38 | onCalendarOpen={() => setIsOpen(true)}
39 | onCalendarClose={() => setIsOpen(false)}
40 | />
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/StyledDate.ts:
--------------------------------------------------------------------------------
1 | import { styled } from 'leva/plugin'
2 |
3 | export const StyledInput = styled('input', {
4 | $reset: '',
5 | padding: '0 $sm',
6 | width: '100%',
7 | minWidth: 0,
8 | flex: 1,
9 | height: '100%',
10 | })
11 |
12 | export const InputContainer = styled('div', {
13 | $flex: '',
14 | position: 'relative',
15 | borderRadius: '$sm',
16 | color: 'inherit',
17 | height: '$rowHeight',
18 | backgroundColor: '$elevation3',
19 | $inputStyle: '$elevation1',
20 | $hover: '',
21 | $focusWithin: '',
22 | variants: {
23 | textArea: { true: { height: 'auto' } },
24 | },
25 | })
26 |
27 | export const StyledWrapper = styled('div', {
28 | position: 'relative',
29 |
30 | '& .react-datepicker__header': {
31 | backgroundColor: '$elevation3',
32 | border: 'none',
33 | },
34 |
35 | '& .react-datepicker__current-month, .react-datepicker__day, .react-datepicker__day-name': {
36 | color: 'inherit',
37 | },
38 |
39 | '& .react-datepicker__day': {
40 | transition: 'all 0.2s ease',
41 | },
42 |
43 | '& .react-datepicker__day--selected': {
44 | backgroundColor: '$accent1',
45 | color: '$highlight3',
46 | },
47 |
48 | '& .react-datepicker__day--keyboard-selected': {
49 | backgroundColor: 'transparent',
50 | color: 'inherit',
51 | },
52 |
53 | '& .react-datepicker__day--today': {
54 | backgroundColor: '$accent3',
55 | color: '$highlight3',
56 | },
57 |
58 | '& .react-datepicker__month-container': {
59 | backgroundColor: '$elevation2',
60 | borderRadius: '$lg',
61 | },
62 |
63 | '& .react-datepicker__day:hover': {
64 | backgroundColor: '$highlight1',
65 | },
66 | })
67 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/date-plugin.ts:
--------------------------------------------------------------------------------
1 | import type { DateInput, DateSettings, InternalDate, InternalDateSettings } from './date-types'
2 | import { formatDate } from './date-utils'
3 |
4 | const defaultSettings = {
5 | inputFormat: 'MM/dd/yyyy',
6 | }
7 |
8 | export const sanitize = (value: Date, settings: DateSettings) => {
9 | return {
10 | date: value,
11 | formattedDate: formatDate(value, settings.locale),
12 | }
13 | }
14 |
15 | export const format = (value: InternalDate, settings: DateSettings) => {
16 | return {
17 | date: value.date,
18 | formattedDate: formatDate(value.date, settings.locale),
19 | }
20 | }
21 |
22 | export const normalize = ({ date, ..._settings }: DateInput) => {
23 | const settings = { ...defaultSettings, ..._settings }
24 | const defaultDate = date ?? new Date()
25 | return {
26 | value: { date: defaultDate, formattedDate: formatDate(defaultDate, settings.locale) },
27 | settings: settings as InternalDateSettings,
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/date-types.ts:
--------------------------------------------------------------------------------
1 | import type { LevaInputProps } from 'leva/plugin'
2 | import { ChangeEventHandler, MouseEventHandler } from 'react'
3 | import { CalendarContainer } from 'react-datepicker'
4 |
5 | export type DateSettings = { locale: string; inputFormat: string }
6 | export type DateInput = { date: Date } & Partial
7 |
8 | // TODO: export this upstream
9 | export type DateCalendarContainerProps = React.ComponentProps
10 | export type DateInputProps = { value: string; onClick: MouseEventHandler; onChange: ChangeEventHandler }
11 |
12 | export type InternalDate = { date: Date; formattedDate: string }
13 |
14 | export type InternalDateSettings = Required
15 |
16 | export type DateProps = LevaInputProps
17 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/date-utils.ts:
--------------------------------------------------------------------------------
1 | export function parseDate(date: string, locale: string) {
2 | return new Date(date)
3 | }
4 |
5 | export function formatDate(date: Date, locale?: string) {
6 | return date.toLocaleDateString(locale)
7 | }
8 |
--------------------------------------------------------------------------------
/packages/plugin-dates/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from 'leva/plugin'
2 | import { Date } from './Date'
3 | import { sanitize, normalize, format } from './date-plugin'
4 |
5 | export const date = createPlugin({
6 | sanitize,
7 | format,
8 | normalize,
9 | component: Date,
10 | })
11 |
--------------------------------------------------------------------------------
/packages/plugin-plot/README.md:
--------------------------------------------------------------------------------
1 | ## Leva Plot
2 |
3 | ### Installation
4 |
5 | ```bash
6 | npm i @leva-ui/plugin-plot
7 | ```
8 |
9 | ### Quick start
10 |
11 | ```jsx
12 | import { useControls } from 'leva'
13 | import { plot } from '@leva-ui/plugin-plot'
14 |
15 | function MyComponent() {
16 | const { y } = useControls({ y: plot({ expression: 'cos(x)', graph: true, boundsX: [-10, 10], boundsY: [0, 100] }) })
17 | return y(Math.PI)
18 | }
19 | ```
20 |
--------------------------------------------------------------------------------
/packages/plugin-plot/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@leva-ui/plugin-plot",
3 | "version": "0.10.0",
4 | "main": "dist/leva-ui-plugin-plot.cjs.js",
5 | "module": "dist/leva-ui-plugin-plot.esm.js",
6 | "types": "dist/leva-ui-plugin-plot.cjs.d.ts",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/pmndrs/leva.git",
11 | "directory": "packages/plugin-plot"
12 | },
13 | "bugs": "https://github.com/pmndrs/leva/issues",
14 | "peerDependencies": {
15 | "@use-gesture/react": "^10.0.0",
16 | "leva": ">=0.10.0",
17 | "react": "^18.0.0 || ^19.0.0",
18 | "react-dom": "^18.0.0 || ^19.0.0"
19 | },
20 | "dependencies": {
21 | "mathjs": "^10.1.1"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/Plot.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../../leva/stories/components/decorator-reset'
5 | import { useControls } from '../../leva/src'
6 |
7 | import { plot } from './index'
8 |
9 | export default {
10 | title: 'Plugins/Plot',
11 | decorators: [Reset],
12 | } as Meta
13 |
14 | const Template: Story = (args) => {
15 | const { y } = useControls({ y: plot(args) })
16 | return (
17 |
18 | {[0, 0.5, -1].map((x) => (
19 |
20 | y({x}) = {y(x).toFixed(2)}
21 |
22 | ))}
23 |
24 | )
25 | }
26 |
27 | export const DefaultBounds = Template.bind({})
28 | DefaultBounds.args = { expression: 'x' }
29 |
30 | export const HideGraph = Template.bind({})
31 | HideGraph.args = { expression: 'x', graph: false }
32 |
33 | export const BoundsX = Template.bind({})
34 | BoundsX.args = { expression: 'cos(x)', boundsX: [-10, 10] }
35 |
36 | export const BoundsY = Template.bind({})
37 | BoundsY.args = { expression: 'sin(x) * tan(x)', boundsX: [-10, 10], boundsY: [-1, 1] }
38 |
39 | export const InputAsVariable = () => {
40 | const { y } = useControls({ var: 10, y: plot({ expression: 'cos(x * var)' }) })
41 | return (
42 |
43 | {[0, 0.5, -1].map((x) => (
44 |
45 | y({x}) = {y(x).toFixed(2)}
46 |
47 | ))}
48 |
49 | )
50 | }
51 |
52 | export const CurveAsVariable = () => {
53 | const { y2 } = useControls({
54 | var: 10,
55 | y1: plot({ expression: 'cos(x * var)' }),
56 | y2: plot({ expression: 'x * y1' }),
57 | })
58 | return (
59 |
60 | {[0, 0.5, -1].map((x) => (
61 |
62 | y2({x}) = {y2(x).toFixed(2)}
63 |
64 | ))}
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/Plot.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import { useInputContext, useValues, Components } from 'leva/plugin'
3 | import { PlotCanvas } from './PlotCanvas'
4 | import type { PlotProps } from './plot-types'
5 | import { SyledInnerLabel, Container } from './StyledPlot'
6 |
7 | const { Label, Row, String } = Components
8 |
9 | export function Plot() {
10 | const { label, value, displayValue, settings, onUpdate, onChange, setSettings } = useInputContext()
11 |
12 | const { graph } = settings
13 |
14 | const scope = useValues(value.__symbols)
15 | const displayRef = useRef(displayValue)
16 | displayRef.current = displayValue
17 |
18 | useEffect(() => {
19 | // recomputes when scope which holds the values of the symbols change
20 | onUpdate(displayRef.current)
21 | }, [scope, onUpdate])
22 |
23 | return (
24 | <>
25 | {graph && (
26 |
27 |
28 |
29 | )}
30 |
31 |
32 |
33 | setSettings({ graph: !graph })}>
34 | 𝑓
35 |
36 |
37 |
38 |
39 | >
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/StyledPlot.ts:
--------------------------------------------------------------------------------
1 | import { styled } from 'leva/plugin'
2 |
3 | export const Wrapper = styled('div', {
4 | position: 'relative',
5 | height: 80,
6 | width: '100%',
7 | marginBottom: '$sm',
8 | })
9 |
10 | export const ToolTip = styled('div', {
11 | position: 'absolute',
12 | top: -4,
13 | pointerEvents: 'none',
14 | fontFamily: '$mono',
15 | fontSize: 'calc($fontSizes$root * 0.9)',
16 | padding: '$xs $sm',
17 | color: '$toolTipBackground',
18 | backgroundColor: '$toolTipText',
19 | borderRadius: '$xs',
20 | whiteSpace: 'nowrap',
21 | transform: 'translate(-50%, -100%)',
22 | boxShadow: '$level2',
23 | })
24 |
25 | export const Canvas = styled('canvas', {
26 | height: '100%',
27 | width: '100%',
28 | })
29 |
30 | export const Dot = styled('div', {
31 | position: 'absolute',
32 | height: 6,
33 | width: 6,
34 | borderRadius: 6,
35 | backgroundColor: '$highlight3',
36 | pointerEvents: 'none',
37 | })
38 |
39 | export const SyledInnerLabel = styled('div', {
40 | userSelect: 'none',
41 | $flexCenter: '',
42 | height: 14,
43 | width: 14,
44 | borderRadius: 7,
45 | marginRight: '$sm',
46 | cursor: 'pointer',
47 | fontSize: '0.8em',
48 | variants: {
49 | graph: { true: { backgroundColor: '$elevation1' } },
50 | },
51 | })
52 |
53 | export const Container = styled('div', {
54 | display: 'grid',
55 | gridTemplateColumns: 'auto 1fr',
56 | alignItems: 'center',
57 | })
58 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from 'leva/plugin'
2 | import { Plot } from './Plot'
3 | import { normalize, sanitize, format } from './plot-plugin'
4 |
5 | export const plot = createPlugin({
6 | normalize,
7 | sanitize,
8 | format,
9 | component: Plot,
10 | })
11 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/plot-plugin.ts:
--------------------------------------------------------------------------------
1 | import { Data, StoreType } from 'packages/leva/src/types'
2 | import * as math from 'mathjs'
3 | import { parseExpression } from './plot-utils'
4 | import type { PlotInput, InternalPlot, InternalPlotSettings } from './plot-types'
5 |
6 | export const sanitize = (
7 | expression: string,
8 | _settings: InternalPlotSettings,
9 | _prevValue: math.MathNode,
10 | _path: string,
11 | store: StoreType
12 | ) => {
13 | if (expression === '') throw Error('Empty mathjs expression')
14 | try {
15 | return parseExpression(expression, store.get)
16 | } catch (e) {
17 | throw Error('Invalid mathjs expression string')
18 | }
19 | }
20 |
21 | export const format = (value: InternalPlot) => {
22 | return value.__parsed.toString()
23 | }
24 |
25 | const defaultSettings = { boundsX: [-1, 1], boundsY: [-Infinity, Infinity], graph: true }
26 |
27 | export const normalize = ({ expression, ..._settings }: PlotInput, _path: string, data: Data) => {
28 | const get = (path: string) => {
29 | // @ts-expect-error
30 | if ('value' in data[path]) return data[path].value
31 | return undefined // TODO should throw
32 | }
33 | const value = parseExpression(expression, get) as (v: number) => any
34 | const settings = { ...defaultSettings, ..._settings }
35 | return { value, settings: settings as InternalPlotSettings }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/plot-types.ts:
--------------------------------------------------------------------------------
1 | import type { LevaInputProps } from 'leva/plugin'
2 |
3 | export type Plot = { expression: string }
4 | export type PlotSettings = { boundsX?: [number, number]; boundsY?: [number, number]; graph?: boolean }
5 | export type PlotInput = Plot & PlotSettings
6 |
7 | export type InternalPlot = {
8 | (v: number): any
9 | __parsedScoped: math.MathNode
10 | __parsed: math.MathNode
11 | __symbols: string[]
12 | }
13 |
14 | export type InternalPlotSettings = Required
15 |
16 | export type PlotProps = LevaInputProps
17 |
--------------------------------------------------------------------------------
/packages/plugin-plot/src/plot-utils.ts:
--------------------------------------------------------------------------------
1 | import * as math from 'mathjs'
2 |
3 | export function getSymbols(expr: math.MathNode) {
4 | return expr
5 | .filter((node) => {
6 | if (node instanceof math.SymbolNode && node.isSymbolNode) {
7 | try {
8 | const e = node.evaluate()
9 | return !!e.units
10 | } catch {
11 | return node.name !== 'x'
12 | }
13 | }
14 | return false
15 | })
16 | .map((node: unknown) => (node as math.SymbolNode).name)
17 | }
18 |
19 | export function parseExpression(expression: string, get: (path: string) => any) {
20 | const parsed = math.parse(expression)
21 | const symbols = getSymbols(parsed)
22 | const scope = symbols.reduce((acc, path) => {
23 | const symbol = get(path)
24 | if (!symbol) throw Error(`Invalid symbol at path \`${path}\``)
25 | return Object.assign(acc, { [path]: symbol })
26 | }, {} as { [key in keyof typeof symbols]: any })
27 |
28 | let _formattedString = parsed.toString()
29 |
30 | for (let key in scope) {
31 | const re = new RegExp(`\\b${key}\\b`, 'g')
32 | // TODO check type better than this
33 | const s = typeof scope[key] === 'function' ? scope[key].__parsedScoped.toString() : scope[key]
34 | _formattedString = _formattedString.replace(re, s)
35 | }
36 |
37 | const parsedScoped = math.parse(_formattedString)
38 | const compiled = parsedScoped.compile()
39 |
40 | function expr(v: number) {
41 | return compiled.evaluate({ x: v })
42 | }
43 |
44 | Object.assign(expr, {
45 | __parsedScoped: parsedScoped,
46 | __parsed: parsed,
47 | __symbols: symbols,
48 | })
49 |
50 | return expr
51 | }
52 |
--------------------------------------------------------------------------------
/packages/plugin-spring/README.md:
--------------------------------------------------------------------------------
1 | ## Leva Spring
2 |
3 | ### Installation
4 |
5 | ```bash
6 | npm i @leva-ui/plugin-spring
7 | ```
8 |
9 | ### Quick start
10 |
11 | ```jsx
12 | import { useControls } from 'leva'
13 | import { spring } from '@leva-ui/plugin-spring'
14 |
15 | function MyComponent() {
16 | const { mySpring } = useControls({ mySpring: spring({ tension: 100, friction: 30, mass: 1 }) })
17 | return mySpring.toString()
18 | }
19 | ```
20 |
--------------------------------------------------------------------------------
/packages/plugin-spring/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@leva-ui/plugin-spring",
3 | "version": "0.10.0",
4 | "main": "dist/leva-ui-plugin-spring.cjs.js",
5 | "module": "dist/leva-ui-plugin-spring.esm.js",
6 | "types": "dist/leva-ui-plugin-spring.cjs.d.ts",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/pmndrs/leva.git",
11 | "directory": "packages/plugin-spring"
12 | },
13 | "bugs": "https://github.com/pmndrs/leva/issues",
14 | "peerDependencies": {
15 | "leva": ">=0.10.0",
16 | "react": "^18.0.0 || ^19.0.0",
17 | "react-dom": "^18.0.0 || ^19.0.0"
18 | },
19 | "dependencies": {
20 | "@react-spring/web": "9.4.2"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/Spring.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Story, Meta } from '@storybook/react'
3 |
4 | import Reset from '../../leva/stories/components/decorator-reset'
5 | import { useControls } from '../../leva/src'
6 |
7 | import { spring } from './index'
8 |
9 | export default {
10 | title: 'Plugins/Spring',
11 | decorators: [Reset],
12 | } as Meta
13 |
14 | const Template: Story = (args) => {
15 | const values = useControls(
16 | {
17 | bar: spring({ tension: 100, friction: 30 }),
18 | },
19 | args
20 | )
21 |
22 | return (
23 |
24 |
{JSON.stringify(values, null, ' ')}
25 |
26 | )
27 | }
28 |
29 | export const Spring = Template.bind({})
30 | Spring.args = {}
31 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/Spring.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useInputContext, Components } from 'leva/plugin'
3 | import { SpringCanvas } from './SpringCanvas'
4 | import type { SpringProps } from './spring-types'
5 |
6 | const { Row, Label, Vector } = Components
7 |
8 | export function Spring() {
9 | const { label, displayValue, onUpdate, settings } = useInputContext()
10 |
11 | return (
12 | <>
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | >
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/StyledSpring.ts:
--------------------------------------------------------------------------------
1 | import { styled } from 'leva/plugin'
2 |
3 | export const Canvas = styled('canvas', {
4 | height: 80,
5 | width: '100%',
6 | cursor: 'crosshair',
7 | display: 'block',
8 | $draggable: '',
9 | })
10 |
11 | export const SpringPreview = styled('div', {
12 | position: 'relative',
13 | top: -2,
14 | backgroundColor: '$accent2',
15 | width: '100%',
16 | height: 2,
17 | opacity: 0.2,
18 | borderRadius: 1,
19 | transition: 'opacity 350ms ease',
20 | transformOrigin: 'left',
21 | })
22 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from 'leva/plugin'
2 | import { Spring } from './Spring'
3 | import { normalize, sanitize } from './spring-plugin'
4 |
5 | export const spring = createPlugin({
6 | normalize,
7 | sanitize,
8 | component: Spring,
9 | })
10 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/math.ts:
--------------------------------------------------------------------------------
1 | export function springFn(tension: number, friction: number, mass = 1) {
2 | const w0 = Math.sqrt(tension / mass) / 1000 // angular frequency in rad/ms
3 | const zeta = friction / (2 * Math.sqrt(tension * mass)) // damping ratio
4 |
5 | const w1 = w0 * Math.sqrt(1.0 - zeta * zeta) // exponential decay
6 | const w2 = w0 * Math.sqrt(zeta * zeta - 1.0) // frequency of damped oscillation
7 |
8 | const v_0 = 0
9 |
10 | const to = 1
11 | const from = 0
12 | const x_0 = to - from
13 |
14 | if (zeta < 1) {
15 | // Under damped
16 | return (t: number) =>
17 | to - Math.exp(-zeta * w0 * t) * (((-v_0 + zeta * w0 * x_0) / w1) * Math.sin(w1 * t) + x_0 * Math.cos(w1 * t))
18 | } else if (zeta === 1) {
19 | // Critically damped
20 | return (t: number) => to - Math.exp(-w0 * t) * (x_0 + (-v_0 + w0 * x_0) * t)
21 | } else {
22 | // Overdamped
23 | return (t: number) =>
24 | to -
25 | (Math.exp(-zeta * w0 * t) * ((-v_0 + zeta * w0 * x_0) * Math.sinh(w2 * t) + w2 * x_0 * Math.cosh(w2 * t))) / w2
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/spring-plugin.ts:
--------------------------------------------------------------------------------
1 | import { normalizeVector, sanitizeVector } from 'leva/plugin'
2 | import type { InternalSpring, InternalSpringSettings, SpringInput } from './spring-types'
3 |
4 | const defaultTensionSettings = { min: 1, step: 1 }
5 | const defaultFrictionSettings = { min: 1, step: 0.5 }
6 | const defaultMassSettings = { min: 0.1, step: 0.1 }
7 | const defaultValue = { tension: 100, friction: 30, mass: 1 }
8 |
9 | export const normalize = (input: SpringInput = {}) => {
10 | const { value: _value, ..._settings } = 'value' in input ? input : { value: input }
11 | const mergedSettings = {
12 | tension: { ...defaultTensionSettings, ..._settings.tension },
13 | friction: { ...defaultFrictionSettings, ..._settings.friction },
14 | mass: { ...defaultMassSettings, ..._settings.mass },
15 | }
16 |
17 | const { value, settings } = normalizeVector({ ...defaultValue, ..._value }, mergedSettings)
18 | return { value, settings: settings as InternalSpringSettings }
19 | }
20 |
21 | export const sanitize = (value: InternalSpring, settings: InternalSpringSettings, prevValue?: any) =>
22 | sanitizeVector(value, settings, prevValue)
23 |
--------------------------------------------------------------------------------
/packages/plugin-spring/src/spring-types.ts:
--------------------------------------------------------------------------------
1 | import type { InputWithSettings, NumberSettings, LevaInputProps, InternalVectorSettings } from 'leva/plugin'
2 |
3 | export type Spring = { tension?: number; friction?: number; mass?: number }
4 | export type InternalSpring = { tension: number; friction: number; mass: number }
5 | export type SpringSettings = { [key in keyof Spring]?: NumberSettings }
6 |
7 | export type SpringInput = Spring | InputWithSettings
8 |
9 | export type InternalSpringSettings = InternalVectorSettings
10 |
11 | export type SpringProps = LevaInputProps
12 |
--------------------------------------------------------------------------------