diff --git a/components/custom-range-slider/README.md b/components/custom-range-slider/README.md new file mode 100644 index 0000000..7fdd824 --- /dev/null +++ b/components/custom-range-slider/README.md @@ -0,0 +1,180 @@ +# Custom Range Slider for Retool + +A customizable range slider component with histogram visualization for Retool applications. Perfect for filtering data ranges and visualizing distributions. + +## Features + +- **Interactive Range Selection**: Drag handles to select min/max values +- **Histogram Visualization**: Display data distribution alongside the slider +- **Multiple Scale Types**: Linear, logarithmic, and square root scales +- **Flexible Data Input**: Supports multiple data formats from Retool queries +- **Custom Formatting**: Define custom value formatters with JavaScript functions +- **Fully Customizable**: Colors, labels, and step sizes +- **Negative Value Support**: Optional display of negative values in histogram +- **Click-to-Select**: Click histogram bars to quickly select ranges + +## Installation + +1. Clone this repository or navigate to your project directory + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Log in to Retool: + + ```bash + npx retool-ccl login + ``` + + > Note: You'll need an API access token with read and write scopes for Custom Component Libraries. + +4. Start development mode: + + ```bash + npm run dev + ``` + +5. Deploy your component: + + ```bash + npm run deploy + ``` + +6. Switch component versions: + > To pin your app to the component version you just published, navigate to the Custom Component settings in your Retool app and change dev to the latest version. + +## Configuration + +The component exposes the following properties in Retool: + +### Range Configuration + +| Property | Type | Default | Description | +| -------------- | ------ | ------- | ------------------------------------- | +| `min` | Number | 0 | Minimum value of the range slider | +| `max` | Number | 100 | Maximum value of the range slider | +| `defaultStart` | Number | 25 | Initial start value of selected range | +| `defaultEnd` | Number | 75 | Initial end value of selected range | +| `step` | Number | 1 | Increment step size for the slider | +| `label` | String | "Label" | Label displayed above the slider | + +### Histogram Configuration + +| Property | Type | Default | Description | +| --------------------- | ------ | ---------- | ------------------------------------------------------------------------------- | +| `distributionData` | Array | [] | Array of buckets: `[{ min, max, count }]` | +| `histogramScale` | Enum | "linear" | Scale type: "linear", "logarithmic", or "sqrt" | +| `showNegativeValues` | Boolean| false | Show negative values below x-axis when min or max is negative | + +### Styling + +| Property | Type | Default | Description | +| ------------------ | ------ | --------- | ---------------------------------------------------- | +| `primaryColor` | String | #f97316 | Main color for selected range and active elements | +| `primaryLightColor`| String | #fb923c | Lighter shade for hover states | +| `secondaryColor` | String | #d1d5db | Color for inactive/unselected elements | +| `backgroundColor` | String | #f3f4f6 | Background color for the component | +| `textColor` | String | #1f2937 | Color for text labels and values | +| `tooltip` | String | #1f2937 | Color for tooltip text | + +### Formatting + +| Property | Type | Default | Description | +| ------------------- | ------ | ------- | --------------------------------------------------------------- | +| `formatterFunction` | String | "" | JavaScript function to format values (e.g., `"v => \`$${v}\`"`) | + +### Output + +| Property | Type | Description | +| --------------- | ------ | ------------------------------------------------ | +| `selectedRange` | Object | Current selected range: `{ start: number, end: number }` | + +### Events + +| Event | Description | +| -------------- | ------------------------------------------ | +| `rangeChanged` | Triggered when the selected range changes | + +## Usage Example + +### Basic Setup + +1. Add the Custom Range Slider to your Retool app +2. Configure basic range: + - Set `min` to 0 + - Set `max` to 100 + - Set `label` to "Price Range" + +### With Histogram Data + +Connect a query that returns distribution data in any of these formats: + +**Format 1: Array of objects** +```json +[ + { "min": 0, "max": 10, "count": 5 }, + { "min": 10, "max": 20, "count": 8 }, + { "min": 20, "max": 30, "count": 3 } +] +``` + +**Format 2: Object of arrays** +```json +{ + "bucket_index": [0, 1, 2], + "bucket_min": [0, 10, 20], + "bucket_max": [10, 20, 30], + "count": [5, 8, 3] +} +``` + +Set `distributionData` to `{{ query1.dataArray }}` + +### Custom Formatting + +Add a custom formatter to display values as currency: +- Set `formatterFunction` to `"v => \`$${v.toFixed(2)}\`"` + +Or format as dates using moment: +- Set `formatterFunction` to `"v => moment.unix(v).format('MMM DD')"` + +### Using the Selected Range + +Access the selected range in other components or queries: +- `{{ customRangeSlider1.selectedRange.start }}` +- `{{ customRangeSlider1.selectedRange.end }}` + +Use the `rangeChanged` event to trigger queries when the range changes. + +## Development + +### Prerequisites + +- Node.js >= 20.0.0 +- Retool developer account + +### Local Development + +1. Run `npm install` to install dependencies +2. Make changes to components in the `src` directory +3. Run `npm run dev` to test your changes +4. Run `npm run deploy` to deploy to Retool + +### Project Structure + +- `src/index.tsx` - Main entry point +- `src/CustomRangeSlider.tsx` - Main component +- `src/components/` - Reusable sub-components (Histogram, RangeSlider) +- `src/utils/` - Utility functions (data transformers, formatters) +- `src/types/` - TypeScript type definitions + +## License + +This project is licensed under the MIT License. + +## About + +Created by [Stackdrop](https://stackdrop.co) diff --git a/components/custom-range-slider/cover.jpg b/components/custom-range-slider/cover.jpg new file mode 100644 index 0000000..8a53407 Binary files /dev/null and b/components/custom-range-slider/cover.jpg differ diff --git a/components/custom-range-slider/metadata.json b/components/custom-range-slider/metadata.json new file mode 100644 index 0000000..2025373 --- /dev/null +++ b/components/custom-range-slider/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "custom-range-slider", + "title": "Custom Range Slider", + "author": "@vagkap", + "shortDescription": "A custom range slider component for Retool.", + "tags": ["Charts", "UI Components", "React", "Custom"] +} diff --git a/components/custom-range-slider/package.json b/components/custom-range-slider/package.json new file mode 100644 index 0000000..610c536 --- /dev/null +++ b/components/custom-range-slider/package.json @@ -0,0 +1,46 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "moment": "^2.30.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3" + }, + "retoolCustomComponentLibraryConfig": { + "name": "Librarium", + "label": "Librarium", + "description": "This is a goooood library", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/custom-range-slider/src/CustomRangeSlider.tsx b/components/custom-range-slider/src/CustomRangeSlider.tsx new file mode 100644 index 0000000..dcddfc6 --- /dev/null +++ b/components/custom-range-slider/src/CustomRangeSlider.tsx @@ -0,0 +1,376 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import { Histogram } from './components/Histogram' +import { RangeSlider } from './components/RangeSlider' +import { createFormatter, DEFAULT_COLORS } from './utils/formatter' +import type { Bucket, ColorConfig } from './types' +import { + transformArrayWrappedObjectOfArrays, + transformObjectOfArrays +} from './utils/dataTransformers' + +export const CustomRangeSlider: React.FC = () => { + // Retool state hooks with proper TypeScript API configuration + const [min] = Retool.useStateNumber({ + name: 'min', + initialValue: 0, + label: 'Minimum Value', + description: 'The minimum value of the range slider', + inspector: 'text' + }) + + const [max] = Retool.useStateNumber({ + name: 'max', + initialValue: 100, + label: 'Maximum Value', + description: 'The maximum value of the range slider', + inspector: 'text' + }) + + const [defaultStart] = Retool.useStateNumber({ + name: 'defaultStart', + initialValue: 25, + label: 'Default Start', + description: 'The initial start value of the selected range', + inspector: 'text' + }) + + const [defaultEnd] = Retool.useStateNumber({ + name: 'defaultEnd', + initialValue: 75, + label: 'Default End', + description: 'The initial end value of the selected range', + inspector: 'text' + }) + + const [step] = Retool.useStateNumber({ + name: 'step', + initialValue: 1, + label: 'Step', + description: 'The increment step size for the slider', + inspector: 'text' + }) + + const [label] = Retool.useStateString({ + name: 'label', + initialValue: 'Label', + label: 'Label', + description: 'The label displayed above the slider', + inspector: 'text' + }) + + const [distributionData] = Retool.useStateArray({ + name: 'distributionData', + initialValue: [], + label: 'Distribution Data', + description: + 'Array of buckets for histogram: [{ min, max, count }]. Example: [{ min: 0, max: 10, count: 5 }]', + inspector: 'text' + }) + + const [formatterFunction] = Retool.useStateString({ + name: 'formatterFunction', + initialValue: '', + label: 'Formatter Function', + description: + 'JavaScript function to format values (e.g., "v => `$${v}`" or "v => v.toFixed(2)")', + inspector: 'text' + }) + + // Individual color properties with proper TypeScript API configuration + const [primaryColor] = Retool.useStateString({ + name: 'primaryColor', + initialValue: '#f97316', + label: 'Primary Color', + description: 'Main color for the selected range and active elements', + inspector: 'text' + }) + + const [primaryLightColor] = Retool.useStateString({ + name: 'primaryLightColor', + initialValue: '#fb923c', + label: 'Primary Light Color', + description: 'Lighter shade of primary color used for hover states', + inspector: 'text' + }) + + const [secondaryColor] = Retool.useStateString({ + name: 'secondaryColor', + initialValue: '#d1d5db', + label: 'Secondary Color', + description: 'Color for inactive/unselected elements', + inspector: 'text' + }) + + const [backgroundColor] = Retool.useStateString({ + name: 'backgroundColor', + initialValue: '#f3f4f6', + label: 'Background Color', + description: 'Background color for the component', + inspector: 'text' + }) + + const [textColor] = Retool.useStateString({ + name: 'textColor', + initialValue: '#1f2937', + label: 'Text Color', + description: 'Color for text labels and values', + inspector: 'text' + }) + + const [tooltip] = Retool.useStateString({ + name: 'tooltip', + initialValue: '#1f2937', + label: 'Tooltip Color', + description: 'Color for tooltip text labels and values', + inspector: 'text' + }) + + const [histogramScale] = Retool.useStateEnumeration({ + name: 'histogramScale', + initialValue: 'linear', + label: 'Histogram Scale', + description: + 'Scale type for histogram bars: linear (proportional), logarithmic (log10, better for wide value ranges), or square root', + inspector: 'segmented', + enumDefinition: ['linear', 'logarithmic', 'sqrt'] + }) + + const [showNegativeValues] = Retool.useStateBoolean({ + name: 'showNegativeValues', + initialValue: false, + label: 'Show Negative Values in Histogram', + description: + 'When enabled, raises the histogram baseline to show negative values below the x-axis. Only applicable when min or max is negative.', + inspector: 'checkbox' + }) + + // Output state that's hidden from inspector but accessible via model + const [_selectedRange, setSelectedRange] = Retool.useStateObject({ + name: 'selectedRange', + inspector: 'hidden', + initialValue: { start: 25, end: 75 } + }) + + // Component settings for responsive sizing + Retool.useComponentSettings({ + defaultHeight: 16, + defaultWidth: 3 + }) + + // Event callback for range changes + const onRangeChanged = Retool.useEventCallback({ name: 'rangeChanged' }) + + // Local state for slider values + const [start, setStart] = useState(defaultStart) + const [end, setEnd] = useState(defaultEnd) + + // Initialize slider values when defaults change + useEffect(() => { + if (defaultStart !== undefined && defaultStart !== null) { + setStart(defaultStart) + } + }, [defaultStart]) + + useEffect(() => { + if (defaultEnd !== undefined && defaultEnd !== null) { + setEnd(defaultEnd) + } + }, [defaultEnd]) + + // Memoize the formatter function + const formatValue = useMemo(() => { + return createFormatter(formatterFunction || '') + }, [formatterFunction]) + + // Build color configuration from individual color properties + const themeColors: ColorConfig = useMemo(() => { + return { + primary: primaryColor || DEFAULT_COLORS.primary, + primaryLight: primaryLightColor || DEFAULT_COLORS.primaryLight, + secondary: secondaryColor || DEFAULT_COLORS.secondary, + background: backgroundColor || DEFAULT_COLORS.background, + text: textColor || DEFAULT_COLORS.text, + tooltip: tooltip || DEFAULT_COLORS.tooltip + } + }, [ + primaryColor, + primaryLightColor, + secondaryColor, + backgroundColor, + textColor, + tooltip + ]) + + // Handle range changes + const handleRangeChange = useCallback( + (newStart: number, newEnd: number) => { + setStart(newStart) + setEnd(newEnd) + + // Update Retool state + setSelectedRange({ start: newStart, end: newEnd }) + + // Trigger event callback + onRangeChanged() + }, + [setSelectedRange, onRangeChanged] + ) + + // Handle bar selection from histogram + const handleBarSelect = useCallback( + (bucketMin: number, bucketMax: number) => { + const clampedStart = Math.max(min, bucketMin) + const clampedEnd = Math.min(max, bucketMax) + handleRangeChange(clampedStart, clampedEnd) + }, + [min, max, handleRangeChange] + ) + + // Use query data if available, otherwise fall back to distributionData + const validData = useMemo(() => { + if (!distributionData) { + return [] + } + + // Case 1: Single object with array properties + // { bucket_index: [0,1,2], bucket_min: [...], bucket_max: [...], count: [...] } + if ( + !Array.isArray(distributionData) && + typeof distributionData === 'object' + ) { + return transformObjectOfArrays( + distributionData as Record + ) + } + + // Case 2: Empty array + if (!Array.isArray(distributionData) || distributionData.length === 0) { + return [] + } + + const firstItem = distributionData[0] + + if (!firstItem || typeof firstItem !== 'object') { + return [] + } + + // Case 3: Array wrapping object-of-arrays + // [{ bucket_index: [0,1,2], bucket_min: [...], bucket_max: [...], count: [...] }] + if ( + 'bucket_index' in firstItem && + Array.isArray((firstItem as Record).bucket_index) + ) { + return transformArrayWrappedObjectOfArrays(distributionData) + } + + // Case 4: Array of bucket objects + // [{ bucket_index: 0, bucket_min: 0, bucket_max: 10, count: 5 }, ...] + if ( + 'bucket_min' in firstItem && + 'bucket_max' in firstItem && + 'count' in firstItem + ) { + return distributionData as unknown as Bucket[] + } + + return [] + }, [distributionData]) + + return ( +
+ {/* First row: Label and value indicators */} + {label && ( +
+
+ {label} +
+
+ {formatValue(start)} - {formatValue(end)} +
+
+ )} + + {/* Second row: Histogram */} + {validData.length > 0 && ( +
+ +
+ )} + + {/* Third row: Range Slider */} +
+ +
+
+ ) +} diff --git a/components/custom-range-slider/src/components/Histogram.tsx b/components/custom-range-slider/src/components/Histogram.tsx new file mode 100644 index 0000000..182f7f1 --- /dev/null +++ b/components/custom-range-slider/src/components/Histogram.tsx @@ -0,0 +1,323 @@ +import React, { useState, useMemo } from 'react' +import type { Bucket, ColorConfig } from '../types' + +interface HistogramProps { + data: Bucket[] + selectedStart: number + selectedEnd: number + colors: ColorConfig + onBarSelect?: (bucketMin: number, bucketMax: number) => void + formatValue: (value: number) => string + scale?: 'linear' | 'logarithmic' | 'sqrt' + showNegativeValues?: boolean + min: number + max: number +} + +export const Histogram: React.FC = ({ + data, + selectedStart, + selectedEnd, + colors, + onBarSelect, + formatValue, + scale = 'linear', + showNegativeValues = false, + min, + max +}) => { + const [hoveredBar, setHoveredBar] = useState(null) + const [selectionStart, setSelectionStart] = useState(null) + const [isDragging, setIsDragging] = useState(false) + + const maxCount = useMemo( + () => Math.max(...data.map((b) => b.count), 1), + [data] + ) + + // Calculate where zero falls in the range (as a percentage from bottom) + const zeroLinePosition = useMemo(() => { + if (!showNegativeValues || min >= 0) return 0 + if (max <= 0) return 100 + // Zero position as percentage from bottom + return (Math.abs(min) / (max - min)) * 100 + }, [showNegativeValues, min, max]) + + // Determine if a bucket represents primarily negative or positive values + const getBucketValence = (bucket: Bucket): 'negative' | 'positive' | 'mixed' => { + if (bucket.bucket_max <= 0) return 'negative' + if (bucket.bucket_min >= 0) return 'positive' + return 'mixed' + } + + const calculateScaledHeight = (count: number, maxCount: number): number => { + if (count === 0) return 0 + + switch (scale) { + case 'logarithmic': + // Add 1 to avoid log(0), then normalize + const logValue = Math.log10(count + 1) + const logMax = Math.log10(maxCount + 1) + return (logValue / logMax) * 100 + + case 'sqrt': + // Square root scale + const sqrtValue = Math.sqrt(count) + const sqrtMax = Math.sqrt(maxCount) + return (sqrtValue / sqrtMax) * 100 + + case 'linear': + default: + return (count / maxCount) * 100 + } + } + + const getTooltipPosition = (barIndex: number, barHeight: number) => { + const totalBars = data.length + const horizontalPosition = ((barIndex + 0.5) / totalBars) * 100 + + // Determine if tooltip should be on left, center, or right + let leftPosition = `${horizontalPosition}%` + let transform = 'translateX(-50%)' + + // If on the left edge (first ~20% of bars), align left + if (barIndex < totalBars * 0.2) { + leftPosition = `${(barIndex / totalBars) * 100}%` + transform = 'translateX(0)' + } + // If on the right edge (last ~20% of bars), align right + else if (barIndex > totalBars * 0.8) { + leftPosition = `${((barIndex + 1) / totalBars) * 100}%` + transform = 'translateX(-100%)' + } + + // Determine vertical position + // If bar is very tall (> 80% height), show tooltip below it + const showBelow = barHeight > 80 + + return { leftPosition, transform, showBelow } + } + + const getBarOpacity = (bucket: Bucket): number => { + const bucketCenter = (bucket.bucket_min + bucket.bucket_max) / 2 + if (bucketCenter >= selectedStart && bucketCenter <= selectedEnd) { + return 1 + } + return 0.3 + } + + const handleBarMouseDown = (bucket: Bucket) => { + setSelectionStart(bucket.bucket_index) + setIsDragging(true) + } + + const handleBarMouseEnter = (bucket: Bucket) => { + setHoveredBar(bucket.bucket_index) + if (isDragging && selectionStart !== null && onBarSelect) { + const startIdx = Math.min(selectionStart, bucket.bucket_index) + const endIdx = Math.max(selectionStart, bucket.bucket_index) + const startBucket = data[startIdx] + const endBucket = data[endIdx] + if (startBucket && endBucket) { + onBarSelect(startBucket.bucket_min, endBucket.bucket_max) + } + } + } + + const handleBarMouseUp = (bucket: Bucket) => { + if (selectionStart !== null && onBarSelect) { + const startIdx = Math.min(selectionStart, bucket.bucket_index) + const endIdx = Math.max(selectionStart, bucket.bucket_index) + const startBucket = data[startIdx] + const endBucket = data[endIdx] + if (startBucket && endBucket) { + onBarSelect(startBucket.bucket_min, endBucket.bucket_max) + } + } + setIsDragging(false) + setSelectionStart(null) + } + + const handleMouseLeave = () => { + setHoveredBar(null) + if (isDragging) { + setIsDragging(false) + setSelectionStart(null) + } + } + + return ( +
{ + setIsDragging(false) + setSelectionStart(null) + }} + > + {/* Bars container */} +
+ {data.map((bucket) => { + const height = calculateScaledHeight(bucket.count, maxCount) + const opacity = getBarOpacity(bucket) + const valence = getBucketValence(bucket) + const isNegative = showNegativeValues && valence === 'negative' + + // Calculate bar position from the zero line + let barStyles: React.CSSProperties = {} + + if (showNegativeValues) { + // Calculate available space for negative and positive bars + const availableNegativeSpace = zeroLinePosition + const availablePositiveSpace = 100 - zeroLinePosition + + // Position bars relative to the zero line + if (isNegative) { + // Negative bars grow downward from zero line + // Scale height to fit within available negative space + const scaledHeight = (height / 100) * availableNegativeSpace + barStyles = { + position: 'absolute', + bottom: `${zeroLinePosition - scaledHeight}%`, + height: `${scaledHeight}%` + } + } else { + // Positive bars grow upward from zero line + // Scale height to fit within available positive space + const scaledHeight = (height / 100) * availablePositiveSpace + barStyles = { + position: 'absolute', + bottom: `${zeroLinePosition}%`, + height: `${scaledHeight}%` + } + } + } else { + // Standard mode - all bars grow from bottom + barStyles = { + position: 'absolute', + bottom: 0, + height: `${height}%` + } + } + + return ( +
+
handleBarMouseEnter(bucket)} + onMouseDown={() => handleBarMouseDown(bucket)} + onMouseUp={() => handleBarMouseUp(bucket)} + /> +
+ ) + })} + + {/* Zero line indicator */} + {showNegativeValues && zeroLinePosition > 0 && zeroLinePosition < 100 && ( +
+ )} +
+ + {/* Tooltip rendered outside bars */} + {hoveredBar !== null && data[hoveredBar] && (() => { + const barHeight = calculateScaledHeight(data[hoveredBar].count, maxCount) + const { leftPosition, transform, showBelow } = getTooltipPosition( + hoveredBar, + barHeight + ) + + return ( +
+
+ Count: {data[hoveredBar].count} +
+
+ {formatValue(data[hoveredBar].bucket_min)} -{' '} + {formatValue(data[hoveredBar].bucket_max)} +
+
+ ) + })()} +
+ ) +} diff --git a/components/custom-range-slider/src/components/RangeSlider.tsx b/components/custom-range-slider/src/components/RangeSlider.tsx new file mode 100644 index 0000000..a38b137 --- /dev/null +++ b/components/custom-range-slider/src/components/RangeSlider.tsx @@ -0,0 +1,225 @@ +import React, { useRef, useState, useCallback, useEffect } from 'react' +import type { ColorConfig } from '../types' + +interface RangeSliderProps { + min: number + max: number + step: number + start: number + end: number + colors: ColorConfig + onChange: (start: number, end: number) => void + formatValue: (value: number) => string + label?: string +} + +export const RangeSlider: React.FC = ({ + min, + max, + step, + start, + end, + colors, + onChange, + formatValue, + label +}) => { + const sliderRef = useRef(null) + const [activeHandle, setActiveHandle] = useState<'start' | 'end' | null>(null) + const [hoveredHandle, setHoveredHandle] = useState<'start' | 'end' | null>( + null + ) + + const valueToPercent = useCallback( + (value: number): number => ((value - min) / (max - min)) * 100, + [min, max] + ) + + const percentToValue = useCallback( + (percent: number): number => { + const rawValue = min + (percent / 100) * (max - min) + return Math.round(rawValue / step) * step + }, + [min, max, step] + ) + + const handleMouseDown = useCallback((handle: 'start' | 'end') => { + setActiveHandle(handle) + }, []) + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!activeHandle || !sliderRef.current) return + + const rect = sliderRef.current.getBoundingClientRect() + const percent = Math.max( + 0, + Math.min(100, ((e.clientX - rect.left) / rect.width) * 100) + ) + const newValue = percentToValue(percent) + + if (activeHandle === 'start') { + const clampedValue = Math.min(newValue, end - step) + onChange(Math.max(min, clampedValue), end) + } else { + const clampedValue = Math.max(newValue, start + step) + onChange(start, Math.min(max, clampedValue)) + } + }, + [activeHandle, start, end, min, max, step, onChange, percentToValue] + ) + + const handleMouseUp = useCallback(() => { + setActiveHandle(null) + }, []) + + useEffect(() => { + if (activeHandle) { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + } + }, [activeHandle, handleMouseMove, handleMouseUp]) + + const startPercent = valueToPercent(start) + const endPercent = valueToPercent(end) + + return ( +
+ {label && ( +
+
+ {label} +
+
+
+ {formatValue(start)} +
+
+ {formatValue(end)} +
+
+
+ )} + +
+
+ + {['start', 'end'].map((handle) => { + const isStart = handle === 'start' + const isActive = activeHandle === handle + const isHovered = hoveredHandle === handle + const percent = isStart ? startPercent : endPercent + + return ( +
handleMouseDown(handle as 'start' | 'end')} + onMouseEnter={() => setHoveredHandle(handle as 'start' | 'end')} + onMouseLeave={() => setHoveredHandle(null)} + /> + ) + })} +
+
+ ) +} diff --git a/components/custom-range-slider/src/index.tsx b/components/custom-range-slider/src/index.tsx new file mode 100644 index 0000000..51ee128 --- /dev/null +++ b/components/custom-range-slider/src/index.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { type FC } from 'react' + +import { Retool } from '@tryretool/custom-component-support' + +export const HelloWorld: FC = () => { + const [name, _setName] = Retool.useStateString({ + name: 'name' + }) + + return ( +
+
Hello {name}!
+
+ ) +} + +export { CustomRangeSlider } from './CustomRangeSlider' diff --git a/components/custom-range-slider/src/types/index.ts b/components/custom-range-slider/src/types/index.ts new file mode 100644 index 0000000..63e0c4c --- /dev/null +++ b/components/custom-range-slider/src/types/index.ts @@ -0,0 +1,32 @@ +export interface Bucket { + bucket_index: number + bucket_min: number + bucket_max: number + count: number +} + +export interface ColorConfig { + primary: string + primaryLight: string + secondary: string + background: string + text: string + tooltip: string +} + +export interface RangeSliderModel { + min: number + max: number + defaultStart: number + defaultEnd: number + step: number + label: string + distributionData: Bucket[] + formatterFunction: string + colors: ColorConfig +} + +export interface RangeOutput { + start: number + end: number +} diff --git a/components/custom-range-slider/src/utils/dataTransformers.ts b/components/custom-range-slider/src/utils/dataTransformers.ts new file mode 100644 index 0000000..6cca190 --- /dev/null +++ b/components/custom-range-slider/src/utils/dataTransformers.ts @@ -0,0 +1,64 @@ +import type { Bucket } from '../types' + +/** + * Transforms object-of-arrays format to Bucket array + * Input: { bucket_index: [0,1,2], bucket_min: [0,10,20], bucket_max: [10,20,30], count: [5,3,8] } + * Output: [{ bucket_index: 0, bucket_min: 0, bucket_max: 10, count: 5 }, ...] + */ +export const transformObjectOfArrays = ( + data: Record +): Bucket[] => { + if ( + !Array.isArray(data.bucket_index) || + !Array.isArray(data.bucket_min) || + !Array.isArray(data.bucket_max) || + !Array.isArray(data.count) + ) { + return [] + } + + const bucketIndex = data.bucket_index as (number | string)[] + const bucketMin = data.bucket_min as (number | string)[] + const bucketMax = data.bucket_max as (number | string)[] + const count = data.count as (number | string)[] + const length = bucketIndex.length + + return Array.from({ length }, (_, i) => ({ + bucket_index: Number(bucketIndex[i]), + bucket_min: Number(bucketMin[i]), + bucket_max: Number(bucketMax[i]), + count: Number(count[i]) + })) +} + +/** + * Transforms array containing object-of-arrays format to Bucket array + * Input: [{ bucket_index: [0,1,2], bucket_min: [0,10,20], bucket_max: [10,20,30], count: [5,3,8] }] + * Output: [{ bucket_index: 0, bucket_min: 0, bucket_max: 10, count: 5 }, ...] + */ +export const transformArrayWrappedObjectOfArrays = ( + data: unknown[] +): Bucket[] => { + if (data.length === 0) { + return [] + } + + const firstItem = data[0] + + if (!firstItem || typeof firstItem !== 'object') { + return [] + } + + const item = firstItem as Record + + if ( + Array.isArray(item.bucket_index) && + Array.isArray(item.bucket_min) && + Array.isArray(item.bucket_max) && + Array.isArray(item.count) + ) { + return transformObjectOfArrays(item) + } + + return [] +} diff --git a/components/custom-range-slider/src/utils/formatter.ts b/components/custom-range-slider/src/utils/formatter.ts new file mode 100644 index 0000000..01216a0 --- /dev/null +++ b/components/custom-range-slider/src/utils/formatter.ts @@ -0,0 +1,67 @@ +// Declare global window interface extension for moment +declare global { + interface Window { + moment?: typeof import('moment') + } +} + +/** + * Creates a value formatter function from a string representation + * Supports access to the moment library if available in Retool context + * Falls back to numeric display if formatter fails + */ +export const createFormatter = ( + formatterString: string +): ((value: number) => string) => { + if (!formatterString || formatterString.trim() === '') { + return (value: number) => value.toString() + } + + try { + // Check if moment is available in the global scope (Retool environment) + const momentAvailable = typeof window.moment !== 'undefined' + + // Create the formatter function with access to moment if available + const formatterFunc = new Function( + 'value', + 'moment', + ` + try { + const formatter = ${formatterString}; + return formatter(value); + } catch (error) { + console.warn('Formatter function error:', error); + return value.toString(); + } + ` + ) + + return (value: number) => { + try { + const moment = momentAvailable ? window.moment : undefined + const result = formatterFunc(value, moment) + return result !== undefined && result !== null + ? String(result) + : value.toString() + } catch (error) { + console.warn('Error executing formatter:', error) + return value.toString() + } + } + } catch (error) { + console.error('Error creating formatter function:', error) + return (value: number) => value.toString() + } +} + +/** + * Default color theme + */ +export const DEFAULT_COLORS = { + primary: '#f97316', + primaryLight: '#fb923c', + secondary: '#d1d5db', + background: '#f3f4f6', + text: '#1f2937', + tooltip: '#1f2937' +} diff --git a/components/custom-range-slider/tsconfig.json b/components/custom-range-slider/tsconfig.json new file mode 100644 index 0000000..55be51b --- /dev/null +++ b/components/custom-range-slider/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["**/*.tsx", "**/*.ts"] +}