diff --git a/components/custom-radio-group/README.md b/components/custom-radio-group/README.md new file mode 100644 index 0000000..8cffa1c --- /dev/null +++ b/components/custom-radio-group/README.md @@ -0,0 +1,320 @@ +# RadioGroup Component for Retool + +A powerful, feature-rich radio/checkbox group component for Retool applications. Build beautiful selection interfaces with single or multiple choice, icons, badges, conditional display, and flexible layouts. + +## Features + +### Core Functionality +- **Single & Multiple Selection** - Switch between radio (single) and checkbox (multiple) behavior +- **Rich Content** - Support for titles, descriptions, and HTML rendering +- **Flexible Layouts** - Vertical, horizontal, grid, or justified arrangements +- **Button Positioning** - Place buttons on left, right, top, or bottom + +### Visual Customization +- **Multiple Button Shapes** - Bullet (circle), square, rounded square, or diamond +- **Icon Support** - Add emojis or custom icons with left/right positioning +- **Badges** - Highlight options with customizable badges +- **Typography Control** - Customize font sizes and text alignment +- **Full Color Customization** - Control all colors including primary, borders, backgrounds, and hover states + +### Advanced Features +- **Option Groups** - Organize options with section headers +- **Conditional Display** - Show/hide options based on JavaScript expressions +- **Smart Tooltips** - Automatic tooltips for truncated text +- **Line Clamp** - Truncate long descriptions with ellipsis +- **HTML Rendering** - Render rich formatted content +- **Keyboard Accessible** - Full keyboard navigation support +- **Disabled State** - Individual option-level disabling + +## Installation + +This component is part of the Librarium custom component library for Retool. + +### Prerequisites +- Node.js >= 20.0.0 +- Retool account with custom component support +- `retool-ccl` CLI tool + +### Setup + +1. **Install dependencies:** +```bash +npm install +``` + +2. **Login to Retool:** +```bash +npx retool-ccl login +``` + +3. **Deploy to Retool:** +```bash +npm run deploy +``` + +For development with live reload: +```bash +npm run dev +``` + +## Quick Start + +### Basic Radio Group + +```json +{ + "options": [ + { + "id": "option1", + "title": "Option 1", + "description": "This is the first option" + }, + { + "id": "option2", + "title": "Option 2", + "description": "This is the second option" + }, + { + "id": "option3", + "title": "Option 3", + "description": "This is the third option" + } + ], + "defaultValue": "option1" +} +``` + +### Yes/No Decision + +```json +{ + "options": [ + { "id": "yes", "title": "Yes", "icon": "✓" }, + { "id": "no", "title": "No", "icon": "✗" } + ], + "layout": "horizontal", + "buttonPosition": "top", + "titleTextAlign": "center" +} +``` + +### Multiple Selection with Icons + +```json +{ + "options": [ + { + "id": "email", + "title": "Email Notifications", + "icon": "📧", + "badge": "Recommended", + "badgeColor": "#10b981" + }, + { + "id": "sms", + "title": "SMS Alerts", + "icon": "📱" + }, + { + "id": "push", + "title": "Push Notifications", + "icon": "🔔", + "badge": "New", + "badgeColor": "#3b82f6" + } + ], + "multipleSelect": true, + "defaultValues": ["email"] +} +``` + +### Grid Layout with Groups + +```json +{ + "options": [ + { "type": "header", "title": "Basic Features" }, + { "id": "feature1", "title": "Feature 1", "description": "Description 1" }, + { "id": "feature2", "title": "Feature 2", "description": "Description 2" }, + { "type": "header", "title": "Premium Features" }, + { "id": "feature3", "title": "Feature 3", "description": "Description 3", "badge": "+$10/mo" }, + { "id": "feature4", "title": "Feature 4", "description": "Description 4", "badge": "+$20/mo" } + ], + "layout": "grid", + "gridColumns": 2 +} +``` + +## Configuration + +### Core Properties + +| Property | Type | Default | Description | +|------------------|---------|------------|-------------------------------------------------------| +| `options` | Array | [] | Array of option objects or group headers | +| `multipleSelect` | Boolean | false | Enable multiple selection (checkbox mode) | +| `defaultValue` | String | "" | Default selected option ID (single select) | +| `defaultValues` | Array | [] | Default selected option IDs (multiple select) | + +### Layout & Positioning + +| Property | Type | Default | Description | +|------------------|--------|------------|-------------------------------------------------------| +| `layout` | Enum | "vertical" | Layout mode: vertical, horizontal, grid, justified | +| `gridColumns` | Number | 2 | Number of columns for grid layout | +| `buttonPosition` | Enum | "left" | Button position: left, right, top, bottom | + +### Button Styling + +| Property | Type | Default | Description | +|---------------|--------|----------|-------------------------------------------------------| +| `buttonShape` | Enum | "bullet" | Shape: bullet, square, rounded-square, diamond | +| `buttonSize` | Number | 24 | Button size in pixels | + +### Typography + +| Property | Type | Default | Description | +|-------------------------|--------|---------|------------------------------------------------| +| `titleFontSize` | Number | 16 | Title font size in pixels | +| `descriptionFontSize` | Number | 14 | Description font size in pixels | +| `titleTextAlign` | Enum | "left" | Title alignment: left, center, right, justify | +| `descriptionTextAlign` | Enum | "left" | Description alignment | +| `lineClamp` | Number | 0 | Max lines for descriptions (0 = unlimited) | + +### Colors + +| Property | Type | Default | Description | +|--------------------|--------|----------|------------------------------------| +| `primary` | String | #f97316 | Primary color for selected state | +| `primaryLight` | String | #fb923c | Lighter shade for hover | +| `background` | String | #ffffff | Background color | +| `borderColor` | String | #d1d5db | Border and unselected button color | +| `titleColor` | String | #1f2937 | Title text color | +| `descriptionColor` | String | #6b7280 | Description text color | +| `disabledColor` | String | #9ca3af | Disabled option color | +| `hoverColor` | String | #fee2e2 | Hover background color | + +### Output States + +| Property | Type | Description | +|------------------|--------|------------------------------------------------| +| `selectedValue` | String | Currently selected option ID (single select) | +| `selectedValues` | Array | Currently selected option IDs (multiple select)| + +### Events + +| Event | Description | +|-------------------|--------------------------------------| +| `selectionChange` | Triggered when selection changes | + +## Option Structure + +### Standard Option + +```typescript +{ + id: string // Required: Unique identifier + title: string // Required: Option title + description?: string // Optional: Description text + disabled?: boolean // Optional: Disable this option + renderAsHtml?: boolean // Optional: Render as HTML + icon?: string // Optional: Icon/emoji + iconPosition?: 'left' | 'right' // Optional: Icon position + badge?: string // Optional: Badge text + badgeColor?: string // Optional: Badge color + showIf?: string // Optional: Conditional display expression +} +``` + +### Group Header + +```typescript +{ + type: 'header' // Required: Identifies as header + title: string // Required: Header text +} +``` + +## Use Cases + +### Single Select +- Plan selection (pricing tiers) +- Preference settings +- Survey questions +- Filter selection +- Shipping method selection +- Payment method selection + +### Multiple Select +- Feature selection (add-ons) +- User permissions +- Category assignment +- Tag selection +- Notification preferences +- Filter combinations + +## Browser Support + +- Chrome (latest) +- Firefox (latest) +- Safari (latest) +- Edge (latest) + +**Note**: The diamond button shape uses CSS `clip-path` which is well-supported in modern browsers. + +## Performance + +- Optimized for up to 100 options +- Conditional rendering with `showIf` expressions +- Smart tooltip detection (only shows when text is truncated) +- Efficient re-renders with React hooks + +## Accessibility + +- Full keyboard navigation (Tab, Enter, Space) +- ARIA labels for screen readers +- Disabled state properly announced +- High contrast support +- Focus indicators + +## Development + +```bash +# Install dependencies +npm install + +# Start development server with live reload +npm run dev + +# Build for production +npm run deploy + +# Run linter +npx eslint src/ + +# Format code +npx prettier --write . +``` + +## Tech Stack + +- **React** - UI framework +- **TypeScript** - Type safety +- **@tryretool/custom-component-support** - Retool SDK +- **CSS-in-JS** - Inline styles with TypeScript + +### Latest Features +- **Button Position Control** - Position buttons on all four sides +- **Layout Modes** - Vertical, horizontal, grid, and justified layouts +- **Grid Layout** - Configurable column count +- **Icon Support** - Add icons with flexible positioning +- **Badges** - Highlight special options +- **Option Groups** - Organize with headers +- **Conditional Display** - Show/hide based on expressions +- **Smart Tooltips** - Auto-show for truncated text + +--- + +## License + +Created by [Stackdrop](https://stackdrop.co) diff --git a/components/custom-radio-group/cover.png b/components/custom-radio-group/cover.png new file mode 100644 index 0000000..a6e6caa Binary files /dev/null and b/components/custom-radio-group/cover.png differ diff --git a/components/custom-radio-group/metadata.json b/components/custom-radio-group/metadata.json new file mode 100644 index 0000000..d1d6a4b --- /dev/null +++ b/components/custom-radio-group/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "custom-radio-group", + "title": "Custom Radio Group", + "author": "@vagkap", + "shortDescription": "A custom radio group component for Retool.", + "tags": ["UI Components", "React", "Custom"] +} diff --git a/components/custom-radio-group/package.json b/components/custom-radio-group/package.json new file mode 100644 index 0000000..742d25f --- /dev/null +++ b/components/custom-radio-group/package.json @@ -0,0 +1,45 @@ +{ + "name": "custom-radio-group-retool-component", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "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": "CustomRadioGroup", + "label": "Custom Radio Group", + "description": "A customizable radio group component for Retool.", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/custom-radio-group/src/RadioGroup.tsx b/components/custom-radio-group/src/RadioGroup.tsx new file mode 100644 index 0000000..d12196d --- /dev/null +++ b/components/custom-radio-group/src/RadioGroup.tsx @@ -0,0 +1,569 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import { RadioOption } from './components/RadioOption' +import { GroupHeader } from './components/GroupHeader' +import { DEFAULT_COLORS } from './utils/theme' +import { isGroupHeader, isValidRadioOption, evaluateShowIf } from './utils/typeGuards' +import type { + RadioOption as RadioOptionType, + GroupHeader as GroupHeaderType, + ColorConfig, + ButtonShape, + ButtonPosition, + LayoutMode +} from './types' + +export const RadioGroup: React.FC = () => { + // ======================================== + // SECTION 1: Options Configuration + // ======================================== + const [options] = Retool.useStateArray({ + name: 'options', + initialValue: [ + { + id: '1', + title: 'Option 1', + description: 'This is the first option', + disabled: false + }, + { + id: '2', + title: 'Option 2', + description: 'This is the second option', + disabled: false + }, + { + id: '3', + title: 'Option 3', + description: 'This is the third option', + disabled: false + } + ], + label: 'Options', + description: + 'Array of radio options: [{ id: "1", title: "Title", description: "Description", disabled: false, renderAsHtml: false }]', + inspector: 'text' + }) + + const [multipleSelect] = Retool.useStateBoolean({ + name: 'multipleSelect', + initialValue: false, + label: 'Multiple Select', + description: 'Allow selecting multiple options (transforms radio group to checkbox group)', + inspector: 'checkbox' + }) + + const [defaultValue] = Retool.useStateString({ + name: 'defaultValue', + initialValue: '', + label: 'Default Value (Single)', + description: 'ID of the option to select by default in single select mode (e.g., "1")', + inspector: 'text' + }) + + const [defaultValues] = Retool.useStateArray({ + name: 'defaultValues', + initialValue: [], + label: 'Default Values (Multiple)', + description: 'Array of IDs to select by default when Multiple Select is enabled (e.g., ["1", "2"])', + inspector: 'text' + }) + + // ======================================== + // SECTION 2: Button Configuration + // ======================================== + const [buttonShape] = Retool.useStateEnumeration({ + name: 'buttonShape', + initialValue: 'bullet', + enumDefinition: ['bullet', 'square', 'rounded-square', 'diamond'], + enumLabels: { + bullet: 'Bullet (Circle)', + square: 'Square', + 'rounded-square': 'Rounded Square', + diamond: 'Diamond' + }, + label: 'Button Shape', + description: 'Shape of the radio button', + inspector: 'select' + }) + + const [buttonSize] = Retool.useStateNumber({ + name: 'buttonSize', + initialValue: 24, + label: 'Button Size', + description: 'Size of the radio button in pixels (default: 24)', + inspector: 'text' + }) + + const [buttonPosition] = Retool.useStateEnumeration({ + name: 'buttonPosition', + initialValue: 'left', + enumDefinition: ['left', 'right', 'top', 'bottom'], + enumLabels: { + left: 'Left', + right: 'Right', + top: 'Top', + bottom: 'Bottom' + }, + label: 'Button Position', + description: 'Position of the radio button relative to the text (default: left)', + inspector: 'segmented' + }) + + const [layout] = Retool.useStateEnumeration({ + name: 'layout', + initialValue: 'vertical', + enumDefinition: ['vertical', 'horizontal', 'grid', 'justified'], + enumLabels: { + vertical: 'Vertical (Stacked)', + horizontal: 'Horizontal (Row)', + grid: 'Grid', + justified: 'Justified (Space Between)' + }, + label: 'Layout', + description: 'How to arrange multiple options (default: vertical)', + inspector: 'select' + }) + + const [gridColumns] = Retool.useStateNumber({ + name: 'gridColumns', + initialValue: 2, + label: 'Grid Columns', + description: 'Number of columns when layout is set to "grid" (default: 2)', + inspector: 'text' + }) + + // ======================================== + // SECTION 3: Typography + // ======================================== + const [titleFontSize] = Retool.useStateNumber({ + name: 'titleFontSize', + initialValue: 16, + label: 'Title Font Size', + description: 'Font size for option titles in pixels (default: 16)', + inspector: 'text' + }) + + const [descriptionFontSize] = Retool.useStateNumber({ + name: 'descriptionFontSize', + initialValue: 14, + label: 'Description Font Size', + description: 'Font size for option descriptions in pixels (default: 14)', + inspector: 'text' + }) + + const [titleTextAlign] = Retool.useStateEnumeration({ + name: 'titleTextAlign', + initialValue: 'left', + enumDefinition: ['left', 'center', 'right', 'justify'], + enumLabels: { + left: 'Left', + center: 'Center', + right: 'Right', + justify: 'Justify' + }, + label: 'Title Text Align', + description: 'Text alignment for option titles (default: left)', + inspector: 'segmented' + }) + + const [descriptionTextAlign] = Retool.useStateEnumeration({ + name: 'descriptionTextAlign', + initialValue: 'left', + enumDefinition: ['left', 'center', 'right', 'justify'], + enumLabels: { + left: 'Left', + center: 'Center', + right: 'Right', + justify: 'Justify' + }, + label: 'Description Text Align', + description: 'Text alignment for option descriptions (default: left)', + inspector: 'segmented' + }) + + const [lineClamp] = Retool.useStateNumber({ + name: 'lineClamp', + initialValue: 0, + label: 'Line Clamp', + description: 'Number of lines to show for descriptions before adding ellipsis. Set to 0 to show all text (default: 0)', + inspector: 'text' + }) + + // ======================================== + // SECTION 4: Colors + // ======================================== + const [primary] = Retool.useStateString({ + name: 'primary', + initialValue: '#f97316', + label: 'Primary Color', + description: 'Main color for selected state', + inspector: 'text' + }) + + const [primaryLight] = Retool.useStateString({ + name: 'primaryLight', + initialValue: '#fb923c', + label: 'Primary Light Color', + description: 'Lighter shade of primary color for hover states', + inspector: 'text' + }) + + const [background] = Retool.useStateString({ + name: 'background', + initialValue: '#ffffff', + label: 'Background Color', + description: 'Background color for radio options', + inspector: 'text' + }) + + const [borderColor] = Retool.useStateString({ + name: 'borderColor', + initialValue: '#d1d5db', + label: 'Border Color', + description: 'Color for borders and unselected radio buttons', + inspector: 'text' + }) + + const [titleColor] = Retool.useStateString({ + name: 'titleColor', + initialValue: '#1f2937', + label: 'Title Color', + description: 'Color for option titles', + inspector: 'text' + }) + + const [descriptionColor] = Retool.useStateString({ + name: 'descriptionColor', + initialValue: '#6b7280', + label: 'Description Color', + description: 'Color for option descriptions', + inspector: 'text' + }) + + const [disabledColor] = Retool.useStateString({ + name: 'disabledColor', + initialValue: '#9ca3af', + label: 'Disabled Color', + description: 'Color for disabled options', + inspector: 'text' + }) + + const [hoverColor] = Retool.useStateString({ + name: 'hoverColor', + initialValue: '#fee2e2', + label: 'Hover Color', + description: 'Background color on hover and selected state', + inspector: 'text' + }) + + // ======================================== + // SECTION 5: Output States + // ======================================== + const [_selectedValue, setSelectedValue] = Retool.useStateString({ + name: 'selectedValue', + initialValue: '', + label: 'Selected Value', + description: 'Currently selected option ID (single select mode)', + inspector: 'text' + }) + + const [_selectedValues, setSelectedValues] = Retool.useStateArray({ + name: 'selectedValues', + initialValue: [], + label: 'Selected Values', + description: 'Currently selected option IDs (multiple select mode)', + inspector: 'text' + }) + + // Component settings for responsive sizing + Retool.useComponentSettings({ + defaultHeight: 12, + defaultWidth: 6, + }) + + // Event callbacks + const onSelectionChange = Retool.useEventCallback({ name: 'selectionChange' }) + + // Track if we've initialized from defaults + const hasInitialized = useRef(false) + + // Local state for selected values (supports both single and multiple) + const [selectedIds, setSelectedIds] = useState>(new Set()) + + // Initialize selected value(s) from default (only once on mount) + useEffect(() => { + if (hasInitialized.current) { + return + } + + if (multipleSelect) { + // Multiple select mode: initialize from defaultValues array + if (defaultValues && Array.isArray(defaultValues) && defaultValues.length > 0) { + const validIds = defaultValues + .filter((id) => typeof id === 'string' || typeof id === 'number') + .map((id) => String(id)) + if (validIds.length > 0) { + setSelectedIds(new Set(validIds)) + setSelectedValues(validIds) + } + } + } else { + // Single select mode: initialize from defaultValue string + if (defaultValue && typeof defaultValue === 'string' && defaultValue.trim()) { + const id = defaultValue.trim() + setSelectedIds(new Set([id])) + setSelectedValue(id) + } + } + + hasInitialized.current = true + }, [defaultValue, defaultValues, multipleSelect, setSelectedValue, setSelectedValues]) + + // Build color configuration + const colors: ColorConfig = useMemo(() => { + return { + primary: primary || DEFAULT_COLORS.primary, + primaryLight: primaryLight || DEFAULT_COLORS.primaryLight, + background: background || DEFAULT_COLORS.background, + borderColor: borderColor || DEFAULT_COLORS.borderColor, + titleColor: titleColor || DEFAULT_COLORS.titleColor, + descriptionColor: descriptionColor || DEFAULT_COLORS.descriptionColor, + disabledColor: disabledColor || DEFAULT_COLORS.disabledColor, + hoverColor: hoverColor || DEFAULT_COLORS.hoverColor + } + }, [ + primary, + primaryLight, + background, + borderColor, + titleColor, + descriptionColor, + disabledColor, + hoverColor + ]) + + // Parse and validate options (including group headers) + const validItems = useMemo(() => { + if (!options || !Array.isArray(options)) { + return [] + } + + return options + .map((item) => { + // Handle group headers + if (isGroupHeader(item)) { + return { + type: 'header', + title: String(item.title), + id: item.id ? String(item.id) : undefined + } as GroupHeaderType + } + + // Handle regular options + if (isValidRadioOption(item)) { + const option = { + id: String(item.id), + title: String(item.title), + description: item.description ? String(item.description) : undefined, + disabled: Boolean(item.disabled), + renderAsHtml: Boolean(item.renderAsHtml), + icon: item.icon ? String(item.icon) : undefined, + iconPosition: (item.iconPosition === 'right' ? 'right' : 'left') as 'left' | 'right', + badge: item.badge ? String(item.badge) : undefined, + badgeColor: item.badgeColor ? String(item.badgeColor) : undefined, + showIf: item.showIf ? String(item.showIf) : undefined + } as RadioOptionType + + // Apply conditional display logic + if (option.showIf && !evaluateShowIf(option.showIf)) { + return null + } + + return option + } + + return null + }) + .filter((item): item is RadioOptionType | GroupHeaderType => item !== null) + }, [options]) + + // Extract only the radio options (excluding headers) for selection logic + const validOptions = useMemo(() => { + return validItems.filter( + (item): item is RadioOptionType => !('type' in item && item.type === 'header') + ) + }, [validItems]) + + // Handle selection + const handleSelect = useCallback( + (id: string) => { + if (multipleSelect) { + // Multiple select mode: toggle selection + setSelectedIds((prev) => { + const newSet = new Set(prev) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + const selectedArray = Array.from(newSet) + setSelectedValues(selectedArray) + return newSet + }) + } else { + // Single select mode: replace selection + setSelectedIds(new Set([id])) + setSelectedValue(id) + } + onSelectionChange() + }, + [multipleSelect, setSelectedValue, setSelectedValues, onSelectionChange] + ) + + // Get the shape as ButtonShape type + const shape: ButtonShape = (buttonShape as ButtonShape) || 'bullet' + + // Get the button position with validation + const position: ButtonPosition = (buttonPosition as ButtonPosition) || 'left' + + // Get the layout mode with validation + const layoutMode: LayoutMode = (layout as LayoutMode) || 'vertical' + + // Get the size with validation + const size = typeof buttonSize === 'number' && buttonSize > 0 ? buttonSize : 24 + + // Get grid columns with validation + const columns = typeof gridColumns === 'number' && gridColumns > 0 ? Math.floor(gridColumns) : 2 + + // Get font sizes with validation + const validTitleFontSize = typeof titleFontSize === 'number' && titleFontSize > 0 ? titleFontSize : 16 + const validDescriptionFontSize = typeof descriptionFontSize === 'number' && descriptionFontSize > 0 ? descriptionFontSize : 14 + + // Get text alignment with validation + const validTitleTextAlign = (titleTextAlign === 'left' || titleTextAlign === 'center' || titleTextAlign === 'right' || titleTextAlign === 'justify') ? titleTextAlign : 'left' + const validDescriptionTextAlign = (descriptionTextAlign === 'left' || descriptionTextAlign === 'center' || descriptionTextAlign === 'right' || descriptionTextAlign === 'justify') ? descriptionTextAlign : 'left' + + // Get line clamp with validation (0 or positive integer) + const validLineClamp = typeof lineClamp === 'number' && lineClamp >= 0 ? Math.floor(lineClamp) : 0 + + // Get container styles based on layout mode + const getContainerStyles = (): React.CSSProperties => { + const baseStyles: React.CSSProperties = { + width: '99%', + height: 'auto', + fontFamily: "'Lexend', sans-serif, Inter, -apple-system, BlinkMacSystemFont, system-ui, sans-serif", + padding: '0px 0px 16px 0px' + } + + switch (layoutMode) { + case 'horizontal': + return { + ...baseStyles, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: '12px', + alignItems: 'stretch' + } + case 'grid': + return { + ...baseStyles, + display: 'grid', + gridTemplateColumns: `repeat(${columns}, 1fr)`, + gap: '12px' + } + case 'justified': + return { + ...baseStyles, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + gap: '12px', + alignItems: 'stretch' + } + case 'vertical': + default: + return baseStyles + } + } + + if (validOptions.length === 0) { + return ( +
+ No options provided. Add options via the inspector. +
+ ) + } + + return ( +
+ {validItems.map((item, index) => { + // Render group header + if ('type' in item && item.type === 'header') { + // Headers should span full width in grid/horizontal layouts + return ( +
+ +
+ ) + } + + // Render radio option + const option = item as RadioOptionType + return ( + + ) + })} +
+ ) +} diff --git a/components/custom-radio-group/src/components/GroupHeader.tsx b/components/custom-radio-group/src/components/GroupHeader.tsx new file mode 100644 index 0000000..e550b16 --- /dev/null +++ b/components/custom-radio-group/src/components/GroupHeader.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +interface GroupHeaderProps { + title: string + titleColor: string + titleFontSize: number +} + +export const GroupHeader: React.FC = ({ + title, + titleColor, + titleFontSize +}) => { + return ( +
+
+ {title} +
+
+
+ ) +} diff --git a/components/custom-radio-group/src/components/RadioOption.tsx b/components/custom-radio-group/src/components/RadioOption.tsx new file mode 100644 index 0000000..1c8582b --- /dev/null +++ b/components/custom-radio-group/src/components/RadioOption.tsx @@ -0,0 +1,362 @@ +import React, { useState, useRef, useEffect } from 'react' +import type { ColorConfig, ButtonShape, ButtonPosition } from '../types' + +interface RadioOptionProps { + id: string + title: string + description?: string + isSelected: boolean + disabled?: boolean + renderAsHtml?: boolean + buttonSize: number + buttonShape: ButtonShape + buttonPosition: ButtonPosition + colors: ColorConfig + titleFontSize: number + descriptionFontSize: number + titleTextAlign: string + descriptionTextAlign: string + lineClamp: number + icon?: string + iconPosition?: 'left' | 'right' + badge?: string + badgeColor?: string + noMarginBottom?: boolean + onSelect: (id: string) => void +} + +const renderButtonShape = ( + shape: ButtonShape, + size: number, + isSelected: boolean, + colors: ColorConfig +) => { + const commonStyle = { + width: `${size}px`, + height: `${size}px`, + border: `2px solid ${isSelected ? colors.primary : colors.borderColor}`, + transition: 'all 0.2s ease', + flexShrink: 0, + position: 'relative' as const, + backgroundColor: colors.background + } + + const innerDotStyle = { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: `${size * 0.5}px`, + height: `${size * 0.5}px`, + backgroundColor: colors.primary, + transition: 'all 0.2s ease', + opacity: isSelected ? 1 : 0 + } + + switch (shape) { + case 'bullet': + return ( +
+
+
+ ) + case 'square': + return ( +
+
+
+ ) + case 'rounded-square': + return ( +
+
+
+ ) + case 'diamond': + return ( +
+
+
+ ) + default: + return ( +
+
+
+ ) + } +} + +export const RadioOption: React.FC = ({ + id, + title, + description, + isSelected, + disabled = false, + renderAsHtml = false, + buttonSize, + buttonShape, + buttonPosition = 'left', + colors, + titleFontSize, + descriptionFontSize, + titleTextAlign, + descriptionTextAlign, + lineClamp, + icon, + iconPosition = 'left', + badge, + badgeColor, + noMarginBottom = false, + onSelect +}) => { + const descriptionRef = useRef(null) + const [showTooltip, setShowTooltip] = useState(false) + const [isTruncated, setIsTruncated] = useState(false) + + // Check if description is truncated + useEffect(() => { + if (lineClamp > 0 && descriptionRef.current && description) { + const element = descriptionRef.current + setIsTruncated(element.scrollHeight > element.clientHeight) + } + }, [description, lineClamp]) + + const handleClick = () => { + if (!disabled) { + onSelect(id) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!disabled && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onSelect(id) + } + } + + // Determine flex direction based on button position + const getFlexDirection = (): 'row' | 'row-reverse' | 'column' | 'column-reverse' => { + switch (buttonPosition) { + case 'left': + return 'row' + case 'right': + return 'row-reverse' + case 'top': + return 'column' + case 'bottom': + return 'column-reverse' + default: + return 'row' + } + } + + // Determine alignment based on button position + const getAlignment = () => { + if (buttonPosition === 'top' || buttonPosition === 'bottom') { + return 'center' + } + return 'flex-start' + } + + return ( +
{ + if (!disabled && !isSelected) { + e.currentTarget.style.backgroundColor = colors.hoverColor + } + }} + onMouseLeave={(e) => { + if (!disabled && !isSelected) { + e.currentTarget.style.backgroundColor = colors.background + } + }} + > + {/* Radio button */} +
+ {renderButtonShape(buttonShape, buttonSize, isSelected, colors)} +
+ + {/* Content */} +
+ {/* Title with Icon and Badge */} +
+ {/* Icon (Left) */} + {icon && iconPosition === 'left' && ( + + {icon} + + )} + + {/* Title Text */} +
+ {renderAsHtml ? ( +
+ ) : ( + title + )} +
+ + {/* Icon (Right) */} + {icon && iconPosition === 'right' && ( + + {icon} + + )} + + {/* Badge */} + {badge && ( + + {badge} + + )} +
+ + {/* Description with Tooltip */} + {description && ( +
lineClamp > 0 && isTruncated && setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
0 && { + display: '-webkit-box', + WebkitLineClamp: lineClamp, + WebkitBoxOrient: 'vertical', + overflow: 'hidden' + }) + }} + > + {renderAsHtml ? ( +
+ ) : ( + description + )} +
+ + {/* Tooltip */} + {showTooltip && lineClamp > 0 && isTruncated && ( +
+ {renderAsHtml ? ( +
+ ) : ( + description + )} +
+ )} +
+ )} +
+
+ ) +} diff --git a/components/custom-radio-group/src/index.tsx b/components/custom-radio-group/src/index.tsx new file mode 100644 index 0000000..6c23765 --- /dev/null +++ b/components/custom-radio-group/src/index.tsx @@ -0,0 +1 @@ +export { RadioGroup } from './RadioGroup' \ No newline at end of file diff --git a/components/custom-radio-group/src/types/index.ts b/components/custom-radio-group/src/types/index.ts new file mode 100644 index 0000000..a30b08b --- /dev/null +++ b/components/custom-radio-group/src/types/index.ts @@ -0,0 +1,47 @@ +export interface RadioOption { + id: string + title: string + description?: string + disabled?: boolean + renderAsHtml?: boolean + icon?: string + iconPosition?: 'left' | 'right' + badge?: string + badgeColor?: string + showIf?: string +} + +export interface GroupHeader { + type: 'header' + title: string + id?: string +} + +export type ButtonShape = + | 'bullet' + | 'square' + | 'diamond' + | 'rounded-square' + +export type ButtonPosition = 'left' | 'right' | 'top' | 'bottom' + +export type LayoutMode = 'vertical' | 'horizontal' | 'grid' | 'justified' + +export interface ColorConfig { + primary: string + primaryLight: string + background: string + borderColor: string + titleColor: string + descriptionColor: string + disabledColor: string + hoverColor: string +} + +export interface RadioGroupModel { + options: RadioOption[] + selectedValue: string + buttonSize: number + buttonShape: ButtonShape + colors: ColorConfig +} diff --git a/components/custom-radio-group/src/utils/theme.ts b/components/custom-radio-group/src/utils/theme.ts new file mode 100644 index 0000000..651542d --- /dev/null +++ b/components/custom-radio-group/src/utils/theme.ts @@ -0,0 +1,15 @@ +import type { ColorConfig } from '../types' + +/** + * Default color theme for the radio group + */ +export const DEFAULT_COLORS: ColorConfig = { + primary: '#f97316', + primaryLight: '#fb923c', + background: '#ffffff', + borderColor: '#d1d5db', + titleColor: '#1f2937', + descriptionColor: '#6b7280', + disabledColor: '#9ca3af', + hoverColor: '#fee2e2' +} diff --git a/components/custom-radio-group/src/utils/typeGuards.ts b/components/custom-radio-group/src/utils/typeGuards.ts new file mode 100644 index 0000000..4ac3033 --- /dev/null +++ b/components/custom-radio-group/src/utils/typeGuards.ts @@ -0,0 +1,59 @@ +import { Retool } from '@tryretool/custom-component-support' + +type SerializableObject = Retool.SerializableObject +type SerializableType = Retool.SerializableType + +/** + * Type guard to check if an item is a group header + * @param item - The item to check + * @returns True if the item is a group header + */ +export const isGroupHeader = ( + item: SerializableType +): item is SerializableObject => { + return ( + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + 'type' in item && + item.type === 'header' + ) +} + +/** + * Type guard to check if an item is a valid radio option + * @param item - The item to check + * @returns True if the item is a valid radio option with required properties + */ +export const isValidRadioOption = ( + item: SerializableType +): item is SerializableObject => { + return ( + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + 'id' in item && + 'title' in item && + !('type' in item && item.type === 'header') + ) +} + +/** + * Evaluates a conditional display expression (showIf) + * @param expression - JavaScript expression string to evaluate + * @returns Boolean result of the expression, or true if evaluation fails + */ +export const evaluateShowIf = (expression: string): boolean => { + if (!expression || typeof expression !== 'string') { + return true + } + try { + // Create a safe evaluation function + // eslint-disable-next-line no-new-func + const evalFunc = new Function('return ' + expression) + return Boolean(evalFunc()) + } catch (error) { + console.warn('Failed to evaluate showIf expression:', expression, error) + return true // Show by default if evaluation fails + } +} diff --git a/components/custom-radio-group/tsconfig.json b/components/custom-radio-group/tsconfig.json new file mode 100644 index 0000000..55be51b --- /dev/null +++ b/components/custom-radio-group/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"] +}