From 9816eec4fae058cb8d4b0b0b2978ca444ed9541c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 5 Apr 2024 15:10:59 +0100 Subject: [PATCH] feat: add signature pad machine (#1399) * chore: initial design * chore: wip * chore: add more examples * chore: add svelte example * chore: update * chore: rename parts * docs: add changeset --- .changeset/healthy-readers-clean.md | 5 + .xstate/signature-pad.js | 57 ++++ examples/next-ts/package.json | 1 + examples/next-ts/pages/signature-pad.tsx | 69 +++++ examples/nuxt-ts/package.json | 1 + examples/nuxt-ts/pages/signature-pad.vue | 54 ++++ examples/preact-ts/package.json | 1 + examples/solid-ts/package.json | 1 + examples/solid-ts/src/pages/signature-pad.tsx | 68 +++++ examples/solid-ts/src/routes.ts | 1 + examples/svelte-ts/package.json | 3 +- examples/svelte-ts/src/App.svelte | 2 + .../svelte-ts/src/routes/signature-pad.svelte | 70 +++++ examples/vue-ts/package.json | 1 + examples/vue-ts/src/pages/signature-pad.tsx | 75 +++++ examples/vue-ts/src/routes.ts | 1 + packages/machines/signature-pad/README.md | 19 ++ packages/machines/signature-pad/package.json | 50 +++ .../signature-pad/src/get-svg-path.ts | 30 ++ packages/machines/signature-pad/src/index.ts | 11 + .../src/signature-pad.anatomy.ts | 13 + .../src/signature-pad.connect.ts | 101 ++++++ .../signature-pad/src/signature-pad.dom.ts | 54 ++++ .../src/signature-pad.machine.ts | 115 +++++++ .../signature-pad/src/signature-pad.props.ts | 16 + .../signature-pad/src/signature-pad.types.ts | 131 ++++++++ packages/machines/signature-pad/tsconfig.json | 7 + packages/types/src/jsx.ts | 289 ++++++++++++++++++ packages/types/src/prop-types.ts | 13 +- packages/utilities/dom-query/src/query.ts | 4 +- pnpm-lock.yaml | 50 +++ shared/src/controls.ts | 4 + shared/src/css/signature-pad.css | 49 +++ shared/src/routes.ts | 1 + shared/src/style.css | 1 + website/data/components/signature-pad.mdx | 131 ++++++++ 36 files changed, 1495 insertions(+), 4 deletions(-) create mode 100644 .changeset/healthy-readers-clean.md create mode 100644 .xstate/signature-pad.js create mode 100644 examples/next-ts/pages/signature-pad.tsx create mode 100644 examples/nuxt-ts/pages/signature-pad.vue create mode 100644 examples/solid-ts/src/pages/signature-pad.tsx create mode 100644 examples/svelte-ts/src/routes/signature-pad.svelte create mode 100644 examples/vue-ts/src/pages/signature-pad.tsx create mode 100644 packages/machines/signature-pad/README.md create mode 100644 packages/machines/signature-pad/package.json create mode 100644 packages/machines/signature-pad/src/get-svg-path.ts create mode 100644 packages/machines/signature-pad/src/index.ts create mode 100644 packages/machines/signature-pad/src/signature-pad.anatomy.ts create mode 100644 packages/machines/signature-pad/src/signature-pad.connect.ts create mode 100644 packages/machines/signature-pad/src/signature-pad.dom.ts create mode 100644 packages/machines/signature-pad/src/signature-pad.machine.ts create mode 100644 packages/machines/signature-pad/src/signature-pad.props.ts create mode 100644 packages/machines/signature-pad/src/signature-pad.types.ts create mode 100644 packages/machines/signature-pad/tsconfig.json create mode 100644 shared/src/css/signature-pad.css create mode 100644 website/data/components/signature-pad.mdx diff --git a/.changeset/healthy-readers-clean.md b/.changeset/healthy-readers-clean.md new file mode 100644 index 0000000000..67c7a31f16 --- /dev/null +++ b/.changeset/healthy-readers-clean.md @@ -0,0 +1,5 @@ +--- +"@zag-js/signature-pad": minor +--- + +[NEW] Signature Pad machine to allow capturing user signatures diff --git a/.xstate/signature-pad.js b/.xstate/signature-pad.js new file mode 100644 index 0000000000..c0c073b1c1 --- /dev/null +++ b/.xstate/signature-pad.js @@ -0,0 +1,57 @@ +"use strict"; + +var _xstate = require("xstate"); +const { + actions, + createMachine, + assign +} = _xstate; +const { + choose +} = actions; +const fetchMachine = createMachine({ + id: "signature-pad", + initial: "idle", + context: {}, + on: { + CLEAR: { + actions: ["clearPoints", "invokeOnDrawEnd", "focusCanvasEl"] + } + }, + on: { + UPDATE_CONTEXT: { + actions: "updateContext" + } + }, + states: { + idle: { + on: { + POINTER_DOWN: { + target: "drawing", + actions: ["addPoint"] + } + } + }, + drawing: { + activities: ["trackPointerMove"], + on: { + POINTER_MOVE: { + actions: ["addPoint", "invokeOnDraw"] + }, + POINTER_UP: { + target: "idle", + actions: ["endStroke", "invokeOnDrawEnd"] + } + } + } + } +}, { + actions: { + updateContext: assign((context, event) => { + return { + [event.contextKey]: true + }; + }) + }, + guards: {} +}); \ No newline at end of file diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index 9c8cf031f2..d9510a82bb 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -64,6 +64,7 @@ "@zag-js/remove-scroll": "workspace:*", "@zag-js/select": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/splitter": "workspace:*", "@zag-js/store": "workspace:*", diff --git a/examples/next-ts/pages/signature-pad.tsx b/examples/next-ts/pages/signature-pad.tsx new file mode 100644 index 0000000000..227e504601 --- /dev/null +++ b/examples/next-ts/pages/signature-pad.tsx @@ -0,0 +1,69 @@ +import { normalizeProps, useMachine } from "@zag-js/react" +import { signaturePadControls } from "@zag-js/shared" +import * as signaturePad from "@zag-js/signature-pad" +import { RotateCcw } from "lucide-react" +import { useId, useState } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +export default function Page() { + const [url, setUrl] = useState("") + + const controls = useControls(signaturePadControls) + + const [state, send] = useMachine( + signaturePad.machine({ + id: useId(), + onDrawEnd(details) { + details.getDataUrl("image/png").then(setUrl) + }, + drawing: { + fill: "red", + size: 4, + simulatePressure: true, + }, + }), + { context: controls.context }, + ) + + const api = signaturePad.connect(state, send, normalizeProps) + + return ( + <> +
+
+ + +
+ + {api.paths.map((path, i) => ( + + ))} + {api.currentPath && } + + +
+
+ + +
+ + + {url && signature} +
+ + + + + + ) +} diff --git a/examples/nuxt-ts/package.json b/examples/nuxt-ts/package.json index db1157058d..64fbdcca8d 100644 --- a/examples/nuxt-ts/package.json +++ b/examples/nuxt-ts/package.json @@ -62,6 +62,7 @@ "@zag-js/remove-scroll": "workspace:*", "@zag-js/select": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/splitter": "workspace:*", "@zag-js/store": "workspace:*", diff --git a/examples/nuxt-ts/pages/signature-pad.vue b/examples/nuxt-ts/pages/signature-pad.vue new file mode 100644 index 0000000000..4b6531229a --- /dev/null +++ b/examples/nuxt-ts/pages/signature-pad.vue @@ -0,0 +1,54 @@ + + + diff --git a/examples/preact-ts/package.json b/examples/preact-ts/package.json index 012378d08d..7109476e12 100644 --- a/examples/preact-ts/package.json +++ b/examples/preact-ts/package.json @@ -62,6 +62,7 @@ "@zag-js/remove-scroll": "workspace:*", "@zag-js/select": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/splitter": "workspace:*", "@zag-js/store": "workspace:*", diff --git a/examples/solid-ts/package.json b/examples/solid-ts/package.json index 9e4dcb7fdb..dba1b9c3b8 100644 --- a/examples/solid-ts/package.json +++ b/examples/solid-ts/package.json @@ -71,6 +71,7 @@ "@zag-js/remove-scroll": "workspace:*", "@zag-js/select": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/solid": "workspace:*", "@zag-js/splitter": "workspace:*", diff --git a/examples/solid-ts/src/pages/signature-pad.tsx b/examples/solid-ts/src/pages/signature-pad.tsx new file mode 100644 index 0000000000..652cb10b73 --- /dev/null +++ b/examples/solid-ts/src/pages/signature-pad.tsx @@ -0,0 +1,68 @@ +import { signaturePadControls } from "@zag-js/shared" +import * as signaturePad from "@zag-js/signature-pad" +import { normalizeProps, useMachine } from "@zag-js/solid" +import { For, Show, createMemo, createSignal, createUniqueId } from "solid-js" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" +import { RotateCcw } from "lucide-solid" + +export default function Page() { + const [url, setUrl] = createSignal("") + + const controls = useControls(signaturePadControls) + + const [state, send] = useMachine( + signaturePad.machine({ + id: createUniqueId(), + onDrawEnd(details) { + details.getDataUrl("image/png").then(setUrl) + }, + }), + { + context: controls.context, + }, + ) + + const api = createMemo(() => signaturePad.connect(state, send, normalizeProps)) + + return ( + <> +
+
+ + +
+ + {(path) => } + + {(path) => } + + +
+
+ + +
+ + + + + signature + +
+ + + + + + ) +} diff --git a/examples/solid-ts/src/routes.ts b/examples/solid-ts/src/routes.ts index 9c8611c646..c7d434168b 100644 --- a/examples/solid-ts/src/routes.ts +++ b/examples/solid-ts/src/routes.ts @@ -4,6 +4,7 @@ import { lazy } from "solid-js" import Home from "./pages/home" export const routes: RouteDefinition[] = [ + { path: "/signature-pad", component: lazy(() => import("./pages/signature-pad")) }, { path: "/floating-panel", component: lazy(() => import("./pages/floating-panel")) }, { path: "/tour", component: lazy(() => import("./pages/tour")) }, { path: "/collapsible", component: lazy(() => import("./pages/collapsible")) }, diff --git a/examples/svelte-ts/package.json b/examples/svelte-ts/package.json index 0665832867..348f1be2f6 100644 --- a/examples/svelte-ts/package.json +++ b/examples/svelte-ts/package.json @@ -63,6 +63,7 @@ "@zag-js/remove-scroll": "workspace:*", "@zag-js/select": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/splitter": "workspace:*", "@zag-js/store": "workspace:*", @@ -95,4 +96,4 @@ "vite": "5.2.7", "vite-tsconfig-paths": "4.3.2" } -} +} \ No newline at end of file diff --git a/examples/svelte-ts/src/App.svelte b/examples/svelte-ts/src/App.svelte index f0f01bca0d..cb0f33a1b8 100644 --- a/examples/svelte-ts/src/App.svelte +++ b/examples/svelte-ts/src/App.svelte @@ -17,6 +17,7 @@ import PinInput from "./routes/pin-input.svelte" import Progress from "./routes/progress.svelte" import Select from "./routes/select.svelte" + import SignaturePad from "./routes/signature-pad.svelte" import Slider from "./routes/slider.svelte" import Tabs from "./routes/tabs.svelte" import TagsInput from "./routes/tags-input.svelte" @@ -44,6 +45,7 @@ { path: "/progress", component: Progress }, { path: "/tabs", component: Tabs }, { path: "/number-input", component: NumberInput }, + { path: "/signature-pad", component: SignaturePad }, ] diff --git a/examples/svelte-ts/src/routes/signature-pad.svelte b/examples/svelte-ts/src/routes/signature-pad.svelte new file mode 100644 index 0000000000..dc141ebfa8 --- /dev/null +++ b/examples/svelte-ts/src/routes/signature-pad.svelte @@ -0,0 +1,70 @@ + + +
+
+ + + +
+ + {#each api.paths as path} + + {/each} + {#if api.currentPath} + + {/if} + + +
+
+ + +
+ + + + {#if url} + signature + {/if} +
+ + diff --git a/examples/vue-ts/package.json b/examples/vue-ts/package.json index c8a316d3b1..7ca6bfd58a 100644 --- a/examples/vue-ts/package.json +++ b/examples/vue-ts/package.json @@ -63,6 +63,7 @@ "@zag-js/remove-scroll": "workspace:*", "@zag-js/select": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/splitter": "workspace:*", "@zag-js/store": "workspace:*", diff --git a/examples/vue-ts/src/pages/signature-pad.tsx b/examples/vue-ts/src/pages/signature-pad.tsx new file mode 100644 index 0000000000..47cfc0af44 --- /dev/null +++ b/examples/vue-ts/src/pages/signature-pad.tsx @@ -0,0 +1,75 @@ +import * as signaturePad from "@zag-js/signature-pad" +import { normalizeProps, useMachine } from "@zag-js/vue" +import { computed, defineComponent, h, Fragment, ref } from "vue" +import { signaturePadControls } from "@zag-js/shared" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" +import { RotateCcw } from "lucide-vue-next" + +export default defineComponent({ + name: "signature-pad", + setup() { + const controls = useControls(signaturePadControls) + const urlRef = ref("") + const setUrl = (v: string) => { + urlRef.value = v + } + + const [state, send] = useMachine( + signaturePad.machine({ + id: "1", + onDrawEnd(details) { + details.getDataUrl("image/png").then(setUrl) + }, + }), + { + context: controls.context, + }, + ) + + const apiRef = computed(() => signaturePad.connect(state.value, send, normalizeProps)) + + return () => { + const api = apiRef.value + + return ( + <> +
+
+ + +
+ + {api.paths.map((path, i) => ( + + ))} + {api.currentPath && } + + +
+
+ + +
+ + + {urlRef.value && signature} +
+ + + + + + ) + } + }, +}) diff --git a/examples/vue-ts/src/routes.ts b/examples/vue-ts/src/routes.ts index 6f770b9625..d1e4ff0dc4 100644 --- a/examples/vue-ts/src/routes.ts +++ b/examples/vue-ts/src/routes.ts @@ -4,6 +4,7 @@ import Home from "./pages/index" export const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ + { path: "/signature-pad", component: () => import("./pages/signature-pad") }, { path: "/floating-panel", component: () => import("./pages/floating-panel") }, { path: "/tour", component: () => import("./pages/tour") }, { path: "/collapsible", component: () => import("./pages/collapsible") }, diff --git a/packages/machines/signature-pad/README.md b/packages/machines/signature-pad/README.md new file mode 100644 index 0000000000..c77aa7a7f8 --- /dev/null +++ b/packages/machines/signature-pad/README.md @@ -0,0 +1,19 @@ +# @zag-js/signature-pad + +Core logic for the signature-pad widget implemented as a state machine + +## Installation + +```sh +yarn add @zag-js/signature-pad +# or +npm i @zag-js/signature-pad +``` + +## Contribution + +Yes please! See the [contributing guidelines](https://github.com/chakra-ui/zag/blob/main/CONTRIBUTING.md) for details. + +## Licence + +This project is licensed under the terms of the [MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE). diff --git a/packages/machines/signature-pad/package.json b/packages/machines/signature-pad/package.json new file mode 100644 index 0000000000..725c760c59 --- /dev/null +++ b/packages/machines/signature-pad/package.json @@ -0,0 +1,50 @@ +{ + "name": "@zag-js/signature-pad", + "version": "0.0.0", + "description": "Core logic for the signature-pad widget implemented as a state machine", + "keywords": [ + "js", + "machine", + "xstate", + "statechart", + "component", + "chakra-ui", + "signature-pad" + ], + "author": "Segun Adebayo ", + "homepage": "https://github.com/chakra-ui/zag#readme", + "license": "MIT", + "main": "src/index.ts", + "repository": "https://github.com/chakra-ui/zag/tree/main/packages/signature-pad", + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://github.com/chakra-ui/zag/issues" + }, + "dependencies": { + "@zag-js/anatomy": "workspace:*", + "@zag-js/core": "workspace:*", + "@zag-js/dom-query": "workspace:*", + "@zag-js/dom-event": "workspace:*", + "@zag-js/utils": "workspace:*", + "@zag-js/types": "workspace:*", + "perfect-freehand": "^1.2.2" + }, + "devDependencies": { + "clean-package": "2.2.0" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/machines/signature-pad/src/get-svg-path.ts b/packages/machines/signature-pad/src/get-svg-path.ts new file mode 100644 index 0000000000..01fe848494 --- /dev/null +++ b/packages/machines/signature-pad/src/get-svg-path.ts @@ -0,0 +1,30 @@ +const average = (a: number, b: number) => (a + b) / 2 + +export function getSvgPathFromStroke(points: number[][], closed = true): string { + const len = points.length + + if (len < 4) { + return "" + } + + let a = points[0] + let b = points[1] + const c = points[2] + + let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( + b[1], + c[1], + ).toFixed(2)} T` + + for (let i = 2, max = len - 1; i < max; i++) { + a = points[i] + b = points[i + 1] + result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} ` + } + + if (closed) { + result += "Z" + } + + return result +} diff --git a/packages/machines/signature-pad/src/index.ts b/packages/machines/signature-pad/src/index.ts new file mode 100644 index 0000000000..c11c48357d --- /dev/null +++ b/packages/machines/signature-pad/src/index.ts @@ -0,0 +1,11 @@ +export { anatomy } from "./signature-pad.anatomy" +export { connect } from "./signature-pad.connect" +export { machine } from "./signature-pad.machine" +export * from "./signature-pad.props" +export type { + UserDefinedContext as Context, + MachineApi as Api, + DrawDetails, + DrawEndDetails, + SegmentPathProps, +} from "./signature-pad.types" diff --git a/packages/machines/signature-pad/src/signature-pad.anatomy.ts b/packages/machines/signature-pad/src/signature-pad.anatomy.ts new file mode 100644 index 0000000000..34b49ce201 --- /dev/null +++ b/packages/machines/signature-pad/src/signature-pad.anatomy.ts @@ -0,0 +1,13 @@ +import { createAnatomy } from "@zag-js/anatomy" + +export const anatomy = createAnatomy("signature-pad").parts( + "root", + "control", + "segment", + "segmentPath", + "separator", + "clearTrigger", + "label", +) + +export const parts = anatomy.build() diff --git a/packages/machines/signature-pad/src/signature-pad.connect.ts b/packages/machines/signature-pad/src/signature-pad.connect.ts new file mode 100644 index 0000000000..36b97a2793 --- /dev/null +++ b/packages/machines/signature-pad/src/signature-pad.connect.ts @@ -0,0 +1,101 @@ +import { getRelativePoint, isLeftClick, isModifiedEvent } from "@zag-js/dom-event" +import { dataAttr } from "@zag-js/dom-query" +import type { NormalizeProps, PropTypes } from "@zag-js/types" +import { parts } from "./signature-pad.anatomy" +import { dom } from "./signature-pad.dom" +import type { MachineApi, Send, State } from "./signature-pad.types" + +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { + const isDrawing = state.matches("drawing") + const isEmpty = state.context.isEmpty + const isInteractive = state.context.isInteractive + const isDisabled = !!state.context.disabled + + return { + isEmpty, + isDrawing, + currentPath: state.context.currentPath, + paths: state.context.paths, + clear() { + send({ type: "CLEAR" }) + }, + + getDataUrl(type, quality) { + return dom.getDataUrl(state.context, { type, quality }) + }, + + labelProps: normalize.element({ + ...parts.label.attrs, + "data-disabled": dataAttr(isDisabled), + htmlFor: dom.getControlId(state.context), + }), + + rootProps: normalize.element({ + ...parts.root.attrs, + "data-disabled": dataAttr(isDisabled), + id: dom.getRootId(state.context), + }), + + controlProps: normalize.element({ + ...parts.control.attrs, + tabIndex: isDisabled ? undefined : 0, + id: dom.getControlId(state.context), + "aria-label": "Signature Pad", + "aria-roledescription": "signature pad", + "aria-disabled": isDisabled, + "data-disabled": dataAttr(isDisabled), + onPointerDown(event) { + if (!isLeftClick(event) || isModifiedEvent(event) || !isInteractive) return + event.currentTarget.setPointerCapture(event.pointerId) + const point = { x: event.clientX, y: event.clientY } + const { offset } = getRelativePoint(point, dom.getControlEl(state.context)!) + send({ type: "POINTER_DOWN", point: offset, pressure: event.pressure }) + }, + onPointerUp(event) { + if (!isInteractive) return + event.currentTarget.releasePointerCapture(event.pointerId) + }, + style: { + position: "relative", + touchAction: "none", + userSelect: "none", + }, + }), + + segmentProps: normalize.svg({ + ...parts.segment.attrs, + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + fill: state.context.drawing.fill, + }, + }), + + getSegmentPathProps(props) { + return normalize.path({ + ...parts.segmentPath.attrs, + d: props.path, + }) + }, + + separatorProps: normalize.element({ + ...parts.separator.attrs, + "data-disabled": dataAttr(isDisabled), + }), + + clearTriggerProps: normalize.button({ + ...parts.clearTrigger.attrs, + type: "button", + "aria-label": "Clear Signature", + hidden: !state.context.paths.length || isDrawing, + disabled: isDisabled, + onClick() { + send({ type: "CLEAR" }) + }, + }), + } +} diff --git a/packages/machines/signature-pad/src/signature-pad.dom.ts b/packages/machines/signature-pad/src/signature-pad.dom.ts new file mode 100644 index 0000000000..d09db1acd5 --- /dev/null +++ b/packages/machines/signature-pad/src/signature-pad.dom.ts @@ -0,0 +1,54 @@ +import { createScope, query } from "@zag-js/dom-query" +import type { MachineContext as Ctx, DataUrlOptions } from "./signature-pad.types" + +export const dom = createScope({ + getRootId: (ctx: Ctx) => `signature-${ctx.id}`, + getControlId: (ctx: Ctx) => `signature-control-${ctx.id}`, + + getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)), + getSegmentEl: (ctx: Ctx) => query(dom.getControlEl(ctx), "[data-part=segment]"), + + getDataUrl: (ctx: Ctx, options: DataUrlOptions): Promise => { + const { type, quality = 0.92 } = options + + if (ctx.isEmpty) { + return Promise.resolve("") + } + + const svg = dom.getSegmentEl(ctx) as SVGElement | null + if (!svg) { + throw new Error("Could not find the svg element.") + } + + const win = dom.getWin(ctx) + const doc = win.document + + const serializer = new win.XMLSerializer() + const source = '\r\n' + serializer.serializeToString(svg) + const svgString = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(source) + + if (type === "image/svg+xml") { + return Promise.resolve(svgString) + } + + const svgBounds = svg.getBoundingClientRect() + const dpr = win.devicePixelRatio || 1 + + const canvas = doc.createElement("canvas") + const image = new win.Image() + image.src = svgString + + canvas.width = svgBounds.width * dpr + canvas.height = svgBounds.height * dpr + + const context = canvas.getContext("2d") + context!.scale(dpr, dpr) + + return new Promise((resolve) => { + image.onload = () => { + context!.drawImage(image, 0, 0) + resolve(canvas.toDataURL(type, quality)) + } + }) + }, +}) diff --git a/packages/machines/signature-pad/src/signature-pad.machine.ts b/packages/machines/signature-pad/src/signature-pad.machine.ts new file mode 100644 index 0000000000..cbd715e737 --- /dev/null +++ b/packages/machines/signature-pad/src/signature-pad.machine.ts @@ -0,0 +1,115 @@ +import { createMachine } from "@zag-js/core" +import { getRelativePoint, trackPointerMove } from "@zag-js/dom-event" +import { compact } from "@zag-js/utils" +import getStroke from "perfect-freehand" +import { getSvgPathFromStroke } from "./get-svg-path" +import { dom } from "./signature-pad.dom" +import type { MachineContext, MachineState, UserDefinedContext } from "./signature-pad.types" + +export function machine(userContext: UserDefinedContext) { + const ctx = compact(userContext) + return createMachine( + { + id: "signature-pad", + initial: "idle", + context: { + ...ctx, + paths: [], + currentPoints: [], + currentPath: null, + drawing: { + size: 2, + simulatePressure: false, + thinning: 0.7, + smoothing: 0.4, + streamline: 0.6, + ...ctx.drawing, + }, + }, + + computed: { + isInteractive: (ctx) => !(ctx.disabled || ctx.readOnly), + isEmpty: (ctx) => ctx.paths.length === 0, + }, + + on: { + CLEAR: { + actions: ["clearPoints", "invokeOnDrawEnd", "focusCanvasEl"], + }, + }, + + states: { + idle: { + on: { + POINTER_DOWN: { + target: "drawing", + actions: ["addPoint"], + }, + }, + }, + drawing: { + activities: ["trackPointerMove"], + on: { + POINTER_MOVE: { + actions: ["addPoint", "invokeOnDraw"], + }, + POINTER_UP: { + target: "idle", + actions: ["endStroke", "invokeOnDrawEnd"], + }, + }, + }, + }, + }, + { + activities: { + trackPointerMove(ctx, _evt, { send }) { + const doc = dom.getDoc(ctx) + return trackPointerMove(doc, { + onPointerMove({ event, point }) { + const { offset } = getRelativePoint(point, dom.getControlEl(ctx)!) + send({ type: "POINTER_MOVE", point: offset, pressure: event.pressure }) + }, + onPointerUp() { + send({ type: "POINTER_UP" }) + }, + }) + }, + }, + actions: { + addPoint(ctx, evt) { + ctx.currentPoints.push(evt.point) + const stroke = getStroke(ctx.currentPoints, ctx.drawing) + ctx.currentPath = getSvgPathFromStroke(stroke) + }, + endStroke(ctx) { + ctx.paths.push(ctx.currentPath!) + ctx.currentPoints = [] + ctx.currentPath = null + }, + clearPoints(ctx) { + ctx.currentPoints = [] + ctx.paths = [] + }, + focusCanvasEl(ctx) { + queueMicrotask(() => { + dom.getControlEl(ctx)?.focus({ preventScroll: true }) + }) + }, + invokeOnDraw(ctx) { + ctx.onDraw?.({ + paths: [...ctx.paths, ctx.currentPath!], + }) + }, + invokeOnDrawEnd(ctx) { + ctx.onDrawEnd?.({ + paths: [...ctx.paths], + getDataUrl(type, quality = 0.92) { + return dom.getDataUrl(ctx, { type, quality }) + }, + }) + }, + }, + }, + ) +} diff --git a/packages/machines/signature-pad/src/signature-pad.props.ts b/packages/machines/signature-pad/src/signature-pad.props.ts new file mode 100644 index 0000000000..6590aaa373 --- /dev/null +++ b/packages/machines/signature-pad/src/signature-pad.props.ts @@ -0,0 +1,16 @@ +import { createProps } from "@zag-js/types" +import { createSplitProps } from "@zag-js/utils" +import type { UserDefinedContext } from "./signature-pad.types" + +export const props = createProps()([ + "dir", + "disabled", + "getRootNode", + "id", + "onDraw", + "onDrawEnd", + "readOnly", + "drawing", +]) + +export const splitProps = createSplitProps>(props) diff --git a/packages/machines/signature-pad/src/signature-pad.types.ts b/packages/machines/signature-pad/src/signature-pad.types.ts new file mode 100644 index 0000000000..d835316222 --- /dev/null +++ b/packages/machines/signature-pad/src/signature-pad.types.ts @@ -0,0 +1,131 @@ +import type { StateMachine as S } from "@zag-js/core" +import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" +import type { StrokeOptions } from "perfect-freehand" + +interface Point { + x: number + y: number + pressure: number +} + +export interface DrawDetails { + paths: string[] +} + +export interface DrawingOptions extends StrokeOptions { + /** + * The color of the stroke. + * Note: Must be a valid CSS color string, not a css variable. + */ + fill?: string +} + +export type DataUrlType = "image/png" | "image/jpeg" | "image/svg+xml" + +export interface DrawEndDetails { + paths: string[] + getDataUrl(type: DataUrlType, quality?: number): Promise +} + +export interface DataUrlOptions { + type: DataUrlType + quality?: number +} + +interface PublicContext extends DirectionProperty, CommonProperties { + /** + * Callback when the signature pad is drawing. + */ + onDraw?(details: DrawDetails): void + /** + * Callback when the signature pad is done drawing. + */ + onDrawEnd?(details: DrawEndDetails): void + /** + * The drawing options. + */ + drawing: DrawingOptions + /** + * Whether the signature pad is disabled. + */ + disabled?: boolean + /** + * Whether the signature pad is read-only. + */ + readOnly?: boolean +} + +interface PrivateContext { + /** + * The layers of the signature pad. A layer is a snapshot of a single stroke interaction. + */ + paths: string[] + /** + * The current layer points. + */ + currentPoints: Point[] + /** + * The current stroke path + */ + currentPath: string | null +} + +type ComputedContext = Readonly<{ + isInteractive: boolean + isEmpty: boolean +}> + +export type UserDefinedContext = RequiredBy + +export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {} + +export interface MachineState { + value: "idle" | "drawing" +} + +export type State = S.State + +export type Send = S.Send + +/* ----------------------------------------------------------------------------- + * Component API + * -----------------------------------------------------------------------------*/ + +export interface SegmentPathProps { + path: string +} + +export interface MachineApi { + /** + * Whether the signature pad is empty. + */ + isEmpty: boolean + /** + * Whether the user is currently drawing. + */ + isDrawing: boolean + /** + * The current path being drawn. + */ + currentPath: string | null + /** + * The paths of the signature pad. + */ + paths: string[] + /** + * Returns the data URL of the signature pad. + */ + getDataUrl(type: DataUrlType, quality?: number): Promise + /** + * Clears the signature pad. + */ + clear(): void + + labelProps: T["element"] + rootProps: T["element"] + controlProps: T["element"] + segmentProps: T["svg"] + getSegmentPathProps(props: SegmentPathProps): T["path"] + separatorProps: T["element"] + clearTriggerProps: T["element"] +} diff --git a/packages/machines/signature-pad/tsconfig.json b/packages/machines/signature-pad/tsconfig.json new file mode 100644 index 0000000000..8e781cd154 --- /dev/null +++ b/packages/machines/signature-pad/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo" + } +} diff --git a/packages/types/src/jsx.ts b/packages/types/src/jsx.ts index 60111c626b..e4f6b2941b 100644 --- a/packages/types/src/jsx.ts +++ b/packages/types/src/jsx.ts @@ -1235,6 +1235,291 @@ export namespace JSX { disableRemotePlayback?: boolean | undefined } + export interface SVGAttributes extends AriaAttributes, DOMAttributes { + // React-specific Attributes + suppressHydrationWarning?: boolean | undefined + + // Attributes which also defined in HTMLAttributes + // See comment in SVGDOMPropertyConfig.js + className?: string | undefined + color?: string | undefined + height?: number | string | undefined + id?: string | undefined + lang?: string | undefined + max?: number | string | undefined + media?: string | undefined + method?: string | undefined + min?: number | string | undefined + name?: string | undefined + style?: CSSProperties | undefined + target?: string | undefined + type?: string | undefined + width?: number | string | undefined + + // Other HTML properties supported by SVG elements in browsers + role?: AriaRole | undefined + tabIndex?: number | undefined + crossOrigin?: any + + // SVG Specific attributes + accentHeight?: number | string | undefined + accumulate?: "none" | "sum" | undefined + additive?: "replace" | "sum" | undefined + alignmentBaseline?: + | "auto" + | "baseline" + | "before-edge" + | "text-before-edge" + | "middle" + | "central" + | "after-edge" + | "text-after-edge" + | "ideographic" + | "alphabetic" + | "hanging" + | "mathematical" + | "inherit" + | undefined + allowReorder?: "no" | "yes" | undefined + alphabetic?: number | string | undefined + amplitude?: number | string | undefined + arabicForm?: "initial" | "medial" | "terminal" | "isolated" | undefined + ascent?: number | string | undefined + attributeName?: string | undefined + attributeType?: string | undefined + autoReverse?: Booleanish | undefined + azimuth?: number | string | undefined + baseFrequency?: number | string | undefined + baselineShift?: number | string | undefined + baseProfile?: number | string | undefined + bbox?: number | string | undefined + begin?: number | string | undefined + bias?: number | string | undefined + by?: number | string | undefined + calcMode?: number | string | undefined + capHeight?: number | string | undefined + clip?: number | string | undefined + clipPath?: string | undefined + clipPathUnits?: number | string | undefined + clipRule?: number | string | undefined + colorInterpolation?: number | string | undefined + colorInterpolationFilters?: "auto" | "sRGB" | "linearRGB" | "inherit" | undefined + colorProfile?: number | string | undefined + colorRendering?: number | string | undefined + contentScriptType?: number | string | undefined + contentStyleType?: number | string | undefined + cursor?: number | string | undefined + cx?: number | string | undefined + cy?: number | string | undefined + d?: string | undefined + decelerate?: number | string | undefined + descent?: number | string | undefined + diffuseConstant?: number | string | undefined + direction?: number | string | undefined + display?: number | string | undefined + divisor?: number | string | undefined + dominantBaseline?: number | string | undefined + dur?: number | string | undefined + dx?: number | string | undefined + dy?: number | string | undefined + edgeMode?: number | string | undefined + elevation?: number | string | undefined + enableBackground?: number | string | undefined + end?: number | string | undefined + exponent?: number | string | undefined + externalResourcesRequired?: Booleanish | undefined + fill?: string | undefined + fillOpacity?: number | string | undefined + fillRule?: "nonzero" | "evenodd" | "inherit" | undefined + filter?: string | undefined + filterRes?: number | string | undefined + filterUnits?: number | string | undefined + floodColor?: number | string | undefined + floodOpacity?: number | string | undefined + focusable?: Booleanish | "auto" | undefined + fontFamily?: string | undefined + fontSize?: number | string | undefined + fontSizeAdjust?: number | string | undefined + fontStretch?: number | string | undefined + fontStyle?: number | string | undefined + fontVariant?: number | string | undefined + fontWeight?: number | string | undefined + format?: number | string | undefined + fr?: number | string | undefined + from?: number | string | undefined + fx?: number | string | undefined + fy?: number | string | undefined + g1?: number | string | undefined + g2?: number | string | undefined + glyphName?: number | string | undefined + glyphOrientationHorizontal?: number | string | undefined + glyphOrientationVertical?: number | string | undefined + glyphRef?: number | string | undefined + gradientTransform?: string | undefined + gradientUnits?: string | undefined + hanging?: number | string | undefined + horizAdvX?: number | string | undefined + horizOriginX?: number | string | undefined + href?: string | undefined + ideographic?: number | string | undefined + imageRendering?: number | string | undefined + in2?: number | string | undefined + in?: string | undefined + intercept?: number | string | undefined + k1?: number | string | undefined + k2?: number | string | undefined + k3?: number | string | undefined + k4?: number | string | undefined + k?: number | string | undefined + kernelMatrix?: number | string | undefined + kernelUnitLength?: number | string | undefined + kerning?: number | string | undefined + keyPoints?: number | string | undefined + keySplines?: number | string | undefined + keyTimes?: number | string | undefined + lengthAdjust?: number | string | undefined + letterSpacing?: number | string | undefined + lightingColor?: number | string | undefined + limitingConeAngle?: number | string | undefined + local?: number | string | undefined + markerEnd?: string | undefined + markerHeight?: number | string | undefined + markerMid?: string | undefined + markerStart?: string | undefined + markerUnits?: number | string | undefined + markerWidth?: number | string | undefined + mask?: string | undefined + maskContentUnits?: number | string | undefined + maskUnits?: number | string | undefined + mathematical?: number | string | undefined + mode?: number | string | undefined + numOctaves?: number | string | undefined + offset?: number | string | undefined + opacity?: number | string | undefined + operator?: number | string | undefined + order?: number | string | undefined + orient?: number | string | undefined + orientation?: number | string | undefined + origin?: number | string | undefined + overflow?: number | string | undefined + overlinePosition?: number | string | undefined + overlineThickness?: number | string | undefined + paintOrder?: number | string | undefined + panose1?: number | string | undefined + path?: string | undefined + pathLength?: number | string | undefined + patternContentUnits?: string | undefined + patternTransform?: number | string | undefined + patternUnits?: string | undefined + pointerEvents?: number | string | undefined + points?: string | undefined + pointsAtX?: number | string | undefined + pointsAtY?: number | string | undefined + pointsAtZ?: number | string | undefined + preserveAlpha?: Booleanish | undefined + preserveAspectRatio?: string | undefined + primitiveUnits?: number | string | undefined + r?: number | string | undefined + radius?: number | string | undefined + refX?: number | string | undefined + refY?: number | string | undefined + renderingIntent?: number | string | undefined + repeatCount?: number | string | undefined + repeatDur?: number | string | undefined + requiredExtensions?: number | string | undefined + requiredFeatures?: number | string | undefined + restart?: number | string | undefined + result?: string | undefined + rotate?: number | string | undefined + rx?: number | string | undefined + ry?: number | string | undefined + scale?: number | string | undefined + seed?: number | string | undefined + shapeRendering?: number | string | undefined + slope?: number | string | undefined + spacing?: number | string | undefined + specularConstant?: number | string | undefined + specularExponent?: number | string | undefined + speed?: number | string | undefined + spreadMethod?: string | undefined + startOffset?: number | string | undefined + stdDeviation?: number | string | undefined + stemh?: number | string | undefined + stemv?: number | string | undefined + stitchTiles?: number | string | undefined + stopColor?: string | undefined + stopOpacity?: number | string | undefined + strikethroughPosition?: number | string | undefined + strikethroughThickness?: number | string | undefined + string?: number | string | undefined + stroke?: string | undefined + strokeDasharray?: string | number | undefined + strokeDashoffset?: string | number | undefined + strokeLinecap?: "butt" | "round" | "square" | "inherit" | undefined + strokeLinejoin?: "miter" | "round" | "bevel" | "inherit" | undefined + strokeMiterlimit?: number | string | undefined + strokeOpacity?: number | string | undefined + strokeWidth?: number | string | undefined + surfaceScale?: number | string | undefined + systemLanguage?: number | string | undefined + tableValues?: number | string | undefined + targetX?: number | string | undefined + targetY?: number | string | undefined + textAnchor?: string | undefined + textDecoration?: number | string | undefined + textLength?: number | string | undefined + textRendering?: number | string | undefined + to?: number | string | undefined + transform?: string | undefined + u1?: number | string | undefined + u2?: number | string | undefined + underlinePosition?: number | string | undefined + underlineThickness?: number | string | undefined + unicode?: number | string | undefined + unicodeBidi?: number | string | undefined + unicodeRange?: number | string | undefined + unitsPerEm?: number | string | undefined + vAlphabetic?: number | string | undefined + values?: string | undefined + vectorEffect?: number | string | undefined + version?: string | undefined + vertAdvY?: number | string | undefined + vertOriginX?: number | string | undefined + vertOriginY?: number | string | undefined + vHanging?: number | string | undefined + vIdeographic?: number | string | undefined + viewBox?: string | undefined + viewTarget?: number | string | undefined + visibility?: number | string | undefined + vMathematical?: number | string | undefined + widths?: number | string | undefined + wordSpacing?: number | string | undefined + writingMode?: number | string | undefined + x1?: number | string | undefined + x2?: number | string | undefined + x?: number | string | undefined + xChannelSelector?: string | undefined + xHeight?: number | string | undefined + xlinkActuate?: string | undefined + xlinkArcrole?: string | undefined + xlinkHref?: string | undefined + xlinkRole?: string | undefined + xlinkShow?: string | undefined + xlinkTitle?: string | undefined + xlinkType?: string | undefined + xmlBase?: string | undefined + xmlLang?: string | undefined + xmlns?: string | undefined + xmlnsXlink?: string | undefined + xmlSpace?: string | undefined + y1?: number | string | undefined + y2?: number | string | undefined + y?: number | string | undefined + yChannelSelector?: string | undefined + z?: number | string | undefined + zoomAndPan?: string | undefined + } + export interface IntrinsicElements { // HTML a: AnchorHTMLAttributes @@ -1352,5 +1637,9 @@ export namespace JSX { ul: HTMLAttributes var: HTMLAttributes video: VideoHTMLAttributes + + svg: SVGAttributes + circle: SVGAttributes + path: SVGAttributes } } diff --git a/packages/types/src/prop-types.ts b/packages/types/src/prop-types.ts index 9fe0dbc029..8d56c4558c 100644 --- a/packages/types/src/prop-types.ts +++ b/packages/types/src/prop-types.ts @@ -45,7 +45,18 @@ type DataAttr = { } export type PropTypes = Record< - "button" | "label" | "input" | "textarea" | "img" | "output" | "element" | "select" | "style" | "circle" | "svg", + | "button" + | "label" + | "input" + | "textarea" + | "img" + | "output" + | "element" + | "select" + | "style" + | "circle" + | "svg" + | "path", T > diff --git a/packages/utilities/dom-query/src/query.ts b/packages/utilities/dom-query/src/query.ts index f5bfe368e9..ac223f41b7 100644 --- a/packages/utilities/dom-query/src/query.ts +++ b/packages/utilities/dom-query/src/query.ts @@ -1,9 +1,9 @@ type Root = Document | Element | null | undefined -export function queryAll(root: Root, selector: string) { +export function queryAll(root: Root, selector: string) { return Array.from(root?.querySelectorAll(selector) ?? []) } -export function query(root: Root, selector: string) { +export function query(root: Root, selector: string) { return root?.querySelector(selector) ?? null } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4c1d7a86b..556d13584f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: '@zag-js/shared': specifier: workspace:* version: link:../../shared + '@zag-js/signature-pad': + specifier: workspace:* + version: link:../../packages/machines/signature-pad '@zag-js/slider': specifier: workspace:* version: link:../../packages/machines/slider @@ -553,6 +556,9 @@ importers: '@zag-js/shared': specifier: workspace:* version: link:../../shared + '@zag-js/signature-pad': + specifier: workspace:* + version: link:../../packages/machines/signature-pad '@zag-js/slider': specifier: workspace:* version: link:../../packages/machines/slider @@ -797,6 +803,9 @@ importers: '@zag-js/shared': specifier: workspace:* version: link:../../shared + '@zag-js/signature-pad': + specifier: workspace:* + version: link:../../packages/machines/signature-pad '@zag-js/slider': specifier: workspace:* version: link:../../packages/machines/slider @@ -1038,6 +1047,9 @@ importers: '@zag-js/shared': specifier: workspace:* version: link:../../shared + '@zag-js/signature-pad': + specifier: workspace:* + version: link:../../packages/machines/signature-pad '@zag-js/slider': specifier: workspace:* version: link:../../packages/machines/slider @@ -1270,6 +1282,9 @@ importers: '@zag-js/shared': specifier: workspace:* version: link:../../shared + '@zag-js/signature-pad': + specifier: workspace:* + version: link:../../packages/machines/signature-pad '@zag-js/slider': specifier: workspace:* version: link:../../packages/machines/slider @@ -1520,6 +1535,9 @@ importers: '@zag-js/shared': specifier: workspace:* version: link:../../shared + '@zag-js/signature-pad': + specifier: workspace:* + version: link:../../packages/machines/signature-pad '@zag-js/slider': specifier: workspace:* version: link:../../packages/machines/slider @@ -2552,6 +2570,34 @@ importers: specifier: 2.2.0 version: 2.2.0 + packages/machines/signature-pad: + dependencies: + '@zag-js/anatomy': + specifier: workspace:* + version: link:../../anatomy + '@zag-js/core': + specifier: workspace:* + version: link:../../core + '@zag-js/dom-event': + specifier: workspace:* + version: link:../../utilities/dom-event + '@zag-js/dom-query': + specifier: workspace:* + version: link:../../utilities/dom-query + '@zag-js/types': + specifier: workspace:* + version: link:../../types + '@zag-js/utils': + specifier: workspace:* + version: link:../../utilities/core + perfect-freehand: + specifier: ^1.2.2 + version: 1.2.2 + devDependencies: + clean-package: + specifier: 2.2.0 + version: 2.2.0 + packages/machines/slider: dependencies: '@zag-js/anatomy': @@ -15924,6 +15970,10 @@ packages: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} dev: true + /perfect-freehand@1.2.2: + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} + dev: false + /periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} dependencies: diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 5ff8d5f9a4..548663437b 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -254,3 +254,7 @@ export const floatingPanelControls = defineControls({ lockAspectRatio: { type: "boolean", defaultValue: false }, closeOnEscape: { type: "boolean", defaultValue: true }, }) + +export const signaturePadControls = defineControls({ + disabled: { type: "boolean", defaultValue: false }, +}) diff --git a/shared/src/css/signature-pad.css b/shared/src/css/signature-pad.css new file mode 100644 index 0000000000..766fc7cafa --- /dev/null +++ b/shared/src/css/signature-pad.css @@ -0,0 +1,49 @@ +[data-scope="signature-pad"][data-part="root"] { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-width: 400px; +} + +[data-scope="signature-pad"][data-part="label"] { + display: inline-block; + margin-bottom: 8px; + font-weight: 500; +} + +[data-scope="signature-pad"][data-part="control"] { + width: 100%; + height: 160px; + background-color: rgb(239, 239, 239); + border-radius: 8px; +} + +[data-scope="signature-pad"][data-part="separator"] { + position: absolute; + bottom: 12%; + left: 8px; + right: 8px; + border-bottom: 2px dashed rgb(129, 133, 150); +} + +[data-scope="signature-pad"][data-part="clear-trigger"] { + position: absolute; + top: 32px; + right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + + & svg { + width: 1em; + height: 1em; + } +} + +.signature-pad [data-part="preview"] { + height: 160px; + object-fit: cover; + max-width: 100%; +} diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 3c8e722afa..a2718ce0c9 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -4,6 +4,7 @@ type RouteData = { } export const routesData: RouteData[] = [ + { label: "Signature Pad", path: "/signature-pad" }, { label: "Floating Panel", path: "/floating-panel" }, { label: "Tour", path: "/tour" }, { label: "Collapsible", path: "/collapsible" }, diff --git a/shared/src/style.css b/shared/src/style.css index 12c21dcc91..f84bb37ef5 100644 --- a/shared/src/style.css +++ b/shared/src/style.css @@ -36,6 +36,7 @@ @import url("./css/segmented-control.css"); @import url("./css/select.css"); +@import url("./css/signature-pad.css"); @import url("./css/slider.css"); @import url("./css/splitter.css"); @import url("./css/switch.css"); diff --git a/website/data/components/signature-pad.mdx b/website/data/components/signature-pad.mdx new file mode 100644 index 0000000000..ab0d9c0584 --- /dev/null +++ b/website/data/components/signature-pad.mdx @@ -0,0 +1,131 @@ +--- +title: Signature Pad +description: Using the signature pad component in your application +package: "@zag-js/signature-pad" +--- + +# Signature Pad + +The signature pad component allows users to draw handwritten signatures using +touch or pointer devices. The signature can be saved as an image or cleared. + +## Listening to drawing events + +The signature pad component emits the following events: + +- `onDraw`: Emitted when the user is drawing the signature. +- `onDrawEnd`: Emitted when the user stops drawing the signature. + +```jsx +const [state, send] = useMachine( + signature.machine({ + onDraw(details) { + // details => { path: string[] } + console.log("Drawing signature", details) + }, + onDrawEnd(details) { + // details => { path: string[], toDataURL: () => string } + console.log("Signature drawn", details) + }, + }), +) +``` + +## Clearing the signature + +To clear the signature, use the `api.clear()`, or render the clear trigger +button. + +```jsx + +``` + +## Rendering an image preview + +Use the `api.getDataUrl()` method to get the signature as a data URL and render +it as an image. + +> You can also leverage the `onDrawEnd` event to get the signature data URL. + +## Changing the stroke color + +To change the stroke color, set the `drawing.fill` option to a valid CSS color. + +> Note: You can't use a css variable as the stroke color. + +```jsx +const [state, send] = useMachine( + signature.machine({ + drawing: { + fill: "red", + }, + }), +) +``` + +## Changing the stroke width + +To change the stroke width, set the `drawing.size` option to a number. + +```jsx +const [state, send] = useMachine( + signature.machine({ + drawing: { + size: 5, + }, + }), +) +``` + +## Simulating pressure sensitivity + +Pressure sensitivity is disabled by default. To enable it, set the +`drawing.simulatePressure` option to `true`. + +```jsx +const [state, send] = useMachine( + signature.machine({ + drawing: { + simulatePressure: true, + }, + }), +) +``` + +## Usage in forms + +To use the signature pad in a form, set the `name` context property and render +the hidden input element using `api.hiddenInputProps`. + +```jsx +const [state, send] = useMachine( + signature.machine({ + name: "signature", + }), +) +``` + +## Disabling the signature pad + +Set the `disabled` context property to `true` to disable the signature pad. + +```jsx +const [state, send] = useMachine( + signature.machine({ + disabled: true, + }), +) +``` + +## Making the signature pad read-only + +Set the `readOnly` context property to `true` to make the signature pad +read-only. + +```jsx +const [state, send] = useMachine( + signature.machine({ + readOnly: true, + }), +) +```