diff --git a/AGENTS.md b/AGENTS.md index fae7549..1ef9380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,16 @@ const sizeMap: Record = { /> ``` +### Toggle (switch for boolean input — a!toggleField) +```tsx + +``` + +### ButtonToggle (button-style on/off toggle) +```tsx + +``` + ## Validation Run `npm run build` to catch TypeScript errors before considering work complete. diff --git a/src/components/ApplicationHeader/ApplicationHeader.tsx b/src/components/ApplicationHeader/ApplicationHeader.tsx index 5517536..e09d623 100644 --- a/src/components/ApplicationHeader/ApplicationHeader.tsx +++ b/src/components/ApplicationHeader/ApplicationHeader.tsx @@ -1,8 +1,8 @@ import * as React from 'react' import { ButtonWidget } from '../Button/ButtonWidget' import { ButtonArrayLayout } from '../Button/ButtonArrayLayout' -import { SwitchField } from '../Switch/SwitchField' import { ToggleField } from '../Toggle/ToggleField' +import { ButtonToggle } from '../ButtonToggle/ButtonToggle' import type { SAILColorInput } from '../../types/sail' import { paletteHexMap, type SAILPaletteColor } from '../../types/palette-colors.generated' import { @@ -144,7 +144,7 @@ export const ApplicationHeader: React.FC = ({ {showDesignerControls && ( <>
- = ({ saveInto={showStoriesView ? onStoryToggle : undefined} marginBelow="NONE" /> - = ({
- = ({ />
- + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Text Formatting', + text: 'Bold', + value: false, + style: 'SOLID', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const toggle = canvas.getByRole('button', { name: /bold/i }) + await expect(toggle).toBeVisible() + await expect(toggle).toHaveAttribute('aria-pressed', 'false') + await userEvent.click(toggle) + await expect(toggle).toHaveAttribute('aria-pressed', 'true') + await userEvent.click(toggle) + await expect(toggle).toHaveAttribute('aria-pressed', 'false') + }, +} + +export const WithIcon: Story = { + args: { + label: 'Favorite', + text: 'Add to Favorites', + icon: 'star', + value: true, + color: 'ACCENT', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const StyleSolid: Story = { + args: { + label: 'SOLID Style', + text: 'Toggle Me', + value: false, + style: 'SOLID', + color: 'ACCENT', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const StyleOutline: Story = { + args: { + label: 'OUTLINE Style', + text: 'Toggle Me', + value: false, + style: 'OUTLINE', + color: 'ACCENT', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const StyleGhost: Story = { + args: { + label: 'GHOST Style', + text: 'Toggle Me', + value: false, + style: 'GHOST', + color: 'ACCENT', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const IconAtStart: Story = { + args: { + label: 'Icon at START', + text: 'Filter', + icon: 'filter', + iconPosition: 'START', + value: false, + style: 'OUTLINE', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const IconAtEnd: Story = { + args: { + label: 'Icon at END', + text: 'Search', + icon: 'arrow-right', + iconPosition: 'END', + value: false, + style: 'OUTLINE', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const ColorAccent: Story = { + args: { + label: 'ACCENT', + text: 'Accent Color', + value: false, + color: 'ACCENT', + style: 'OUTLINE', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const ColorPositive: Story = { + args: { + label: 'POSITIVE', + text: 'Positive Color', + value: false, + color: 'POSITIVE', + style: 'OUTLINE', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const ColorNegative: Story = { + args: { + label: 'NEGATIVE', + text: 'Negative Color', + value: false, + color: 'NEGATIVE', + style: 'OUTLINE', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const ColorSecondary: Story = { + args: { + label: 'SECONDARY', + text: 'Secondary Color', + value: false, + color: 'SECONDARY', + style: 'OUTLINE', + }, + render: (args) => { + const [value, setValue] = useState(args.value) + return + }, +} + +export const Disabled: Story = { + args: { + label: 'Disabled Toggle', + text: "Can't Click Me", + value: true, + disabled: true, + }, +} diff --git a/src/components/ButtonToggle/ButtonToggle.tsx b/src/components/ButtonToggle/ButtonToggle.tsx new file mode 100644 index 0000000..3e81e80 --- /dev/null +++ b/src/components/ButtonToggle/ButtonToggle.tsx @@ -0,0 +1,280 @@ +import * as React from 'react' +import * as Toggle from '@radix-ui/react-toggle' +import * as LucideIcons from 'lucide-react' +import { FieldWrapper } from '../shared/FieldWrapper' +import type { SAILLabelPosition, SAILMarginSize, SAILSize, SAILColorInput } from '../../types/sail' +import { isPaletteColor } from '../../utils/colorResolver' +import { paletteColorMap } from '../../types/palette-colors.generated' +import { buttonSizeMap } from '../../utils/sailMaps' + +type ButtonToggleStyle = "SOLID" | "OUTLINE" | "GHOST" + +/** + * Displays a toggle button for boolean input (button-style on/off) + * Inspired by SAIL form field patterns + * + * Use ButtonToggle for button-style toggles (e.g., toolbar buttons, filters) + * Use ToggleField for traditional on/off switches (e.g., settings) + */ +export interface ButtonToggleProps { + /** Text to display as the field label */ + label?: string + /** Text to display on the toggle button */ + text?: string + /** Supplemental text about this field */ + instructions?: string + /** Determines if a value is required to submit the form */ + required?: boolean + /** Determines if the field should display as grayed out */ + disabled?: boolean + /** Current pressed state (true = pressed, false = unpressed) */ + value?: boolean + /** Validation errors to display below the field */ + validations?: string[] + /** Callback when the user toggles the button */ + saveInto?: (value: boolean) => void + /** Callback when the user toggles the button (React-style alias for saveInto) */ + onChange?: (value: boolean) => void + /** Validation group name (no spaces) */ + validationGroup?: string + /** Custom message when field is required and not provided */ + requiredMessage?: string + /** Determines where the label appears */ + labelPosition?: SAILLabelPosition + /** Displays a help icon with tooltip text */ + helpTooltip?: string + /** Additional text for screen readers */ + accessibilityText?: string + /** Determines whether component is displayed */ + showWhen?: boolean + /** Space added above component */ + marginAbove?: SAILMarginSize + /** Space added below component */ + marginBelow?: SAILMarginSize + /** Size of the toggle button */ + size?: SAILSize + /** Color when toggle is pressed (hex or semantic) */ + color?: "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | "STANDARD" | SAILColorInput + /** Determines the button's appearance */ + style?: ButtonToggleStyle + /** Icon to display in the button */ + icon?: string + /** Position of icon relative to text */ + iconPosition?: "START" | "END" + /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ + className?: string +} + +export const ButtonToggle: React.FC = ({ + label, + text, + instructions, + required = false, + disabled = false, + value = false, + validations = [], + saveInto, + onChange, + validationGroup: _validationGroup, + requiredMessage, + labelPosition = "ABOVE", + helpTooltip, + accessibilityText, + showWhen = true, + marginAbove = "NONE", + marginBelow = "STANDARD", + size = "STANDARD", + color = "ACCENT", + style = "SOLID", + icon, + iconPosition = "START", + className +}) => { + // Visibility control + if (!showWhen) return null + + const inputId = `buttontoggle-${Math.random().toString(36).substr(2, 9)}` + + // Get color classes based on style and pressed state + const getColorClasses = (): string => { + if (color.startsWith('#') || isPaletteColor(color)) return 'border' + + const semanticColor = color as "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | "STANDARD" + + if (style === "SOLID") { + const solidColors: Record = { + ACCENT: 'border border-blue-500 text-blue-500 bg-white hover:bg-blue-100 data-[state=on]:bg-blue-500 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-blue-500', + POSITIVE: 'border border-green-700 text-green-700 bg-white hover:bg-green-100 data-[state=on]:bg-green-700 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-green-700', + NEGATIVE: 'border border-red-700 text-red-700 bg-white hover:bg-red-100 data-[state=on]:bg-red-700 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-red-700', + SECONDARY: 'border border-gray-700 text-gray-700 bg-white hover:bg-gray-100 data-[state=on]:bg-gray-700 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-gray-700', + STANDARD: 'border border-gray-900 text-gray-900 bg-white hover:bg-gray-200 data-[state=on]:bg-gray-900 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-gray-900' + } + return solidColors[semanticColor] + } + + if (style === "OUTLINE") { + const outlineColors: Record = { + ACCENT: 'border border-blue-500 text-blue-500 bg-white hover:bg-blue-100 data-[state=on]:bg-blue-50', + POSITIVE: 'border border-green-700 text-green-700 bg-white hover:bg-green-100 data-[state=on]:bg-green-50', + NEGATIVE: 'border border-red-700 text-red-700 bg-white hover:bg-red-100 data-[state=on]:bg-red-50', + SECONDARY: 'border border-gray-700 text-gray-700 bg-white hover:bg-gray-100 data-[state=on]:bg-gray-50', + STANDARD: 'border border-gray-900 text-gray-900 bg-white hover:bg-gray-200 data-[state=on]:bg-gray-50' + } + return outlineColors[semanticColor] + } + + if (style === "GHOST") { + const ghostColors: Record = { + ACCENT: 'border border-transparent text-blue-500 hover:bg-blue-100 data-[state=on]:bg-blue-50', + POSITIVE: 'border border-transparent text-green-700 hover:bg-green-100 data-[state=on]:bg-green-50', + NEGATIVE: 'border border-transparent text-red-700 hover:bg-red-100 data-[state=on]:bg-red-50', + SECONDARY: 'border border-transparent text-gray-700 hover:bg-gray-100 data-[state=on]:bg-gray-50', + STANDARD: 'border border-transparent text-gray-900 hover:bg-gray-200 data-[state=on]:bg-gray-50' + } + return ghostColors[semanticColor] + } + + return '' + } + + // Resolve a non-semantic color to a CSS value for inline styles + const resolveInlineColor = (): string | undefined => { + if (color.startsWith('#')) return color + if (isPaletteColor(color)) { + const segment = paletteColorMap[color].bg.replace('bg-', '') + return `var(--color-${segment})` + } + return undefined + } + + const inlineColor = resolveInlineColor() + + const handleChange = (pressed: boolean) => { + const handler = onChange || saveInto + if (handler && !disabled) { + handler(pressed) + } + } + + // Map any Lucide icon name directly, with SAIL compatibility fallbacks + const getIconComponent = (iconName: string) => { + // First try direct Lucide icon name (kebab-case or PascalCase) + const kebabToPascal = (str: string) => + str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('') + + const pascalIconName = kebabToPascal(iconName) + if (pascalIconName in LucideIcons) { + return LucideIcons[pascalIconName as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> + } + + // Also try direct case-insensitive lookup + const directIconName = iconName.charAt(0).toUpperCase() + iconName.slice(1).toLowerCase() + if (directIconName in LucideIcons) { + return LucideIcons[directIconName as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> + } + + // Fallback to SAIL compatibility mapping + const sailIconMap: Record = { + 'STAR': 'Star', + 'HOME': 'Home', + 'USER': 'User', + 'SETTINGS': 'Settings', + 'SEARCH': 'Search', + 'FILTER': 'Filter', + 'ARROW_RIGHT': 'ArrowRight', + 'ARROW_LEFT': 'ArrowLeft', + 'ARROW_UP': 'ArrowUp', + 'ARROW_DOWN': 'ArrowDown' + } + + const lucideIconName = sailIconMap[iconName] + if (lucideIconName && lucideIconName in LucideIcons) { + return LucideIcons[lucideIconName] as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> + } + + // Fallback to a generic icon + return LucideIcons.Circle as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> + } + + // Show validation errors + const showValidations = validations.length > 0 + + // Show required message + const showRequiredMessage = required && !value && requiredMessage + + // Toggle button element + const toggleElement = ( + + {icon && iconPosition === "START" && ( + + )} + {text && {text}} + {icon && iconPosition === "END" && ( + + )} + + ) + + // Footer content (validations and required message) + const footerContent = ( + <> + {showValidations && ( + + )} + + {showRequiredMessage && ( +

+ {requiredMessage} +

+ )} + + ) + + return ( + + {toggleElement} + + ) +} diff --git a/src/components/ButtonToggle/index.ts b/src/components/ButtonToggle/index.ts new file mode 100644 index 0000000..f6e6d4f --- /dev/null +++ b/src/components/ButtonToggle/index.ts @@ -0,0 +1,2 @@ +export { ButtonToggle } from './ButtonToggle' +export type { ButtonToggleProps } from './ButtonToggle' diff --git a/src/components/Switch/Switch.stories.tsx b/src/components/Switch/Switch.stories.tsx deleted file mode 100644 index 3cb4332..0000000 --- a/src/components/Switch/Switch.stories.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { expect, userEvent, within } from 'storybook/test' -import { useState } from 'react' -import { SwitchField } from './SwitchField' - -const meta = { - title: 'Components/Switch', - component: SwitchField, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - label: 'Enable Notifications', - instructions: 'Receive email and push notifications for updates', - value: true, - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement) - const switchEl = canvas.getByRole('switch') - await expect(switchEl).toBeVisible() - await expect(switchEl).toBeChecked() - await userEvent.click(switchEl) - await expect(switchEl).not.toBeChecked() - }, -} - -export const Unchecked: Story = { - args: { - label: 'Dark Mode', - value: false, - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const WithInstructions: Story = { - args: { - label: 'Auto-save', - instructions: 'Automatically save changes every 30 seconds', - value: true, - color: 'POSITIVE', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -// --- Size stories (Issue #40: size affects label too) --- - -export const SmallSize: Story = { - args: { - label: 'Small Switch', - value: true, - size: 'SMALL', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const StandardSize: Story = { - args: { - label: 'Standard Switch', - value: true, - size: 'STANDARD', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const MediumSize: Story = { - args: { - label: 'Medium Switch', - value: true, - size: 'MEDIUM', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const LargeSize: Story = { - args: { - label: 'Large Switch', - value: true, - size: 'LARGE', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const AllSizes: Story = { - args: { label: '', value: true }, - render: () => { - const sizes = ['SMALL', 'STANDARD', 'MEDIUM', 'LARGE'] as const - const SizeRow = ({ s }: { s: typeof sizes[number] }) => { - const [value, setValue] = useState(true) - return - } - return ( -
- {sizes.map((s) => )} -
- ) - }, -} - -// --- Color stories --- - -export const ColorAccent: Story = { - args: { - label: 'ACCENT (Blue)', - value: true, - color: 'ACCENT', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const ColorPositive: Story = { - args: { - label: 'POSITIVE (Green)', - value: true, - color: 'POSITIVE', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const ColorNegative: Story = { - args: { - label: 'NEGATIVE (Red)', - value: true, - color: 'NEGATIVE', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const CustomHexColor: Story = { - args: { - label: 'Custom Hex Color', - value: true, - color: 'VIOLET_500', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -// --- Disabled stories --- - -export const DisabledOff: Story = { - args: { - label: 'Disabled (Off)', - value: false, - disabled: true, - }, -} - -export const DisabledOn: Story = { - args: { - label: 'Disabled (On)', - value: true, - disabled: true, - }, -} - -// --- Label position stories (Issue #41: left or right label) --- - -export const LabelOnRight: Story = { - args: { - label: 'Label on Right (default)', - value: true, - switchLabelPosition: 'RIGHT', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const LabelOnLeft: Story = { - args: { - label: 'Label on Left', - value: true, - switchLabelPosition: 'LEFT', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return - }, -} - -export const LabelPositionComparison: Story = { - args: { label: '', value: true }, - render: () => { - const RightLabel = () => { - const [value, setValue] = useState(true) - return - } - const LeftLabel = () => { - const [value, setValue] = useState(true) - return - } - return ( -
- - -
- ) - }, -} diff --git a/src/components/Switch/SwitchField.tsx b/src/components/Switch/SwitchField.tsx deleted file mode 100644 index f92c279..0000000 --- a/src/components/Switch/SwitchField.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import * as React from 'react' -import * as Switch from '@radix-ui/react-switch' -import { Info } from 'lucide-react' -import { FieldWrapper } from '../shared/FieldWrapper' -import type { SAILLabelPosition, SAILMarginSize, SAILSize, SAILColorInput } from '../../types/sail' -import { isPaletteColor } from '../../utils/colorResolver' -import { paletteColorMap } from '../../types/palette-colors.generated' - -/** - * Displays a switch (toggle) for boolean input - * Inspired by SAIL form field patterns (not an official SAIL component) - * - * This is a "new SAIL" component - not available in public SAIL but follows - * the same conventions and patterns for consistency with other Sailwind components. - */ -export interface SwitchFieldProps { - /** Text to display as the field label */ - label?: string - /** Supplemental text about this field */ - instructions?: string - /** Determines if a value is required to submit the form */ - required?: boolean - /** Determines if the field should display as grayed out */ - disabled?: boolean - /** Current checked state (true = on, false = off) */ - value?: boolean - /** Validation errors to display below the field */ - validations?: string[] - /** Callback when the user toggles the switch */ - saveInto?: (value: boolean) => void - /** Callback when the user toggles the switch (React-style alias for saveInto) */ - onChange?: (value: boolean) => void - /** Validation group name (no spaces) */ - validationGroup?: string - /** Custom message when field is required and not provided */ - requiredMessage?: string - /** Determines where the label appears relative to the component */ - labelPosition?: SAILLabelPosition - /** Displays a help icon with tooltip text */ - helpTooltip?: string - /** Additional text for screen readers */ - accessibilityText?: string - /** Determines whether component is displayed */ - showWhen?: boolean - /** Space added above component */ - marginAbove?: SAILMarginSize - /** Space added below component */ - marginBelow?: SAILMarginSize - /** Size of the switch and its label */ - size?: SAILSize - /** Color when switch is on (hex or semantic) */ - color?: "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | "STANDARD" | SAILColorInput - /** Position of the inline label relative to the switch control: LEFT or RIGHT */ - switchLabelPosition?: "LEFT" | "RIGHT" - /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ - className?: string -} - -export const SwitchField: React.FC = ({ - label, - instructions, - required = false, - disabled = false, - value = false, - validations = [], - saveInto, - onChange, - validationGroup: _validationGroup, - requiredMessage, - labelPosition: _labelPosition = "ABOVE", - helpTooltip, - accessibilityText, - showWhen = true, - marginAbove = "NONE", - marginBelow = "STANDARD", - size = "STANDARD", - color = "ACCENT", - switchLabelPosition = "RIGHT", - className -}) => { - // Visibility control - if (!showWhen) return null - - const inputId = `switchfield-${Math.random().toString(36).substr(2, 9)}` - - // Size mappings for the switch control - const sizeMap: Record = { - SMALL: { - root: 'h-5 w-9', - thumb: 'h-3.5 w-3.5 data-[state=checked]:translate-x-4' - }, - STANDARD: { - root: 'h-6 w-11', - thumb: 'h-4 w-4 data-[state=checked]:translate-x-5.5' - }, - MEDIUM: { - root: 'h-7 w-14', - thumb: 'h-5 w-5 data-[state=checked]:translate-x-7.5' - }, - LARGE: { - root: 'h-9 w-18', - thumb: 'h-7 w-7 data-[state=checked]:translate-x-9.5' - } - } - - // Size mappings for the inline label text - const labelSizeMap: Record = { - SMALL: 'text-sm', - STANDARD: 'text-base', - MEDIUM: 'text-lg', - LARGE: 'text-xl' - } - - // Gap between switch and inline label, scaled by size - const gapMap: Record = { - SMALL: 'gap-2', - STANDARD: 'gap-3', - MEDIUM: 'gap-3', - LARGE: 'gap-4' - } - - // Color mapping for checked state - const getCheckedBgClass = (): string => { - if (color.startsWith('#')) return '' - if (isPaletteColor(color)) return '' // handled via inline style - - const colorMap: Record = { - ACCENT: 'data-[state=checked]:bg-blue-500', - POSITIVE: 'data-[state=checked]:bg-green-700', - NEGATIVE: 'data-[state=checked]:bg-red-700', - SECONDARY: 'data-[state=checked]:bg-gray-700', - STANDARD: 'data-[state=checked]:bg-gray-900' - } - - return colorMap[color] || 'data-[state=checked]:bg-blue-500' - } - - // Resolve a non-semantic color to a CSS value for inline styles - const resolveInlineColor = (): string | undefined => { - if (color.startsWith('#')) return color - if (isPaletteColor(color)) { - // 'bg-teal-700' → 'teal-700' → 'var(--color-teal-700)' - const segment = paletteColorMap[color].bg.replace('bg-', '') - return `var(--color-${segment})` - } - return undefined - } - - const inlineColor = resolveInlineColor() - - const handleChange = (checked: boolean) => { - const handler = onChange || saveInto - if (handler && !disabled) { - handler(checked) - } - } - - // Show validation errors - const showValidations = validations.length > 0 - - // Show required message - const showRequiredMessage = required && !value && requiredMessage - - // The Radix switch control - const switchControl = ( - - - {value && ( - - )} - - - ) - - // Inline label element (rendered next to the switch control) - const inlineLabel = label ? ( - - ) : null - - // The switch + inline label row - const switchElement = ( -
- {switchLabelPosition === "LEFT" ? ( - <> - {inlineLabel} - {switchControl} - - ) : ( - <> - {switchControl} - {inlineLabel} - - )} -
- ) - - // Footer content (validations and required message) - const footerContent = ( - <> - {showValidations && ( - - )} - - {showRequiredMessage && ( -

- {requiredMessage} -

- )} - - ) - - // Use FieldWrapper without a label — we handle the label inline - return ( - - {switchElement} - - ) -} diff --git a/src/components/Switch/index.ts b/src/components/Switch/index.ts deleted file mode 100644 index 3cc8685..0000000 --- a/src/components/Switch/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { SwitchField } from './SwitchField' -export type { SwitchFieldProps } from './SwitchField' diff --git a/src/components/Toggle/Toggle.stories.tsx b/src/components/Toggle/Toggle.stories.tsx index fce9c87..f419d87 100644 --- a/src/components/Toggle/Toggle.stories.tsx +++ b/src/components/Toggle/Toggle.stories.tsx @@ -15,10 +15,8 @@ type Story = StoryObj export const Default: Story = { args: { - label: 'Text Formatting', - text: 'Bold', - value: false, - style: 'SOLID', + choiceLabel: 'Enable Notifications', + value: true, }, render: (args) => { const [value, setValue] = useState(args.value) @@ -26,37 +24,18 @@ export const Default: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement) - const toggle = canvas.getByRole('button', { name: /bold/i }) - await expect(toggle).toBeVisible() - await expect(toggle).toHaveAttribute('aria-pressed', 'false') - await userEvent.click(toggle) - await expect(toggle).toHaveAttribute('aria-pressed', 'true') - await userEvent.click(toggle) - await expect(toggle).toHaveAttribute('aria-pressed', 'false') - }, -} - -export const WithIcon: Story = { - args: { - label: 'Favorite', - text: 'Add to Favorites', - icon: 'star', - value: true, - color: 'ACCENT', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return + const switchEl = canvas.getByRole('switch') + await expect(switchEl).toBeVisible() + await expect(switchEl).toBeChecked() + await userEvent.click(switchEl) + await expect(switchEl).not.toBeChecked() }, } -export const StyleSolid: Story = { +export const Unchecked: Story = { args: { - label: 'SOLID Style', - text: 'Toggle Me', + choiceLabel: 'Dark Mode', value: false, - style: 'SOLID', - color: 'ACCENT', }, render: (args) => { const [value, setValue] = useState(args.value) @@ -64,13 +43,11 @@ export const StyleSolid: Story = { }, } -export const StyleOutline: Story = { +export const WithHelpTooltip: Story = { args: { - label: 'OUTLINE Style', - text: 'Toggle Me', - value: false, - style: 'OUTLINE', - color: 'ACCENT', + choiceLabel: 'Auto-save', + helpTooltip: 'Automatically save changes every 30 seconds', + value: true, }, render: (args) => { const [value, setValue] = useState(args.value) @@ -78,43 +55,31 @@ export const StyleOutline: Story = { }, } -export const StyleGhost: Story = { +// --- Disabled stories --- + +export const DisabledOff: Story = { args: { - label: 'GHOST Style', - text: 'Toggle Me', + choiceLabel: 'Disabled (Off)', value: false, - style: 'GHOST', - color: 'ACCENT', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return + disabled: true, }, } -export const IconAtStart: Story = { +export const DisabledOn: Story = { args: { - label: 'Icon at START', - text: 'Filter', - icon: 'filter', - iconPosition: 'START', - value: false, - style: 'OUTLINE', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return + choiceLabel: 'Disabled (On)', + value: true, + disabled: true, }, } -export const IconAtEnd: Story = { +// --- Choice position stories --- + +export const ChoicePositionStart: Story = { args: { - label: 'Icon at END', - text: 'Search', - icon: 'arrow-right', - iconPosition: 'END', - value: false, - style: 'OUTLINE', + choiceLabel: 'Toggle on left (START, default)', + value: true, + choicePosition: 'START', }, render: (args) => { const [value, setValue] = useState(args.value) @@ -122,13 +87,11 @@ export const IconAtEnd: Story = { }, } -export const ColorAccent: Story = { +export const ChoicePositionEnd: Story = { args: { - label: 'ACCENT', - text: 'Accent Color', - value: false, - color: 'ACCENT', - style: 'OUTLINE', + choiceLabel: 'Toggle on right (END)', + value: true, + choicePosition: 'END', }, render: (args) => { const [value, setValue] = useState(args.value) @@ -136,27 +99,33 @@ export const ColorAccent: Story = { }, } -export const ColorPositive: Story = { - args: { - label: 'POSITIVE', - text: 'Positive Color', - value: false, - color: 'POSITIVE', - style: 'OUTLINE', - }, - render: (args) => { - const [value, setValue] = useState(args.value) - return +export const ChoicePositionComparison: Story = { + args: { choiceLabel: '', value: true }, + render: () => { + const StartPosition = () => { + const [value, setValue] = useState(true) + return + } + const EndPosition = () => { + const [value, setValue] = useState(true) + return + } + return ( +
+ + +
+ ) }, } -export const ColorNegative: Story = { +// --- Required message stories --- + +export const RequiredDefault: Story = { args: { - label: 'NEGATIVE', - text: 'Negative Color', + choiceLabel: 'Accept Terms', value: false, - color: 'NEGATIVE', - style: 'OUTLINE', + required: true, }, render: (args) => { const [value, setValue] = useState(args.value) @@ -164,25 +133,15 @@ export const ColorNegative: Story = { }, } -export const ColorSecondary: Story = { +export const RequiredCustomMessage: Story = { args: { - label: 'SECONDARY', - text: 'Secondary Color', + choiceLabel: 'Accept Terms', value: false, - color: 'SECONDARY', - style: 'OUTLINE', + required: true, + requiredMessage: 'You must accept the terms to proceed', }, render: (args) => { const [value, setValue] = useState(args.value) return }, } - -export const Disabled: Story = { - args: { - label: 'Disabled Toggle', - text: "Can't Click Me", - value: true, - disabled: true, - }, -} diff --git a/src/components/Toggle/ToggleField.tsx b/src/components/Toggle/ToggleField.tsx index 14ff4ee..21d410a 100644 --- a/src/components/Toggle/ToggleField.tsx +++ b/src/components/Toggle/ToggleField.tsx @@ -1,50 +1,33 @@ import * as React from 'react' -import * as Toggle from '@radix-ui/react-toggle' -import * as LucideIcons from 'lucide-react' +import * as Switch from '@radix-ui/react-switch' +import { Info } from 'lucide-react' import { FieldWrapper } from '../shared/FieldWrapper' -import type { SAILLabelPosition, SAILMarginSize, SAILSize, SAILColorInput } from '../../types/sail' -import { isPaletteColor } from '../../utils/colorResolver' -import { paletteColorMap } from '../../types/palette-colors.generated' -import { buttonSizeMap } from '../../utils/sailMaps' - -type ToggleStyle = "SOLID" | "OUTLINE" | "GHOST" +import type { SAILMarginSize } from '../../types/sail' /** - * Displays a toggle button for boolean input (button-style on/off) - * Inspired by SAIL form field patterns (not an official SAIL component) - * - * This is a "new SAIL" component - not available in public SAIL but follows - * the same conventions and patterns for consistency with other Sailwind components. - * - * Use ToggleField for button-style toggles (e.g., toolbar buttons, filters) - * Use SwitchField for traditional on/off switches (e.g., settings) + * Displays a toggle (switch) for boolean input + * Maps to SAIL's a!toggleField() */ export interface ToggleFieldProps { - /** Text to display as the field label */ - label?: string - /** Text to display on the toggle button */ - text?: string - /** Supplemental text about this field */ - instructions?: string + /** Text to display as the label next to the toggle */ + choiceLabel?: string /** Determines if a value is required to submit the form */ required?: boolean /** Determines if the field should display as grayed out */ disabled?: boolean - /** Current pressed state (true = pressed, false = unpressed) */ + /** Current checked state (true = on, false = off) */ value?: boolean /** Validation errors to display below the field */ validations?: string[] - /** Callback when the user toggles the button */ + /** Callback when the user toggles the switch */ saveInto?: (value: boolean) => void - /** Callback when the user toggles the button (React-style alias for saveInto) */ + /** Callback when the user toggles the switch (React-style alias for saveInto) */ onChange?: (value: boolean) => void /** Validation group name (no spaces) */ validationGroup?: string /** Custom message when field is required and not provided */ requiredMessage?: string - /** Determines where the label appears */ - labelPosition?: SAILLabelPosition - /** Displays a help icon with tooltip text */ + /** Displays a help icon next to the choice label with tooltip text */ helpTooltip?: string /** Additional text for screen readers */ accessibilityText?: string @@ -54,24 +37,14 @@ export interface ToggleFieldProps { marginAbove?: SAILMarginSize /** Space added below component */ marginBelow?: SAILMarginSize - /** Size of the toggle button */ - size?: SAILSize - /** Color when toggle is pressed (hex or semantic) */ - color?: "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | "STANDARD" | SAILColorInput - /** Determines the button's appearance */ - style?: ToggleStyle - /** Icon to display in the button */ - icon?: string - /** Position of icon relative to text */ - iconPosition?: "START" | "END" + /** Determines whether the toggle appears on the left or right of the choice label. Valid values: "START" (default), "END" */ + choicePosition?: "START" | "END" /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ className?: string } export const ToggleField: React.FC = ({ - label, - text, - instructions, + choiceLabel, required = false, disabled = false, value = false, @@ -80,168 +53,111 @@ export const ToggleField: React.FC = ({ onChange, validationGroup: _validationGroup, requiredMessage, - labelPosition = "ABOVE", helpTooltip, accessibilityText, showWhen = true, marginAbove = "NONE", marginBelow = "STANDARD", - size = "STANDARD", - color = "ACCENT", - style = "SOLID", - icon, - iconPosition = "START", + choicePosition = "START", className }) => { // Visibility control if (!showWhen) return null - const inputId = `togglefield-${Math.random().toString(36).substr(2, 9)}` - - // Get color classes based on style and pressed state - const getColorClasses = (): string => { - if (color.startsWith('#') || isPaletteColor(color)) return 'border' - - const semanticColor = color as "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | "STANDARD" - - if (style === "SOLID") { - const solidColors: Record = { - ACCENT: 'border border-blue-500 text-blue-500 bg-white hover:bg-blue-100 data-[state=on]:bg-blue-500 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-blue-500', - POSITIVE: 'border border-green-700 text-green-700 bg-white hover:bg-green-100 data-[state=on]:bg-green-700 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-green-700', - NEGATIVE: 'border border-red-700 text-red-700 bg-white hover:bg-red-100 data-[state=on]:bg-red-700 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-red-700', - SECONDARY: 'border border-gray-700 text-gray-700 bg-white hover:bg-gray-100 data-[state=on]:bg-gray-700 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-gray-700', - STANDARD: 'border border-gray-900 text-gray-900 bg-white hover:bg-gray-200 data-[state=on]:bg-gray-900 data-[state=on]:text-white data-[state=on]:border-transparent hover:data-[state=on]:bg-gray-900' - } - return solidColors[semanticColor] - } - - if (style === "OUTLINE") { - const outlineColors: Record = { - ACCENT: 'border border-blue-500 text-blue-500 bg-white hover:bg-blue-100 data-[state=on]:bg-blue-50', - POSITIVE: 'border border-green-700 text-green-700 bg-white hover:bg-green-100 data-[state=on]:bg-green-50', - NEGATIVE: 'border border-red-700 text-red-700 bg-white hover:bg-red-100 data-[state=on]:bg-red-50', - SECONDARY: 'border border-gray-700 text-gray-700 bg-white hover:bg-gray-100 data-[state=on]:bg-gray-50', - STANDARD: 'border border-gray-900 text-gray-900 bg-white hover:bg-gray-200 data-[state=on]:bg-gray-50' - } - return outlineColors[semanticColor] - } - - if (style === "GHOST") { - const ghostColors: Record = { - ACCENT: 'border border-transparent text-blue-500 hover:bg-blue-100 data-[state=on]:bg-blue-50', - POSITIVE: 'border border-transparent text-green-700 hover:bg-green-100 data-[state=on]:bg-green-50', - NEGATIVE: 'border border-transparent text-red-700 hover:bg-red-100 data-[state=on]:bg-red-50', - SECONDARY: 'border border-transparent text-gray-700 hover:bg-gray-100 data-[state=on]:bg-gray-50', - STANDARD: 'border border-transparent text-gray-900 hover:bg-gray-200 data-[state=on]:bg-gray-50' - } - return ghostColors[semanticColor] - } - - return '' - } - - // Resolve a non-semantic color to a CSS value for inline styles - const resolveInlineColor = (): string | undefined => { - if (color.startsWith('#')) return color - if (isPaletteColor(color)) { - const segment = paletteColorMap[color].bg.replace('bg-', '') - return `var(--color-${segment})` - } - return undefined - } - - const inlineColor = resolveInlineColor() + const inputId = `togglefield-${Math.random().toString(36).substring(2, 11)}` - const handleChange = (pressed: boolean) => { + const handleChange = (checked: boolean) => { const handler = onChange || saveInto if (handler && !disabled) { - handler(pressed) + handler(checked) } } - // Map any Lucide icon name directly, with SAIL compatibility fallbacks - const getIconComponent = (iconName: string) => { - // First try direct Lucide icon name (kebab-case or PascalCase) - const kebabToPascal = (str: string) => - str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('') - - const pascalIconName = kebabToPascal(iconName) - if (pascalIconName in LucideIcons) { - return LucideIcons[pascalIconName as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> - } - - // Also try direct case-insensitive lookup - const directIconName = iconName.charAt(0).toUpperCase() + iconName.slice(1).toLowerCase() - if (directIconName in LucideIcons) { - return LucideIcons[directIconName as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> - } - - // Fallback to SAIL compatibility mapping - const sailIconMap: Record = { - 'STAR': 'Star', - 'HOME': 'Home', - 'USER': 'User', - 'SETTINGS': 'Settings', - 'SEARCH': 'Search', - 'FILTER': 'Filter', - 'ARROW_RIGHT': 'ArrowRight', - 'ARROW_LEFT': 'ArrowLeft', - 'ARROW_UP': 'ArrowUp', - 'ARROW_DOWN': 'ArrowDown' - } - - const lucideIconName = sailIconMap[iconName] - if (lucideIconName && lucideIconName in LucideIcons) { - return LucideIcons[lucideIconName] as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> - } - - // Fallback to a generic icon - return LucideIcons.Circle as React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> - } - // Show validation errors const showValidations = validations.length > 0 - // Show required message - const showRequiredMessage = required && !value && requiredMessage + // Show required message (default per SAIL docs: "Enable the toggle to continue") + const resolvedRequiredMessage = requiredMessage || "Enable the toggle to continue" + const showRequiredMessage = required && !value - // Toggle button element - const toggleElement = ( - - {icon && iconPosition === "START" && ( -