@purpurds/toggle 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/toggle.tsx ADDED
@@ -0,0 +1,155 @@
1
+ import React, { ForwardedRef, forwardRef, useState } from "react";
2
+ import { checkmarkBold, Icon } from "@purpurds/icon";
3
+ import { Label } from "@purpurds/label";
4
+ import { Paragraph } from "@purpurds/paragraph";
5
+ import * as Switch from "@radix-ui/react-switch";
6
+ import c from "classnames";
7
+
8
+ import styles from "./toggle.module.scss";
9
+ import { DraggableX } from "./DraggableX";
10
+ import { useToggleDrag } from "./useToggleDrag";
11
+
12
+ export type ToggleProps = {
13
+ /**
14
+ * To use when no label is given.
15
+ * */
16
+ ["aria-label"]?: string;
17
+ /**
18
+ * To use with custom label (not recommended).
19
+ * */
20
+ ["aria-labelledby"]?: string;
21
+ ["data-testid"]?: string;
22
+ /**
23
+ * The controlled state of the toggle. Must be used in conjunction with `onChange`.
24
+ * */
25
+ checked?: boolean;
26
+ className?: string;
27
+ /**
28
+ * The state of the toggle when it is initially rendered. Use when you do not need to control its state.
29
+ * */
30
+ defaultChecked?: boolean;
31
+ /**
32
+ * When `true`, prevents the user from interacting with the toggle.
33
+ * */
34
+ disabled?: boolean;
35
+ /**
36
+ * When `true`, the toggle isn't draggable.
37
+ * */
38
+ disableDrag?: boolean;
39
+ /**
40
+ * ID of the toggle.
41
+ * */
42
+ id: string;
43
+ /**
44
+ * The label of the toggle.
45
+ * */
46
+ label?: string;
47
+ /**
48
+ * Set to decide which side of the toggle the label should be rendered.
49
+ * */
50
+ labelPosition?: "left" | "right";
51
+ /**
52
+ * The name of the toggle. Submitted with its owning form as part of a name/value pair, when wrapped in a form.
53
+ * */
54
+ name?: string;
55
+ /**
56
+ * Event handler called when the toggle is toggled.
57
+ * */
58
+ onChange?: (checked: boolean) => void;
59
+ /**
60
+ * When `true`, indicates that the user must check the toggle before the owning form can be submitted..
61
+ * */
62
+ required?: boolean;
63
+ /**
64
+ * The value given as data when wrapped with a form and submitted with a name.
65
+ * */
66
+ value?: string;
67
+ };
68
+
69
+ const rootClassName = "purpur-toggle";
70
+
71
+ const ToggleComponent = (
72
+ {
73
+ ["data-testid"]: dataTestId,
74
+ className,
75
+ label,
76
+ onChange,
77
+ labelPosition = "right",
78
+ checked,
79
+ disableDrag,
80
+ defaultChecked,
81
+ ...props
82
+ }: ToggleProps,
83
+ ref: ForwardedRef<HTMLButtonElement>
84
+ ) => {
85
+ const [internalChecked, setInternalChecked] = useState(
86
+ typeof checked === "boolean" ? checked : !!defaultChecked
87
+ );
88
+ const isChecked = Boolean(typeof checked === "boolean" ? checked : internalChecked);
89
+ const { thumbRef, trackRef, isDragging, onChangeWithDrag, ...draggableXProps } = useToggleDrag({
90
+ checked: isChecked,
91
+ onChange: (value) => {
92
+ if (!props.disabled) {
93
+ onChange?.(value);
94
+ setInternalChecked(value);
95
+ }
96
+ },
97
+ });
98
+
99
+ const renderLabel = () => (
100
+ <Label
101
+ htmlFor={props.id}
102
+ data-testid={dataTestId && `${dataTestId}-label`}
103
+ disabled={props.disabled}
104
+ className={c(
105
+ styles[`${rootClassName}__label`],
106
+ styles[`${rootClassName}__label--${labelPosition}`]
107
+ )}
108
+ >
109
+ <Paragraph variant="paragraph-100" disabled={props.disabled}>
110
+ {label}
111
+ </Paragraph>
112
+ </Label>
113
+ );
114
+
115
+ return (
116
+ <div className={c([className, styles[`${rootClassName}__container`]])}>
117
+ {label && labelPosition === "left" && renderLabel()}
118
+ <Switch.Root
119
+ {...props}
120
+ ref={ref}
121
+ id={props.id}
122
+ data-testid={dataTestId}
123
+ className={styles[rootClassName]}
124
+ onCheckedChange={onChangeWithDrag}
125
+ checked={isChecked}
126
+ >
127
+ <span ref={trackRef} className={styles[`${rootClassName}__track`]}>
128
+ <span className={styles[`${rootClassName}__checkmark-container`]}>
129
+ <Icon
130
+ className={styles[`${rootClassName}__checkmark`]}
131
+ svg={checkmarkBold}
132
+ size="xxs"
133
+ />
134
+ </span>
135
+ <DraggableX disabled={disableDrag} {...draggableXProps}>
136
+ <Switch.Thumb
137
+ ref={thumbRef}
138
+ data-testid={dataTestId && `${dataTestId}-thumb`}
139
+ className={c([
140
+ styles[`${rootClassName}__thumb`],
141
+ {
142
+ [styles[`${rootClassName}__thumb--dragging`]]: isDragging,
143
+ },
144
+ ])}
145
+ />
146
+ </DraggableX>
147
+ </span>
148
+ </Switch.Root>
149
+ {label && labelPosition === "right" && renderLabel()}
150
+ </div>
151
+ );
152
+ };
153
+
154
+ export const Toggle = forwardRef(ToggleComponent);
155
+ Toggle.displayName = "Toggle";
@@ -0,0 +1,58 @@
1
+ import { MutableRefObject, useRef, useState, useLayoutEffect } from "react";
2
+ import { ToggleProps } from "./toggle";
3
+
4
+ export const useToggleDrag = ({ checked, onChange }: Pick<ToggleProps, "checked" | "onChange">) => {
5
+ const trackRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
6
+ const thumbRef = useRef<HTMLSpanElement>(null);
7
+ const [dragX, setDragX] = useState<number | undefined>(undefined);
8
+ const [isDragging, setIsDragging] = useState(false);
9
+ const [dragStopped, setDragStopped] = useState(0);
10
+ const [thumbHeight, setThumbHeight] = useState(0);
11
+ const [switchHeight, setSwitchHeight] = useState(0);
12
+
13
+ useLayoutEffect(() => {
14
+ setThumbHeight(thumbRef.current?.clientHeight || 0);
15
+ setSwitchHeight(trackRef.current?.clientHeight || 0);
16
+ }, [thumbRef.current?.clientHeight, trackRef.current?.clientHeight]);
17
+
18
+ const switchThumbHeightDelta = (switchHeight || 0) - thumbHeight;
19
+ const maxDragRange = thumbHeight + switchThumbHeightDelta;
20
+ const minDragRange = switchThumbHeightDelta / 2;
21
+ const halfMaxDragRange = maxDragRange / 2;
22
+ const isDraggedHalfway =
23
+ typeof dragX === "number" && (checked ? dragX < halfMaxDragRange : dragX > halfMaxDragRange);
24
+
25
+ const onDrag = ({ x }: { x: number }) => {
26
+ setDragX(x);
27
+ const dragDelta = Math.abs((checked ? maxDragRange : minDragRange) - x);
28
+
29
+ // Add a threshold here for then accidentally dragging when clicking/touching
30
+ dragDelta > 2 && setIsDragging(true);
31
+ };
32
+
33
+ const onStop = () => {
34
+ setIsDragging(false);
35
+ isDragging && setDragStopped(Date.now());
36
+ if (isDraggedHalfway) {
37
+ onChange?.(!checked);
38
+ }
39
+ };
40
+
41
+ const onChangeWithDrag = () => {
42
+ if (Date.now() - dragStopped > 50) {
43
+ onChange?.(!checked);
44
+ }
45
+ setDragX(undefined);
46
+ };
47
+
48
+ return {
49
+ trackRef,
50
+ thumbRef,
51
+ isDragging,
52
+ bounds: { left: minDragRange, right: maxDragRange },
53
+ position: checked ? maxDragRange : minDragRange,
54
+ onDrag,
55
+ onStop,
56
+ onChangeWithDrag,
57
+ };
58
+ };