From 63057cfdc2b0839ca36b18d9efb4261173741b82 Mon Sep 17 00:00:00 2001 From: i-subham Date: Sun, 8 Mar 2026 19:36:22 +0530 Subject: [PATCH] Add SliderBlock component with label, inline editing, and range support - Introduce SliderBlock: a high-level slider wrapper with a label header, inline value controls (text Input for continuous, Select dropdown for discrete), range support with two controls, input validation with error messages, and debounced onValueCommit - Merge sliderBlock styles into slider.scss (single file); fix input placement by aligning header items properly and adding flex-start on error state - Disable Input and Select controls when the slider is disabled - Fix SelectItem children to use String(o) so value "0" displays correctly in the trigger - Update genui-lib Slider to render SliderBlock instead of the base Slider, adding optional label prop to the schema - Replace storybook stories with SliderBlock variants (continuous, discrete, range, disabled) - Export SliderBlock from the Slider barrel and dependencies Made-with: Cursor --- .../src/components/Slider/SliderBlock.tsx | 277 ++++++++++++++++++ .../src/components/Slider/dependencies.ts | 2 +- .../react-ui/src/components/Slider/index.ts | 1 + .../src/components/Slider/slider.scss | 112 ++++++- .../Slider/stories/slider.stories.tsx | 190 +++++------- .../react-ui/src/genui-lib/Form/schema.ts | 2 +- .../react-ui/src/genui-lib/Slider/index.tsx | 10 +- .../react-ui/src/genui-lib/Slider/schema.ts | 5 +- 8 files changed, 471 insertions(+), 128 deletions(-) create mode 100644 packages/react-ui/src/components/Slider/SliderBlock.tsx diff --git a/packages/react-ui/src/components/Slider/SliderBlock.tsx b/packages/react-ui/src/components/Slider/SliderBlock.tsx new file mode 100644 index 000000000..8bf521937 --- /dev/null +++ b/packages/react-ui/src/components/Slider/SliderBlock.tsx @@ -0,0 +1,277 @@ +import clsx from "clsx"; +import debounce from "lodash-es/debounce"; +import { AlertCircle } from "lucide-react"; +import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Input } from "../Input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../Select"; +import { Slider, SliderProps } from "./Slider"; + +export interface SliderBlockProps extends Omit { + label: string; + defaultValue?: number[]; +} + +const ValueInput = ({ + value, + onChange, + error, + disabled, +}: { + value: number; + onChange: (newValue: number) => void; + error: string | undefined; + disabled?: boolean; +}) => { + const [inputValue, setInputValue] = useState(value); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (!isFocused) { + setInputValue(value); + } + }, [value, isFocused]); + + return ( +
+ ) => { + const { value: newValue } = e.target; + setInputValue(newValue); + + if (!isNaN(Number(newValue))) { + onChange(Number(newValue)); + } + }} + className={clsx("openui-slider-block__input", { + "openui-slider-block__input-error": error, + })} + disabled={disabled} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> +
+ ); +}; + +export const SliderBlock = (props: SliderBlockProps) => { + const { + label, + name, + variant, + min = 0, + max = 100, + step, + defaultValue, + disabled, + ...sliderProps + } = props; + + const [value, setValue] = useState(defaultValue ?? [min]); + + const { min: minError, max: maxError } = useMemo(() => { + const [minValue, maxValue] = value; + const checkValue = (v: number) => { + if (isNaN(v)) return "Invalid number"; + if (v < min || v > max) return `Value must be between ${min} and ${max}`; + return ""; + }; + const error = { min: checkValue(minValue!), max: checkValue(maxValue!) }; + + if (value.length > 1 && minValue! > maxValue!) { + error.min = "Min must be less than max"; + } + + return error; + }, [value, min, max]); + + const onValueCommitRef = useRef((sliderProps as any)?.onValueCommit); + onValueCommitRef.current = (sliderProps as any).onValueCommit; + + const debouncedOnValueCommit = useMemo( + () => + debounce((newValue: number[]) => { + onValueCommitRef.current?.(newValue); + }, 200), + [], + ); + + useEffect(() => { + return () => { + debouncedOnValueCommit.flush(); + }; + }, [debouncedOnValueCommit]); + + const setValueAndCommit = useCallback( + (newValue: number[]) => { + setValue(newValue); + debouncedOnValueCommit(newValue); + }, + [debouncedOnValueCommit], + ); + + useEffect(() => { + setValue(defaultValue ?? [min]); + }, [defaultValue, min]); + + const isRange = value.length > 1; + const isDiscrete = variant === "discrete"; + const effectiveStep = isDiscrete ? (step ?? 1) : Math.max(1, step ?? 1); + + const controlElements = useMemo(() => { + if (isDiscrete) { + const allOptions = Array.from( + { length: Math.floor((max - min) / effectiveStep) + 1 }, + (_, i) => min + i * effectiveStep, + ); + return isRange ? ( +
+
+ +
+ +
+
+ ) : ( +
+ +
+ ); + } else { + return isRange ? ( +
+
+ setValueAndCommit([newMin, value[1] ?? max])} + error={minError} + disabled={disabled} + /> +
+ setValueAndCommit([value[0] ?? min, newMax])} + error={maxError} + disabled={disabled} + /> +
+ {(minError || maxError) && ( +
+ {minError || maxError} +
+ )} +
+ ) : ( +
+ setValueAndCommit([newVal])} + error={minError} + disabled={disabled} + /> + {minError && ( +
+ + {minError} +
+ )} +
+ ); + } + }, [ + isDiscrete, + isRange, + value, + min, + max, + effectiveStep, + minError, + maxError, + disabled, + setValueAndCommit, + ]); + + const hasError = !isDiscrete && (isRange ? Boolean(minError || maxError) : Boolean(minError)); + + return ( +
+
+ {label} + {controlElements} +
+
+ { + setValueAndCommit([...v].sort((a, b) => a - b)); + }} + min={min} + max={max} + step={effectiveStep} + variant={variant} + name={name} + disabled={disabled} + /> +
+
+ ); +}; diff --git a/packages/react-ui/src/components/Slider/dependencies.ts b/packages/react-ui/src/components/Slider/dependencies.ts index 821b03073..94f2d2aa8 100644 --- a/packages/react-ui/src/components/Slider/dependencies.ts +++ b/packages/react-ui/src/components/Slider/dependencies.ts @@ -1,2 +1,2 @@ -const dependencies = ["Slider"]; +const dependencies = ["Slider", "SliderBlock"]; export default dependencies; diff --git a/packages/react-ui/src/components/Slider/index.ts b/packages/react-ui/src/components/Slider/index.ts index a58909197..85e273607 100644 --- a/packages/react-ui/src/components/Slider/index.ts +++ b/packages/react-ui/src/components/Slider/index.ts @@ -1 +1,2 @@ export * from "./Slider"; +export * from "./SliderBlock"; diff --git a/packages/react-ui/src/components/Slider/slider.scss b/packages/react-ui/src/components/Slider/slider.scss index 507915102..8bd2539e7 100644 --- a/packages/react-ui/src/components/Slider/slider.scss +++ b/packages/react-ui/src/components/Slider/slider.scss @@ -1,5 +1,7 @@ @use "../../cssUtils" as cssUtils; +// ─── Base Slider ──────────────────────────────────────────────────────────── + .openui-slider { &-wrapper { display: flex; @@ -67,6 +69,7 @@ &-thumb { outline: none; + &-handle { display: block; width: 20px; @@ -104,17 +107,15 @@ position: absolute; top: -35px; left: 50%; - border-radius: cssUtils.$radius-2xs; transform: translateX(-50%); background-color: cssUtils.$foreground; color: cssUtils.$text-neutral-primary; padding: cssUtils.$space-2xs; border-radius: cssUtils.$radius-s; - box-shadow: cssUtils.$shadow-m; + box-shadow: cssUtils.$shadow-s; opacity: 0; transition: opacity 0.2s; border: 1px solid cssUtils.$border-interactive; - box-shadow: cssUtils.$shadow-s; .openui-slider-thumb-handle:hover &, .openui-slider-thumb-handle:focus & { @@ -157,3 +158,108 @@ pointer-events: none; } } + +// ─── Slider Block ─────────────────────────────────────────────────────────── + +.openui-slider-block { + width: 100%; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(cssUtils.$space-s + cssUtils.$space-xs); + + &--with-error { + align-items: flex-start; + margin-bottom: cssUtils.$space-s; + } + } + + &__label { + @include cssUtils.typography(label, default); + color: cssUtils.$text-neutral-primary; + padding-top: cssUtils.$space-xs; + } + + &__controls { + display: flex; + gap: cssUtils.$space-xs; + position: relative; + flex-direction: column; + align-items: flex-end; + + input, + button { + height: 36px; + } + + &.is-range { + .openui-slider-block__validated-input input { + width: 64px; + } + + button { + width: 80px; + } + } + + &.is-single { + .openui-slider-block__validated-input input { + width: 80px; + } + + button { + width: 100px; + } + } + } + + &__content { + display: flex; + align-items: center; + } + + &__validated-input-container { + display: flex; + gap: cssUtils.$space-xs; + align-items: center; + } + + &__validated-select-container { + display: flex; + align-items: center; + gap: cssUtils.$space-xs; + } + + &__validated-input { + position: relative; + + .openui-slider-block__input-error { + border-color: cssUtils.$border-danger-emphasis !important; + } + } + + &__input { + text-align: right !important; + padding-left: cssUtils.$space-s !important; + padding-right: cssUtils.$space-s !important; + } + + &__error-message { + display: flex; + gap: cssUtils.$space-2xs; + align-items: center; + @include cssUtils.typography(label, small); + color: cssUtils.$text-danger-primary; + white-space: nowrap; + padding-right: cssUtils.$space-2xs; + } + + &__separator { + width: 8px; + height: 2px; + background-color: cssUtils.$text-neutral-secondary; + flex-shrink: 0; + } +} diff --git a/packages/react-ui/src/components/Slider/stories/slider.stories.tsx b/packages/react-ui/src/components/Slider/stories/slider.stories.tsx index 6eacef6b8..e4e72b14f 100644 --- a/packages/react-ui/src/components/Slider/stories/slider.stories.tsx +++ b/packages/react-ui/src/components/Slider/stories/slider.stories.tsx @@ -1,28 +1,35 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { Volume1Icon, Volume2Icon } from "lucide-react"; -import { useState } from "react"; -import { Slider, SliderProps } from "../Slider"; +import { SliderBlock, SliderBlockProps } from "../SliderBlock"; -const meta: Meta = { +const meta: Meta = { title: "Components/Slider", - component: Slider, + component: SliderBlock, parameters: { layout: "centered", docs: { description: { - component: "```tsx\nimport { Slider } from '@openui-ui/react-ui';\n```", + component: + "A slider component with a label, inline value editing (text input for continuous, select dropdown for discrete), range support, and validation.\n\n```tsx\nimport { SliderBlock } from '@openui-ui/react-ui';\n```", }, }, }, tags: ["!dev", "autodocs"], argTypes: { + label: { + control: "text", + description: "Label displayed above the slider", + table: { + category: "Content", + type: { summary: "string" }, + required: true, + }, + }, variant: { - control: false, + control: "radio", options: ["continuous", "discrete"], description: - "The type of slider - continuous for smooth sliding, or discrete for stepped values. Range functionality is enabled by passing an array with multiple values to `value` or `defaultValue`.", - defaultValue: "continuous", + "Continuous shows a text input for value editing. Discrete shows a select dropdown with stepped options.", table: { category: "Appearance", type: { summary: "'continuous' | 'discrete'" }, @@ -32,99 +39,54 @@ const meta: Meta = { min: { control: "number", description: "Minimum value of the slider", - defaultValue: 0, table: { category: "Value", type: { summary: "number" }, - required: true, + defaultValue: { summary: "0" }, }, }, max: { control: "number", description: "Maximum value of the slider", - defaultValue: 100, table: { category: "Value", type: { summary: "number" }, - required: true, + defaultValue: { summary: "100" }, }, }, step: { control: "number", - description: "Step increment (required for discrete variant)", - defaultValue: 1, + description: "Step increment. For discrete variant, defaults to 1 if not provided.", table: { category: "Value", type: { summary: "number" }, }, }, - disabled: { - control: "boolean", - description: "Whether the slider is disabled", - table: { - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, - category: "State", - }, - }, - value: { - control: false, - description: - "Controlled value(s) of the slider. Single number for continuous/discrete, array of two numbers for range", - table: { - category: "Value", - type: { summary: "number[]" }, - }, - }, defaultValue: { - description: - "Default value(s) of the slider. Single number for continuous/discrete, array of two numbers for range", control: false, + description: + "Initial value(s). Pass a single-element array for single slider, two-element array for range.", table: { category: "Value", type: { summary: "number[]" }, }, }, - onValueChange: { - control: false, - description: "Callback when the value changes", + disabled: { + control: "boolean", + description: "Whether the slider is disabled", table: { - category: "Events", - type: { summary: "(value: number[]) => void" }, + category: "State", + type: { summary: "boolean" }, }, }, - className: { + name: { control: "text", - description: "Additional CSS class names", + description: "Form field name", table: { - category: "Appearance", + category: "Form", type: { summary: "string" }, }, }, - style: { - control: "object", - description: "Additional inline styles", - table: { - category: "Appearance", - type: { summary: "React.CSSProperties" }, - }, - }, - leftContent: { - control: "text", - description: "Content to display on the left side of the slider", - table: { - category: "Appearance", - type: { summary: "React.ReactNode" }, - }, - }, - rightContent: { - control: "text", - description: "Content to display on the right side of the slider", - table: { - category: "Appearance", - type: { summary: "React.ReactNode" }, - }, - }, }, decorators: [ @@ -134,56 +96,61 @@ const meta: Meta = {
), ], -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; -export const Continuous: Story = { +export const ContinuousSingle: Story = { args: { + label: "Brightness", variant: "continuous", min: 0, max: 100, - defaultValue: [25], + defaultValue: [50], }, parameters: { docs: { description: { - story: "A continuous slider allows smooth, precise value selection across its range.", + story: + "A continuous single-value slider with a text input for precise value entry. Validates that the input is within the min/max range.", }, }, }, }; -export const Discrete: Story = { +export const ContinuousRange: Story = { args: { - variant: "discrete", + label: "Price Range", + variant: "continuous", min: 0, - max: 100, - step: 10, - defaultValue: [50], + max: 1000, + defaultValue: [200, 800], }, parameters: { docs: { description: { story: - "A discrete slider shows step markers and snaps to specific values. Useful for selecting from predefined options.", + "A continuous range slider with two text inputs for min and max values. Shows validation errors for out-of-range or invalid values.", }, }, }, }; -export const Range: Story = { +export const DiscreteSingle: Story = { args: { - variant: "continuous", - min: 0, - max: 100, - defaultValue: [20, 80], + label: "Team Size", + variant: "discrete", + min: 1, + max: 10, + step: 1, + defaultValue: [5], }, parameters: { docs: { description: { - story: "A range slider allows selection of a value range using two handles.", + story: + "A discrete single-value slider with a select dropdown showing all valid step values.", }, }, }, @@ -191,81 +158,72 @@ export const Range: Story = { export const DiscreteRange: Story = { args: { + label: "Experience (years)", variant: "discrete", min: 0, - max: 100, - step: 10, - defaultValue: [20, 80], + max: 20, + step: 2, + defaultValue: [4, 14], }, parameters: { docs: { description: { story: - "A discrete range slider combines the features of a discrete slider and a range slider.", + "A discrete range slider with two select dropdowns. Each dropdown filters out values that would create an invalid range.", }, }, }, }; -export const Controlled: Story = { - render: () => { - const [value, setValue] = useState([50]); - return ( - - ); +export const CustomStep: Story = { + args: { + label: "Volume", + variant: "discrete", + min: 0, + max: 100, + step: 25, + defaultValue: [50], }, parameters: { docs: { description: { story: - "A controlled slider where the value is managed by component state via the `value` prop.", + "A discrete slider with a custom step of 25, creating options at 0, 25, 50, 75, 100.", }, }, }, - name: "Controlled (with value prop)", }; -export const UncontrolledWithoutDefault: Story = { - name: "Without Value or DefaultValue", +export const LargeRange: Story = { args: { + label: "Salary Range ($K)", variant: "continuous", min: 0, - max: 100, - step: 1, - disabled: false, + max: 500, + defaultValue: [80, 250], }, parameters: { docs: { description: { - story: - "A slider without an initial `value` or `defaultValue` prop. It defaults to the minimum value.", + story: "A continuous range slider demonstrating larger value ranges with text inputs.", }, }, }, }; -export const WithIcons: Story = { - name: "With Icons", +export const Disabled: Story = { args: { + label: "Locked Setting", variant: "continuous", min: 0, max: 100, - defaultValue: [40], - leftContent: , - rightContent: , + defaultValue: [75], + disabled: true, }, parameters: { docs: { description: { - story: "A slider can have icons or other content on either side.", + story: "A disabled slider block. The slider track and thumb are non-interactive.", }, }, }, diff --git a/packages/react-ui/src/genui-lib/Form/schema.ts b/packages/react-ui/src/genui-lib/Form/schema.ts index 59e093cc4..945c29626 100644 --- a/packages/react-ui/src/genui-lib/Form/schema.ts +++ b/packages/react-ui/src/genui-lib/Form/schema.ts @@ -4,6 +4,6 @@ import { FormControl } from "../FormControl"; export const FormSchema = z.object({ name: z.string(), - fields: z.array(FormControl.ref), buttons: Buttons.ref.optional(), + fields: z.array(FormControl.ref), }); diff --git a/packages/react-ui/src/genui-lib/Slider/index.tsx b/packages/react-ui/src/genui-lib/Slider/index.tsx index 555a17612..b9c406d4b 100644 --- a/packages/react-ui/src/genui-lib/Slider/index.tsx +++ b/packages/react-ui/src/genui-lib/Slider/index.tsx @@ -11,7 +11,7 @@ import { useSetFieldValue, } from "@openuidev/lang-react"; import React from "react"; -import { Slider as OpenUISlider } from "../../components/Slider"; +import { SliderBlock as OpenUISliderBlock } from "../../components/Slider"; import { SliderSchema } from "./schema"; export { SliderSchema } from "./schema"; @@ -19,7 +19,7 @@ export { SliderSchema } from "./schema"; export const Slider = defineComponent({ name: "Slider", props: SliderSchema, - description: "", + description: "Numeric slider input; supports continuous and discrete (stepped) variants", component: ({ props }) => { const formName = useFormName(); const getFieldValue = useGetFieldValue(); @@ -49,15 +49,17 @@ export const Slider = defineComponent({ }, [isStreaming, rules.length > 0]); const value = existingValue ?? defaultVal; + const defaultValue = value != null ? [value as number] : undefined; return ( - { setFieldValue(formName, "Slider", fieldName, vals[0], true); if (rules.length > 0) { diff --git a/packages/react-ui/src/genui-lib/Slider/schema.ts b/packages/react-ui/src/genui-lib/Slider/schema.ts index 57977daf5..2259346b1 100644 --- a/packages/react-ui/src/genui-lib/Slider/schema.ts +++ b/packages/react-ui/src/genui-lib/Slider/schema.ts @@ -1,13 +1,12 @@ import { z } from "zod"; -const validationRules = z.array(z.string()).optional(); - export const SliderSchema = z.object({ name: z.string(), + label: z.string().optional(), variant: z.enum(["continuous", "discrete"]), min: z.number(), max: z.number(), step: z.number().optional(), defaultValue: z.number().optional(), - rules: validationRules, + rules: z.array(z.string()).optional(), });