diff --git a/documentation-site/components/yard/config/sliding-button.ts b/documentation-site/components/yard/config/sliding-button.ts new file mode 100644 index 0000000000..4e9d0c20f2 --- /dev/null +++ b/documentation-site/components/yard/config/sliding-button.ts @@ -0,0 +1,70 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import { SlidingButton, THRESHOLD } from "baseui/sliding-button"; +import { PropTypes } from "react-view"; +import type { TConfig } from "../types"; + +const SlidingButtonConfig: TConfig = { + componentName: "SlidingButton", + imports: { + "baseui/sliding-button": { + named: ["SlidingButton"], + }, + }, + scope: { + SlidingButton, + THRESHOLD, + }, + theme: [], + props: { + label: { + value: "Slide to confirm", + type: PropTypes.String, + description: "Text displayed in the button track.", + }, + onComplete: { + value: '() => console.log("completed!")', + type: PropTypes.Function, + description: "Called once when the user drags past the threshold.", + }, + threshold: { + value: "THRESHOLD.high", + defaultValue: "THRESHOLD.high", + options: THRESHOLD, + type: PropTypes.Enum, + description: + "Completion threshold — 'high' requires 80%, 'low' requires 20%.", + imports: { + "baseui/sliding-button": { + named: ["THRESHOLD"], + }, + }, + }, + isLoading: { + value: false, + type: PropTypes.Boolean, + description: "Shows loading spinner, disables interaction.", + }, + isDisabled: { + value: false, + type: PropTypes.Boolean, + description: "Grays out the component, disables interaction.", + }, + slideBackAfterMs: { + value: undefined, + type: PropTypes.Number, + description: "Auto-reset to idle state after N milliseconds.", + }, + overrides: { + value: undefined, + type: PropTypes.Custom, + description: "Override internal elements.", + }, + }, +}; + +export default SlidingButtonConfig; diff --git a/documentation-site/examples/sliding-button/basic.tsx b/documentation-site/examples/sliding-button/basic.tsx new file mode 100644 index 0000000000..69b9d81657 --- /dev/null +++ b/documentation-site/examples/sliding-button/basic.tsx @@ -0,0 +1,20 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { SlidingButton } from "baseui/sliding-button"; + +export default function Example() { + return ( + { + // eslint-disable-next-line no-console + console.log("Completed!"); + }} + /> + ); +} diff --git a/documentation-site/examples/sliding-button/low-threshold.tsx b/documentation-site/examples/sliding-button/low-threshold.tsx new file mode 100644 index 0000000000..16ce695b08 --- /dev/null +++ b/documentation-site/examples/sliding-button/low-threshold.tsx @@ -0,0 +1,22 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { SlidingButton, THRESHOLD } from "baseui/sliding-button"; + +export default function Example() { + return ( + { + // eslint-disable-next-line no-console + console.log("Completed!"); + }} + /> + ); +} diff --git a/documentation-site/examples/sliding-button/states.tsx b/documentation-site/examples/sliding-button/states.tsx new file mode 100644 index 0000000000..8574d145ea --- /dev/null +++ b/documentation-site/examples/sliding-button/states.tsx @@ -0,0 +1,26 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { SlidingButton } from "baseui/sliding-button"; + +export default function Example() { + const [isLoading, setIsLoading] = React.useState(false); + return ( +
+ {}} /> + { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 2000); + }} + /> + +
+ ); +} diff --git a/documentation-site/pages/components/sliding-button.mdx b/documentation-site/pages/components/sliding-button.mdx new file mode 100644 index 0000000000..92fba352bb --- /dev/null +++ b/documentation-site/pages/components/sliding-button.mdx @@ -0,0 +1,79 @@ +import Example from "../../components/example"; +import Exports from "../../components/exports"; +import Layout from "../../components/layout"; +import SEO from "../../components/meta-seo"; + +import Basic from "examples/sliding-button/basic.tsx"; +import States from "examples/sliding-button/states.tsx"; +import LowThreshold from "examples/sliding-button/low-threshold.tsx"; + +import * as SlidingButtonExports from "baseui/sliding-button"; + +import Yard from "../../components/yard/index"; +import slidingButtonYardConfig from "../../components/yard/config/sliding-button"; + + + +export default Layout; + +# Sliding Button + + + +Sliding buttons are a variation of primary buttons that use a drag gesture instead of a standard tap to confirm actions. They help reduce accidental triggers and reinforce user intent by introducing deliberate friction. + +## When to use + +- **Prevent accidental actions**: Situations requiring confirmation to avoid unintended triggers. +- **Costly or non-reversible actions**: Confirming intent on high-stakes operations (e.g., emergency assistance, trip completion). +- **Multi-step flow completion**: Final step in extended user journeys. + +> **Caution**: If the action is not critical, a sliding button may add unnecessary complexity. Use a standard [Button](/components/button) instead. + +## Examples + + + + + + + + + + + + + +## Threshold + +Two completion thresholds support different user types: + +- **`THRESHOLD.high`** (default) — at least 80% drag distance. Standard, requires deliberate intent. +- **`THRESHOLD.low`** — at least 20% drag distance. For users with motor disabilities, seniors, or low-friction flows. + +## Accessibility + +- **Keyboard support**: Press Enter or Space to activate — provides a standard button interaction for screen reader users. +- **Screen readers**: Use the `aria-label` prop to provide alternative text that omits "Slide" terminology, since swipe gestures behave differently with VoiceOver and TalkBack enabled. +- The component sets `role="button"`, `aria-busy` during loading, and `aria-disabled` when disabled. + +## Content guidelines + +- **Use "Slide"** in the visible label to indicate the drag interaction. +- **Avoid "Swipe"** — it carries a navigational connotation rather than task completion. +- **Omit "Slide"** from `aria-label` when VoiceOver/TalkBack is expected — use the `aria-label` prop to provide a plain-language alternative. + + diff --git a/documentation-site/routes.jsx b/documentation-site/routes.jsx index 7b493a9642..236707b931 100644 --- a/documentation-site/routes.jsx +++ b/documentation-site/routes.jsx @@ -131,6 +131,10 @@ const routes = [ title: 'Slider', itemId: '/components/slider', }, + { + title: 'Sliding Button', + itemId: '/components/sliding-button', + }, { title: 'Stepper', itemId: '/components/stepper', diff --git a/package.json b/package.json index 358c1b234b..657eb24d40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "baseui", - "version": "18.0.0", + "version": "18.1.0", "description": "A React Component library implementing the Base design language", "keywords": [ "react", diff --git a/src/sliding-button/__tests__/sliding-button-low-threshold.scenario.tsx b/src/sliding-button/__tests__/sliding-button-low-threshold.scenario.tsx new file mode 100644 index 0000000000..7178f4ba0e --- /dev/null +++ b/src/sliding-button/__tests__/sliding-button-low-threshold.scenario.tsx @@ -0,0 +1,22 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { SlidingButton, THRESHOLD } from ".."; + +export function Scenario() { + return ( + { + // eslint-disable-next-line no-console + console.log("completed"); + }} + /> + ); +} diff --git a/src/sliding-button/__tests__/sliding-button-states.scenario.tsx b/src/sliding-button/__tests__/sliding-button-states.scenario.tsx new file mode 100644 index 0000000000..9333bc6d49 --- /dev/null +++ b/src/sliding-button/__tests__/sliding-button-states.scenario.tsx @@ -0,0 +1,26 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { SlidingButton } from ".."; + +export function Scenario() { + const [isLoading, setIsLoading] = React.useState(false); + return ( +
+ {}} /> + { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 2000); + }} + /> + +
+ ); +} diff --git a/src/sliding-button/__tests__/sliding-button.scenario.tsx b/src/sliding-button/__tests__/sliding-button.scenario.tsx new file mode 100644 index 0000000000..f300d7e3ba --- /dev/null +++ b/src/sliding-button/__tests__/sliding-button.scenario.tsx @@ -0,0 +1,20 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { SlidingButton } from ".."; + +export function Scenario() { + return ( + { + // eslint-disable-next-line no-console + console.log("completed"); + }} + /> + ); +} diff --git a/src/sliding-button/__tests__/sliding-button.stories.tsx b/src/sliding-button/__tests__/sliding-button.stories.tsx new file mode 100644 index 0000000000..e9e8405afd --- /dev/null +++ b/src/sliding-button/__tests__/sliding-button.stories.tsx @@ -0,0 +1,20 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import React from "react"; +import { Scenario as SlidingButtonDefault } from "./sliding-button.scenario"; +import { Scenario as SlidingButtonStates } from "./sliding-button-states.scenario"; +import { Scenario as SlidingButtonLowThreshold } from "./sliding-button-low-threshold.scenario"; + +export const Default = () => ; +export const States = () => ; +export const LowThreshold = () => ; + +export default { + meta: { + runtimeErrorsAllowed: true, + }, +}; diff --git a/src/sliding-button/__tests__/sliding-button.test.tsx b/src/sliding-button/__tests__/sliding-button.test.tsx new file mode 100644 index 0000000000..94c305000b --- /dev/null +++ b/src/sliding-button/__tests__/sliding-button.test.tsx @@ -0,0 +1,141 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { SlidingButton } from ".."; + +const getRoot = (container: HTMLElement) => + container.querySelector('[data-baseweb="sliding-button"]'); + +const getGrabber = (container: HTMLElement) => + container.querySelector('[data-baseweb="sliding-button-grabber"]'); + +beforeAll(() => { + HTMLElement.prototype.setPointerCapture = jest.fn(); + if (typeof globalThis.PointerEvent === "undefined") { + // @ts-ignore + globalThis.PointerEvent = class PointerEvent extends MouseEvent { + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + } + }; + } +}); + +describe("SlidingButton", () => { + test("renders the label", () => { + const { getByText } = render(); + expect(getByText("Confirm trip")).toBeInTheDocument(); + }); + + test("returns null when label is empty", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("has role=button and is keyboard-focusable", () => { + const { container } = render(); + const root = getRoot(container); + expect(root).toHaveAttribute("role", "button"); + expect(root).toHaveAttribute("tabindex", "0"); + }); + + test("sets aria-label from label when no explicit aria-label", () => { + const { container } = render(); + expect(getRoot(container)).toHaveAttribute("aria-label", "Confirm trip"); + }); + + test("sets explicit aria-label when provided", () => { + const { container } = render( + , + ); + expect(getRoot(container)).toHaveAttribute("aria-label", "Confirm trip"); + }); + + test("sets aria-busy when isLoading is true", () => { + const { container } = render( + , + ); + expect(getRoot(container)).toHaveAttribute("aria-busy", "true"); + }); + + test("does not render the grabber while loading", () => { + const { container } = render( + , + ); + expect(getGrabber(container)).toBeNull(); + }); + + test("sets aria-disabled when isDisabled is true", () => { + const { container } = render( + , + ); + expect(getRoot(container)).toHaveAttribute("aria-disabled", "true"); + }); + + test("calls onComplete on Enter key", () => { + const onComplete = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(getRoot(container) as Element, { key: "Enter" }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + test("calls onComplete on Space key", () => { + const onComplete = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(getRoot(container) as Element, { key: " " }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + test("does not call onComplete on Enter when disabled", () => { + const onComplete = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(getRoot(container) as Element, { key: "Enter" }); + expect(onComplete).not.toHaveBeenCalled(); + }); + + test("does not call onComplete on Enter when loading", () => { + const onComplete = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(getRoot(container) as Element, { key: "Enter" }); + expect(onComplete).not.toHaveBeenCalled(); + }); + + test("resets after slideBackAfterMs", () => { + jest.useFakeTimers(); + const onComplete = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(getRoot(container) as Element, { key: "Enter" }); + expect(onComplete).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(60); + fireEvent.keyDown(getRoot(container) as Element, { key: "Enter" }); + expect(onComplete).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + test("forwards ref to root element", () => { + const ref = React.createRef(); + render(); + expect(ref.current).not.toBeNull(); + expect(ref.current?.getAttribute("data-baseweb")).toBe("sliding-button"); + }); +}); diff --git a/src/sliding-button/constants.ts b/src/sliding-button/constants.ts new file mode 100644 index 0000000000..a5b8b3a045 --- /dev/null +++ b/src/sliding-button/constants.ts @@ -0,0 +1,25 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ + +export const THRESHOLD = { + low: "low", + high: "high", +} as const; + +export const THRESHOLD_VALUES: Record< + (typeof THRESHOLD)[keyof typeof THRESHOLD], + number +> = { + low: 0.2, + high: 0.8, +}; + +const GRABBER_ICON_SIZE_PX = 24; +const GRABBER_PADDING_PX = 16; + +export const BUTTON_SIZE = GRABBER_ICON_SIZE_PX + GRABBER_PADDING_PX * 2; // 56 +export const TAP_OFFSET = GRABBER_PADDING_PX; // 16 diff --git a/src/sliding-button/default-props.ts b/src/sliding-button/default-props.ts new file mode 100644 index 0000000000..226b49c24d --- /dev/null +++ b/src/sliding-button/default-props.ts @@ -0,0 +1,14 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import { THRESHOLD } from "./constants"; + +export const defaultProps = { + threshold: THRESHOLD.high, + isLoading: false, + isDisabled: false, + overrides: {}, +}; diff --git a/src/sliding-button/index.ts b/src/sliding-button/index.ts new file mode 100644 index 0000000000..33e5aa1095 --- /dev/null +++ b/src/sliding-button/index.ts @@ -0,0 +1,22 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +export { default as SlidingButton } from "./sliding-button"; +// Constants +export { THRESHOLD } from "./constants"; +// Styled elements +export { + Root as StyledRoot, + Track as StyledTrack, + Slider as StyledSlider, + Label as StyledLabel, + CompletedLabel as StyledCompletedLabel, + Grabber as StyledGrabber, + LoadingOverlay as StyledLoadingOverlay, + LoadingSpinner as StyledLoadingSpinner, +} from "./styled-components"; +// Types +export * from "./types"; diff --git a/src/sliding-button/sliding-button.tsx b/src/sliding-button/sliding-button.tsx new file mode 100644 index 0000000000..57ebcf1de6 --- /dev/null +++ b/src/sliding-button/sliding-button.tsx @@ -0,0 +1,267 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import ArrowRight from "../icon/arrow-right"; +import Check from "../icon/check"; +import { getOverrides } from "../helpers/overrides"; +import { useStyletron } from "../styles"; +import { + Root as StyledRoot, + Track as StyledTrack, + Slider as StyledSlider, + Label as StyledLabel, + CompletedLabel as StyledCompletedLabel, + Grabber as StyledGrabber, + LoadingOverlay as StyledLoadingOverlay, + LoadingSpinner as StyledLoadingSpinner, +} from "./styled-components"; +import { BUTTON_SIZE, TAP_OFFSET, THRESHOLD_VALUES } from "./constants"; +import { defaultProps } from "./default-props"; + +import type { SlidingButtonProps, StyleProps } from "./types"; + +function SlidingButtonInner( + { + label, + threshold = defaultProps.threshold, + isLoading = defaultProps.isLoading, + isDisabled = defaultProps.isDisabled, + onComplete, + overrides = defaultProps.overrides, + slideBackAfterMs, + "aria-label": ariaLabel, + }: SlidingButtonProps, + ref: React.Ref, +) { + const [, theme] = useStyletron(); + const isRtl = theme.direction === "rtl"; + + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState(0); + const [isCompleted, setIsCompleted] = useState(false); + + const dragStartRef = useRef(0); + const isDraggingRef = useRef(false); + const containerRef = useRef(null); + + const thresholdValue = THRESHOLD_VALUES[threshold]; + const isInteractive = !isDisabled && !isLoading && !isCompleted; + + // -- Pointer handlers ------------------------------------------------------- + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!isInteractive) return; + setIsDragging(true); + isDraggingRef.current = true; + setDragOffset(TAP_OFFSET); + const pos = isRtl ? -e.clientX : e.clientX; + dragStartRef.current = pos - TAP_OFFSET; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, + [isInteractive, isRtl], + ); + + const resetDrag = useCallback(() => { + setIsDragging(false); + isDraggingRef.current = false; + if (!isCompleted) { + setDragOffset(0); + } + }, [isCompleted]); + + useEffect(() => { + const handleGlobalPointerMove = (e: PointerEvent) => { + if (!isDraggingRef.current || !containerRef.current || isCompleted) + return; + + const pos = isRtl ? -e.clientX : e.clientX; + const delta = pos - dragStartRef.current; + if (delta <= 0) return; + + const containerWidth = containerRef.current.offsetWidth; + const maxOffset = containerWidth - BUTTON_SIZE; + const thresholdPixels = containerWidth * thresholdValue; + + if (delta > thresholdPixels) { + setDragOffset(maxOffset); + setIsCompleted(true); + setIsDragging(false); + isDraggingRef.current = false; + onComplete?.(); + } else { + setDragOffset(Math.min(delta, maxOffset)); + } + }; + + const handleGlobalPointerUp = () => { + setIsDragging(false); + isDraggingRef.current = false; + if (!isCompleted) { + setDragOffset(0); + } + }; + + if (isDragging) { + document.addEventListener("pointermove", handleGlobalPointerMove); + document.addEventListener("pointerup", handleGlobalPointerUp); + return () => { + document.removeEventListener("pointermove", handleGlobalPointerMove); + document.removeEventListener("pointerup", handleGlobalPointerUp); + }; + } + }, [isDragging, thresholdValue, isCompleted, onComplete, isRtl]); + + // -- Slide-back timer ------------------------------------------------------- + + useEffect(() => { + if (!isCompleted || slideBackAfterMs == null || slideBackAfterMs <= 0) + return; + const timerId = setTimeout(() => { + setIsCompleted(false); + setDragOffset(0); + }, slideBackAfterMs); + return () => clearTimeout(timerId); + }, [isCompleted, slideBackAfterMs]); + + // -- Keyboard handler ------------------------------------------------------- + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isInteractive) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsCompleted(true); + onComplete?.(); + } + }, + [isInteractive, onComplete], + ); + + // -- Early return ----------------------------------------------------------- + + if (!label) { + return null; + } + + // -- Derived values --------------------------------------------------------- + + const sliderWidth = isCompleted ? "100%" : `${BUTTON_SIZE + dragOffset}px`; + const isActuallySliding = isDragging && dragOffset > TAP_OFFSET; + + const sharedProps: StyleProps = { + $isDisabled: isDisabled, + $isLoading: isLoading, + $isCompleted: isCompleted, + $isDragging: isDragging, + $isInteractive: isInteractive, + $isActuallySliding: isActuallySliding, + $dragOffset: dragOffset, + $sliderWidth: sliderWidth, + $isRtl: isRtl, + }; + + // -- Overrides -------------------------------------------------------------- + + const [Root, rootProps] = getOverrides(overrides.Root, StyledRoot); + const [Track, trackProps] = getOverrides(overrides.Track, StyledTrack); + const [SliderEl, sliderProps] = getOverrides(overrides.Slider, StyledSlider); + const [LabelEl, labelProps] = getOverrides(overrides.Label, StyledLabel); + const [CompletedLabelEl, completedLabelProps] = getOverrides( + overrides.CompletedLabel, + StyledCompletedLabel, + ); + const [GrabberEl, grabberProps] = getOverrides( + overrides.Grabber, + StyledGrabber, + ); + const [LoadingOverlayEl, loadingOverlayProps] = getOverrides( + overrides.LoadingOverlay, + StyledLoadingOverlay, + ); + const [LoadingSpinnerEl, loadingSpinnerProps] = getOverrides( + overrides.LoadingSpinner, + StyledLoadingSpinner, + ); + + // -- Aria ------------------------------------------------------------------- + + const ariaProps: Record = { + "aria-label": ariaLabel || label, + }; + if (isLoading) { + ariaProps["aria-busy"] = true; + ariaProps["aria-live"] = "polite"; + } + if (isDisabled) { + ariaProps["aria-disabled"] = true; + } + + // -- Render ----------------------------------------------------------------- + + return ( + + + {isLoading && ( + + + + )} + + {!isLoading && ( + <> + + {label} + + + + {isCompleted && ( + + {label} + + )} + + + + + + + )} + + + ); +} + +function ArrowIcon({ isCompleted }: { isCompleted: boolean }) { + if (isCompleted) { + return ; + } + return ; +} + +const SlidingButton = React.forwardRef( + SlidingButtonInner, +); +SlidingButton.displayName = "SlidingButton"; +export default SlidingButton; diff --git a/src/sliding-button/styled-components.ts b/src/sliding-button/styled-components.ts new file mode 100644 index 0000000000..bd1d4a47c5 --- /dev/null +++ b/src/sliding-button/styled-components.ts @@ -0,0 +1,209 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import { styled } from "../styles"; +import { TAP_OFFSET } from "./constants"; +import type { StyleProps } from "./types"; + +export const Root = styled<"div", StyleProps>("div", ({ $theme }) => ({ + width: "100%", + borderTopLeftRadius: $theme.borders.radius300, + borderTopRightRadius: $theme.borders.radius300, + borderBottomLeftRadius: $theme.borders.radius300, + borderBottomRightRadius: $theme.borders.radius300, + ":focus": { outline: "none" }, + ":focus-visible": { + outline: `3px solid ${$theme.colors.borderAccent}`, + outlineOffset: "0px", + }, +})); +Root.displayName = "Root"; + +export const Track = styled<"div", StyleProps>( + "div", + ({ $theme, $isDisabled }) => ({ + position: "relative" as const, + backgroundColor: $isDisabled + ? $theme.colors.backgroundStateDisabled + : $theme.colors.backgroundTertiary, + borderTopLeftRadius: $theme.borders.radius300, + borderTopRightRadius: $theme.borders.radius300, + borderBottomLeftRadius: $theme.borders.radius300, + borderBottomRightRadius: $theme.borders.radius300, + height: $theme.sizing.scale1400, + overflow: "hidden" as const, + userSelect: "none" as const, + touchAction: "none" as const, + }), +); +Track.displayName = "Track"; + +export const LoadingOverlay = styled<"div", StyleProps>( + "div", + ({ $theme }) => ({ + position: "absolute" as const, + top: "0", + left: "0", + right: "0", + bottom: "0", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: $theme.colors.backgroundInversePrimary, + borderTopLeftRadius: $theme.borders.radius300, + borderTopRightRadius: $theme.borders.radius300, + borderBottomLeftRadius: $theme.borders.radius300, + borderBottomRightRadius: $theme.borders.radius300, + }), +); +LoadingOverlay.displayName = "LoadingOverlay"; + +export const LoadingSpinner = styled<"div", StyleProps>( + "div", + ({ $theme }) => ({ + width: $theme.sizing.scale700, + height: $theme.sizing.scale700, + borderTopLeftRadius: "50%", + borderTopRightRadius: "50%", + borderBottomLeftRadius: "50%", + borderBottomRightRadius: "50%", + borderLeftWidth: $theme.sizing.scale0, + borderTopWidth: $theme.sizing.scale0, + borderRightWidth: $theme.sizing.scale0, + borderBottomWidth: $theme.sizing.scale0, + borderLeftStyle: "solid" as const, + borderTopStyle: "solid" as const, + borderRightStyle: "solid" as const, + borderBottomStyle: "solid" as const, + borderLeftColor: $theme.colors.borderInverseOpaque, + borderRightColor: $theme.colors.borderInverseOpaque, + borderBottomColor: $theme.colors.borderInverseOpaque, + borderTopColor: $theme.colors.contentInversePrimary, + boxSizing: "border-box" as const, + animationName: { + from: { transform: "rotate(0deg)" }, + to: { transform: "rotate(360deg)" }, + }, + animationDuration: "0.75s", + animationTimingFunction: "linear", + animationIterationCount: "infinite", + }), +); +LoadingSpinner.displayName = "LoadingSpinner"; + +export const Label = styled<"div", StyleProps>( + "div", + ({ $theme, $isDisabled, $isActuallySliding, $isCompleted, $isRtl }) => { + const startProperty = $isRtl ? "right" : "left"; + const endProperty = $isRtl ? "left" : "right"; + return { + position: "absolute" as const, + top: "0", + [startProperty]: $theme.sizing.scale1400, + [endProperty]: "0", + bottom: "0", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: + $isDisabled || $isActuallySliding + ? $theme.colors.contentStateDisabled + : $theme.colors.contentPrimary, + opacity: $isCompleted ? 0 : 1, + transitionProperty: "opacity, color", + transitionDuration: $theme.animation.timing200, + transitionTimingFunction: "ease-out", + pointerEvents: "none" as const, + ...$theme.typography.LabelLarge, + fontWeight: 500, + }; + }, +); +Label.displayName = "Label"; + +export const Slider = styled<"div", StyleProps>( + "div", + ({ + $theme, + $isDisabled, + $isDragging, + $dragOffset, + $sliderWidth, + $isCompleted, + $isRtl, + }) => { + const startProperty = $isRtl ? "right" : "left"; + const endAlign = $isRtl ? "flex-start" : "flex-end"; + const paddingEnd = $isRtl ? "paddingLeft" : "paddingRight"; + const suppressTransition = $isDragging && $dragOffset !== TAP_OFFSET; + return { + position: "absolute" as const, + top: "0", + [startProperty]: "0", + bottom: "0", + width: $sliderWidth, + backgroundColor: $isDisabled + ? $theme.colors.backgroundStateDisabled + : $theme.colors.backgroundInversePrimary, + borderTopLeftRadius: $theme.borders.radius300, + borderTopRightRadius: $theme.borders.radius300, + borderBottomLeftRadius: $theme.borders.radius300, + borderBottomRightRadius: $theme.borders.radius300, + display: "flex", + alignItems: "center", + justifyContent: endAlign, + [paddingEnd]: $isCompleted ? $theme.sizing.scale600 : "0", + overflow: "hidden" as const, + ...(suppressTransition + ? { transition: "none" } + : { + transitionProperty: "width", + transitionDuration: $theme.animation.timing200, + transitionTimingFunction: "ease-out", + }), + }; + }, +); +Slider.displayName = "Slider"; + +export const CompletedLabel = styled<"div", StyleProps>( + "div", + ({ $theme, $isDisabled }) => ({ + position: "absolute" as const, + top: "0", + left: "0", + right: "0", + bottom: "0", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: $isDisabled + ? $theme.colors.contentStateDisabled + : $theme.colors.contentInversePrimary, + pointerEvents: "none" as const, + ...$theme.typography.LabelLarge, + fontWeight: 500, + }), +); +CompletedLabel.displayName = "CompletedLabel"; + +export const Grabber = styled<"div", StyleProps>( + "div", + ({ $theme, $isDisabled, $isInteractive }) => ({ + width: $theme.sizing.scale1400, + height: $theme.sizing.scale1400, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: $isDisabled + ? $theme.colors.contentStateDisabled + : $theme.colors.contentInversePrimary, + cursor: $isInteractive ? "grab" : "default", + touchAction: "none" as const, + }), +); +Grabber.displayName = "Grabber"; diff --git a/src/sliding-button/types.ts b/src/sliding-button/types.ts new file mode 100644 index 0000000000..4ee828879d --- /dev/null +++ b/src/sliding-button/types.ts @@ -0,0 +1,50 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import type { Override } from "../helpers/overrides"; +import type { THRESHOLD } from "./constants"; + +export type SlidingButtonOverrides = { + Root?: Override; + Track?: Override; + Slider?: Override; + Label?: Override; + CompletedLabel?: Override; + Grabber?: Override; + LoadingOverlay?: Override; + LoadingSpinner?: Override; +}; + +export type SlidingButtonProps = { + /** Text displayed in the button track. */ + label: string; + /** Completion threshold — 'high' requires 80% drag, 'low' requires 20%. */ + threshold?: keyof typeof THRESHOLD; + /** Shows loading spinner, disables interaction. */ + isLoading?: boolean; + /** Grays out the component, disables interaction. */ + isDisabled?: boolean; + /** Called once when the user drags past the threshold. */ + onComplete?: () => void; + /** Auto-reset to idle state after N milliseconds. */ + slideBackAfterMs?: number; + /** Override internal elements. */ + overrides?: SlidingButtonOverrides; + /** Accessible label — recommended to omit "Slide" for screen readers. */ + "aria-label"?: string; +}; + +export type StyleProps = { + $isDisabled?: boolean; + $isLoading?: boolean; + $isCompleted?: boolean; + $isDragging?: boolean; + $isInteractive?: boolean; + $isActuallySliding?: boolean; + $dragOffset?: number; + $sliderWidth?: string; + $isRtl?: boolean; +};