diff --git a/.changeset/pink-dancers-run.md b/.changeset/pink-dancers-run.md new file mode 100644 index 00000000..42c6f278 --- /dev/null +++ b/.changeset/pink-dancers-run.md @@ -0,0 +1,6 @@ +--- +"@spear-ai/storybook": minor +"@spear-ai/ui": minor +--- + +Created Slider component(s). diff --git a/packages/storybook/src/app/providers.tsx b/packages/storybook/src/app/providers.tsx index 48506020..de4eaf51 100644 --- a/packages/storybook/src/app/providers.tsx +++ b/packages/storybook/src/app/providers.tsx @@ -3,6 +3,7 @@ import { usePreviousDistinct } from "@react-hookz/web"; import { ThemeProvider } from "next-themes"; import { ReactNode, useEffect } from "react"; +import { I18nProvider } from "react-aria-components"; import { IntlProvider, ReactIntlErrorCode } from "react-intl"; import { StorybookProvider } from "@/components/storybook-provider/storybook-provider"; @@ -38,9 +39,11 @@ export const AppProviders = (properties: { } }} > - - {children} - + + + {children} + + ); diff --git a/packages/storybook/src/components/icon-button/index.stories.tsx b/packages/storybook/src/components/icon-button/index.stories.tsx index 27a907c3..f8bb02ee 100644 --- a/packages/storybook/src/components/icon-button/index.stories.tsx +++ b/packages/storybook/src/components/icon-button/index.stories.tsx @@ -35,7 +35,6 @@ const PreviewIconButton = (properties: { return ( { + const state = useContext(SliderStateContext); + // eslint-disable-next-line @typescript-eslint/unbound-method + const { decrementThumb } = state; + + const handlePress = useCallback(() => { + decrementThumb(0); + decrementThumb(1); + }, [decrementThumb]); + + return ( + + + + + + ); +}; + +const PreviewSliderIncrementButton = () => { + const state = useContext(SliderStateContext); + // eslint-disable-next-line @typescript-eslint/unbound-method + const { incrementThumb } = state; + + const handlePress = useCallback(() => { + incrementThumb(0); + incrementThumb(1); + }, [incrementThumb]); + + return ( + + + + + + ); +}; + +const PreviewSlider = (properties: { + color: "neutral" | "primary"; + hasEndIcon: boolean; + hasFill: boolean; + hasLabel: boolean; + hasLabelDescription: boolean; + hasOrigin: boolean; + hasStartIcon: boolean; + hasValence: boolean; + isDisabled: boolean; + isRange: boolean; + isSquished: boolean; + maxValue: number; + minValue: number; + orientation: "horizontal" | "vertical"; + originValue: number; + step: number; + thumbShape: "circle" | "pill" | "square"; + variant: "soft" | "surface"; +}) => { + const { + color, + hasEndIcon, + hasFill, + hasLabel, + hasLabelDescription, + hasOrigin, + hasStartIcon, + hasValence, + isDisabled, + isRange, + isSquished, + maxValue, + minValue, + orientation, + originValue, + step, + thumbShape, + variant, + } = properties; + const intl = useIntl(); + + let thumbShapeClassName = ""; + + switch (thumbShape) { + case "pill": { + thumbShapeClassName = "h-5 w-2.5 group-data-[orientation=vertical]:rotate-90"; + break; + } + case "square": { + thumbShapeClassName = "rounded-sm"; + break; + } + default: { + break; + } + } + + const defaultValue = useMemo( + () => + getDefaultSliderValue({ + isRange, + maxValue, + minValue, + }), + [isRange, maxValue, minValue], + ); + + const key = useMemo( + () => + getUniqueSliderKey({ + isRange, + maxValue, + minValue, + }), + [isRange, maxValue, minValue], + ); + + return ( +
+
+ + + {hasLabel ? ( + + {intl.formatMessage({ + defaultMessage: "Volume", + id: "y867Vs", + })} + + ) : null} + {hasLabel && hasLabelDescription ? ( + + {intl.formatMessage({ + defaultMessage: "How loud until the neighbors complain.", + id: "bEWLDO", + })} + + ) : null} + {orientation === "horizontal" ? ( + + {({ state }) => + state.values.length === 2 + ? `${state.getThumbValue(0)}–${state.getThumbValue(1)} dB` + : `${state.getThumbValue(0)} dB` + } + + ) : null} + + + {hasStartIcon ? : null} + + {hasFill ? : null} + + {isRange ? : null} + + {hasEndIcon ? : null} + + +
+
+ ); +}; + +const meta = { + component: PreviewSlider, +} satisfies Meta; + +type Story = StoryObj; + +export const Standard: Story = { + args: { + color: "neutral", + hasEndIcon: true, + hasFill: true, + hasLabel: true, + hasLabelDescription: true, + hasOrigin: false, + hasStartIcon: true, + hasValence: false, + isDisabled: false, + isRange: false, + isSquished: false, + maxValue: 100, + minValue: 0, + orientation: "horizontal", + originValue: 0, + step: 1, + thumbShape: "circle", + variant: "surface", + }, + argTypes: { + color: { + control: { + type: "select", + }, + options: ["neutral", "primary"], + }, + orientation: { + control: { type: "select" }, + options: ["horizontal", "vertical"], + }, + thumbShape: { + control: { type: "select" }, + options: ["circle", "pill", "square"], + }, + variant: { + control: { type: "select" }, + options: ["soft", "surface"], + }, + }, + parameters: { + layout: "centered", + }, +}; + +export default meta; diff --git a/packages/storybook/src/components/slider/slider-group/index.stories.tsx b/packages/storybook/src/components/slider/slider-group/index.stories.tsx new file mode 100644 index 00000000..f2062dd8 --- /dev/null +++ b/packages/storybook/src/components/slider/slider-group/index.stories.tsx @@ -0,0 +1,262 @@ +import { + Slider, + SliderAddonLabel, + SliderAddonOutput, + SliderFill, + SliderGroup, + SliderGroupDescription, + SliderGroupLabel, + SliderThumb, + SliderTrack, + SliderTrackGroup, +} from "@spear-ai/ui/components/slider"; +import { cx } from "@spear-ai/ui/helpers/cx"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useMemo } from "react"; +import { Form } from "react-aria-components"; +import { useIntl } from "react-intl"; +import { getDefaultSliderValue } from "@/helpers/get-default-slider-value"; +import { getUniqueSliderKey } from "@/helpers/get-unique-slider-key"; + +const PreviewSliderGroup = (properties: { + color: "neutral" | "primary"; + firstSliderIsDisabled: boolean; + groupIsDisabled: boolean; + hasFill: boolean; + hasGroupLabel: boolean; + hasGroupLabelDescription: boolean; + hasLabel: boolean; + hasOrigin: boolean; + hasOutput: boolean; + hasValence: boolean; + isRange: boolean; + isSquished: boolean; + maxValue: number; + minValue: number; + originValue: number; + step: number; + thumbShape: "circle" | "pill" | "square"; + variant: "soft" | "surface"; +}) => { + const { + color, + firstSliderIsDisabled, + groupIsDisabled, + hasFill, + hasGroupLabel, + hasGroupLabelDescription, + hasLabel, + hasOrigin, + hasOutput, + hasValence, + isRange, + isSquished, + maxValue, + minValue, + originValue, + step, + thumbShape, + variant, + } = properties; + const intl = useIntl(); + + let thumbShapeClassName = ""; + + switch (thumbShape) { + case "pill": { + thumbShapeClassName = "h-5 w-2.5 rotate-90"; + break; + } + case "square": { + thumbShapeClassName = "rounded-sm"; + break; + } + default: { + break; + } + } + + const defaultValue = useMemo( + () => + getDefaultSliderValue({ + isRange, + maxValue, + minValue, + }), + [isRange, maxValue, minValue], + ); + + const defaultKey = useMemo( + () => + getUniqueSliderKey({ + isRange, + maxValue, + minValue, + }), + [isRange, maxValue, minValue], + ); + + return ( +
+
+ + {hasGroupLabel ? ( + + {intl.formatMessage({ + defaultMessage: "Yearly budget", + id: "2BAYgQ", + })} + + ) : null} + {hasGroupLabel && hasGroupLabelDescription ? ( + + {intl.formatMessage({ + defaultMessage: "Don’t worry, it’s other peoples money.", + id: "6olAwT", + })} + + ) : null} +
+
    + {Array.from({ length: 14 }, (_, index) => ( +
  1. + ))} +
+
+ {Array.from({ length: 5 }, (_, index) => ( + + + {hasLabel ? ( + + {intl.formatMessage( + { + defaultMessage: "FY{year}", + id: "kReONT", + }, + { + year: index + 25, + }, + )} + + ) : null} + + {hasFill ? : null} + + {isRange ? : null} + + {hasOutput ? ( + + {({ state }) => ( + + {state.values.length === 1 + ? intl.formatNumber(state.values[0] ?? 0, { + currency: "USD", + maximumFractionDigits: 0, + minimumFractionDigits: 0, + signDisplay: "exceptZero", + style: "currency", + }) + : intl.formatMessage( + { + defaultMessage: "{lower}–{upper}", + id: "zfZnaF", + }, + { + lower: intl.formatNumber(state.values[0] ?? 0, { + currency: "USD", + maximumFractionDigits: 0, + minimumFractionDigits: 0, + style: "currency", + }), + upper: intl.formatNumber(state.values[1] ?? 0, { + currency: "USD", + maximumFractionDigits: 0, + minimumFractionDigits: 0, + style: "currency", + }), + }, + )} + + )} + + ) : null} + + + ))} +
+
+
+
+
+ ); +}; + +const meta = { + component: PreviewSliderGroup, +} satisfies Meta; + +type Story = StoryObj; + +export const Standard: Story = { + args: { + color: "neutral", + firstSliderIsDisabled: false, + groupIsDisabled: false, + hasFill: true, + hasGroupLabel: true, + hasGroupLabelDescription: true, + hasLabel: true, + hasOrigin: true, + hasOutput: true, + hasValence: true, + isRange: false, + isSquished: false, + maxValue: 200, + minValue: -100, + originValue: 0, + step: 1, + thumbShape: "pill", + variant: "soft", + }, + argTypes: { + color: { + control: { + type: "select", + }, + options: ["neutral", "primary"], + }, + thumbShape: { + control: { type: "select" }, + options: ["circle", "pill", "square"], + }, + variant: { + control: { type: "select" }, + options: ["soft", "surface"], + }, + }, + parameters: { + layout: "centered", + }, +}; + +export default meta; diff --git a/packages/storybook/src/helpers/get-default-slider-value.ts b/packages/storybook/src/helpers/get-default-slider-value.ts new file mode 100644 index 00000000..5b9bbe15 --- /dev/null +++ b/packages/storybook/src/helpers/get-default-slider-value.ts @@ -0,0 +1,22 @@ +export const getDefaultSliderValue = ({ + isRange, + maxValue, + minValue, +}: { + isRange: boolean; + maxValue: number; + minValue: number; +}) => { + const delta = maxValue - minValue; + + if (isRange) { + const lowerDistance = Math.max(Math.floor(delta / 4), minValue); + const upperDistance = Math.max(Math.floor(delta / 2), minValue); + const lowerValue = Math.min(minValue + lowerDistance, maxValue); + const upperValue = Math.min(minValue + upperDistance, maxValue); + return [lowerValue, upperValue]; + } + + const distance = Math.max(Math.floor(delta / 3), minValue); + return Math.min(minValue + distance, maxValue); +}; diff --git a/packages/storybook/src/helpers/get-unique-slider-key.ts b/packages/storybook/src/helpers/get-unique-slider-key.ts new file mode 100644 index 00000000..10554cf2 --- /dev/null +++ b/packages/storybook/src/helpers/get-unique-slider-key.ts @@ -0,0 +1,2 @@ +export const getUniqueSliderKey = (options: { isRange: boolean; maxValue: number; minValue: number }) => + JSON.stringify(options); diff --git a/packages/ui/src/components/slider.tsx b/packages/ui/src/components/slider.tsx new file mode 100644 index 00000000..cd1c0f50 --- /dev/null +++ b/packages/ui/src/components/slider.tsx @@ -0,0 +1,309 @@ +import { + ComponentPropsWithoutRef, + createContext, + CSSProperties, + ElementRef, + forwardRef, + HTMLAttributes, + useContext, + useMemo, +} from "react"; +import { + Group as SliderGroupPrimitive, + Label as LabelPrimitive, + Slider as SliderPrimitive, + SliderContext, + SliderOutput as SliderOutputPrimitive, + SliderStateContext, + SliderThumb as SliderThumbPrimitive, + SliderTrack as SliderTrackPrimitive, +} from "react-aria-components"; +import { cx } from "@/helpers/cx"; + +export const SliderExtraContext = createContext<{ + hasValence: boolean; + originValue: number | null; +}>({ + hasValence: false, + originValue: null, +}); + +export const SliderGroup = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + className?: string | undefined; + /** + * Whether the slider group is disabled. + * @selector [data-disabled] + */ + isDisabled?: boolean | undefined; + } +>(({ className, isDisabled = false, ...properties }, reference) => { + const mergedClassName = cx("group/group outline-0", className); + const context = useMemo(() => ({ isDisabled }), [isDisabled]); + return ( + + + + ); +}); + +SliderGroup.displayName = "SliderGroup"; + +export const SliderGroupLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...properties }, reference) => { + const mergedClassName = cx( + "text-neutral-12 group-disabled/group:text-neutral-11 mb-2 block select-none text-base/6 sm:text-sm/6", + className, + ); + return ( + + ); +}); + +SliderGroupLabel.displayName = "SliderGroupLabel"; + +export const SliderGroupDescription = forwardRef>( + ({ className, ...properties }, reference) => { + const mergedClassName = cx( + "text-neutral-11 group-disabled/group:text-neutral-9 -mt-1 mb-2 text-base/6 sm:text-sm/6", + className, + ); + return

; + }, +); + +SliderGroupLabel.displayName = "SliderGroupDescription"; + +export const Slider = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + className?: string | undefined; + /** The Slider color. */ + color?: "neutral" | "primary" | undefined; + /** Whether the Slider range is negative below the origin or positive above the origin. */ + hasValence?: boolean | undefined; + /** The origin of the Slider track range. */ + originValue?: number | undefined; + /** The Slider variant. */ + variant?: "soft" | "surface" | undefined; + } +>( + ( + { + className, + color = "neutral", + hasValence = false, + originValue = null, + variant = "surface", + ...properties + }, + reference, + ) => { + const extra = useMemo(() => ({ hasValence, originValue }), [hasValence, originValue]); + const mergedClassName = cx("group", className); + return ( + + + + ); + }, +); + +Slider.displayName = "Slider"; + +export const SliderLabels = forwardRef>( + ({ className, ...properties }, reference) => { + const mergedClassName = cx("flex flex-wrap items-end justify-between", className); + return

; + }, +); + +SliderLabels.displayName = "SliderLabels"; + +export const SliderLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...properties }, reference) => { + const mergedClassName = cx( + "text-neutral-12 group-disabled:text-neutral-11 mb-2 block select-none text-base/6 sm:text-sm/6", + className, + ); + return ; +}); + +SliderLabel.displayName = "SliderLabel"; + +export const SliderAddonLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...properties }, reference) => { + const mergedClassName = cx( + "text-neutral-12 group-disabled:text-neutral-11 mt-1 inline-block select-none whitespace-nowrap text-sm/6 group-data-[orientation=vertical]:order-last group-data-[orientation=vertical]:text-center sm:text-xs/6", + className, + ); + return ; +}); + +SliderLabel.displayName = "SliderAddonLabel"; + +export const SliderDescription = forwardRef>( + ({ className, ...properties }, reference) => { + const mergedClassName = cx( + "text-neutral-11 group-disabled:text-neutral-9 -mt-1 mb-2 w-full text-base/6 sm:text-sm/6", + className, + ); + return

; + }, +); + +SliderDescription.displayName = "SliderDescription"; + +export const SliderOutput = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { className?: string | undefined } +>(({ className, ...properties }, reference) => { + const mergedClassName = cx( + "text-neutral-11 block text-end text-base/6 tabular-nums group-data-[orientation=horizontal]:ms-auto sm:text-sm/6", + className, + ); + return ; +}); + +SliderOutput.displayName = "SliderOutput"; + +export const SliderAddonOutput = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { className?: string | undefined } +>(({ className, ...properties }, reference) => { + const mergedClassName = cx( + "group-disabled:text-neutral-10 text-neutral-11 mb-1 inline-block whitespace-nowrap text-sm/6 tabular-nums group-data-[orientation=vertical]:order-first group-data-[orientation=horizontal]:ms-auto group-data-[orientation=vertical]:text-center sm:text-xs/6", + className, + ); + return ; +}); + +SliderOutput.displayName = "SliderAddonOutput"; + +export const SliderTrackGroup = forwardRef>( + ({ className, ...properties }, reference) => { + const mergedClassName = cx( + "items-center group-data-[orientation=horizontal]:flex group-data-[orientation=vertical]:inline-flex group-data-[orientation=vertical]:flex-col", + className, + ); + return

; + }, +); + +SliderTrackGroup.displayName = "SliderTrackGroup"; + +export const SliderTrack = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { className?: string | undefined } +>(({ className, ...properties }, reference) => { + const mergedClassName = cx( + "before:bg-neutral-a-3 before:outline-neutral-a-7 before:group-disabled:outline-neutral-a-6 relative before:absolute before:h-full before:w-full before:rounded-full before:outline-1 before:-outline-offset-1 before:content-[''] group-data-[orientation=horizontal]:h-2 group-data-[orientation=horizontal]:w-full group-data-[orientation=vertical]:w-2 before:group-data-[orientation=horizontal]:top-1/2 before:group-data-[orientation=horizontal]:-translate-y-1/2 before:group-data-[variant=surface]:outline", + className, + ); + return ; +}); + +SliderTrack.displayName = "SliderTrack"; + +export const SliderFill = forwardRef>( + ({ className, style, ...properties }, reference) => { + const state = useContext(SliderStateContext); + const extra = useContext(SliderExtraContext); + let valence = ""; + let size = 0; + let offset = 0; + + if (state.values.length === 2) { + const thumbDistance1 = 100 * state.getThumbPercent(0); + const thumbDistance2 = 100 * state.getThumbPercent(1); + size = Math.abs(thumbDistance2 - thumbDistance1); + offset = Math.min(thumbDistance1, thumbDistance2); + } else { + const minValue = state.getThumbMinValue(0); + const maxValue = state.getThumbMaxValue(0); + const thumbDistance = 100 * state.getThumbPercent(0); + const originValue = extra.originValue ?? minValue; + const originDistance = 100 * (Math.abs(originValue - minValue) / Math.abs(maxValue - minValue)); + size = Math.abs(thumbDistance - originDistance); + offset = Math.min(thumbDistance, originDistance); + + if (extra.hasValence) { + if (size === 0) { + valence = "neutral"; + } else { + valence = thumbDistance > originDistance ? "x-positive" : "x-negative"; + } + } + } + + const mergedClassName = cx( + "before:data-[valence=x-negative]:bg-x-negative-9 before:data-[valence=x-negative]:outline-x-negative-a-7 before:data-[valence=x-positive]:bg-x-positive-9 before:data-[valence=x-positive]:outline-x-positive-a-7 before:group-disabled:outline-neutral-a-6 before:data-[valence=x-negative]:group-disabled:outline-x-negative-a-6 before:data-[valence=x-positive]:group-disabled:outline-x-positive-a-6 before:outline-neutral-a-7 before:group-data-[color=primary]:outline-primary-a-7 before:bg-neutral-9 before:group-data-[color=primary]:bg-primary-9 before:data-[valence=x-negative]:group-disabled:bg-x-negative-3 before:data-[valence=x-positive]:group-disabled:bg-x-positive-3 before:group-disabled:bg-neutral-3 absolute before:absolute before:inset-0 before:rounded-full before:outline-1 before:-outline-offset-1 before:content-[''] before:group-data-[variant=surface]:outline", + className, + ); + + const mergedStyle: CSSProperties = { + ...style, + bottom: state.orientation === "vertical" ? `${offset}%` : "0%", + height: state.orientation === "vertical" ? `${size}%` : "100%", + insetInlineStart: state.orientation === "vertical" ? "0%" : `${offset}%`, + width: state.orientation === "vertical" ? "100%" : `${size}%`, + }; + + return ( +
+ ); + }, +); + +export const SliderThumb = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { className?: string | undefined } +>(({ className, style, ...properties }, reference) => { + const mergedClassName = cx( + "group-disabled:bg-neutral-2 group-disabled:outline-neutral-6 absolute size-4 rounded-full bg-white shadow group-disabled:outline group-disabled:outline-1 group-disabled:-outline-offset-1 group-data-[orientation=horizontal]:top-1/2 group-data-[orientation=vertical]:start-1 group-data-[orientation=horizontal]:-translate-x-1/2 group-data-[orientation=horizontal]:-translate-y-1/2 group-data-[orientation=vertical]:-translate-y-1/2 ltr:group-data-[orientation=vertical]:-translate-x-1/2 rtl:group-data-[orientation=vertical]:translate-x-1/2", + className, + ); + + const mergedStyle: CSSProperties = { + transform: "", + ...style, + }; + + return ( + + ); +}); + +SliderThumb.displayName = "SliderThumb"; diff --git a/turbo.json b/turbo.json index bdfe6799..ec8cee25 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,16 @@ "//#typescript:check": {}, "build": { "dependsOn": ["^build"], - "inputs": [".env", ".env.local", ".env.production", ".env.production.local"], + "inputs": [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "**/*.cjs", + "**/*.js", + "**/*.ts", + "**/*.tsx" + ], "outputs": ["build/**", "dist/**", "storybook-static/**"] }, "check": {