UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

1,110 lines (1,038 loc) • 45.8 kB
import type { ComposeAlertDialogProps, ComposeAnimatedVisibilityProps, ComposeAnimatedVisibilityTransition, ComposeAssistChipProps, ComposeBadgeProps, ComposeBoxProps, ComposeButtonProps, ComposeCardProps, ComposeCheckboxProps, ComposeColumnProps, ComposeDialogProps, ComposeDividerProps, ComposeDropdownMenuItemProps, ComposeDropdownMenuProps, ComposeExposedDropdownMenuBoxProps, ComposeFilterChipProps, ComposeFlowRowProps, ComposeFloatingActionButtonProps, ComposeIconProps, ComposeIconButtonProps, ComposeInputChipProps, ComposeListItemProps, ComposeNavigationBarItemProps, ComposeNavigationBarProps, ComposeLoadingIndicatorProps, ComposeContainedLoadingIndicatorProps, ComposeProgressIndicatorProps, ComposePullToRefreshBoxProps, ComposeRadioButtonProps, ComposeRowProps, ComposeSearchBarProps, ComposeSliderProps, ComposeSuggestionChipProps, ComposeStyle, ComposeSurfaceProps, ComposeSwitchProps, ComposeTextFieldProps, ComposeTextProps, ComposeToggleButtonProps, ComposeTooltipBoxProps, ComposeSegmentedButtonProps, ComposeDatePickerProps, ComposeTimePickerProps, ComposeImageContentScale, ComposeImageProps, ComposeLazyColumnProps, ComposeLazyRowProps, ComposeModalBottomSheetProps, ComposeSnackbarProps, ComposeSnackbarShowOptions, } from './composeTypes' import { composeDatePickerColorKeys, composeTimePickerColorKeys } from './composeTypes' import { composeIconCodepoints, type ComposeIconName } from './generated/composeIcons' import { isSyncState } from './syncStore' export const horizontalAlignments = ['start', 'centerHorizontally', 'end'] as const export const verticalAlignments = ['top', 'centerVertically', 'bottom'] as const export const contentAlignments = [ 'topStart', 'topCenter', 'topEnd', 'centerStart', 'center', 'centerEnd', 'bottomStart', 'bottomCenter', 'bottomEnd', 'top', 'bottom', 'start', 'end', ] as const export const verticalArrangements = [ 'top', 'center', 'bottom', 'spaceBetween', 'spaceAround', 'spaceEvenly', ] as const export const horizontalArrangements = [ 'start', 'center', 'end', 'spaceBetween', 'spaceAround', 'spaceEvenly', ] as const export const textAlignments = [ 'unspecified', 'left', 'right', 'center', 'justify', 'start', 'end', ] as const export const fontWeights = [ 'thin', 'extraLight', 'light', 'normal', 'medium', 'semiBold', 'bold', 'extraBold', 'black', ] as const export const buttonVariants = ['filled', 'tonal', 'elevated', 'outlined', 'text'] as const export const buttonTones = ['default', 'danger'] as const export const textFieldVariants = ['filled', 'outlined'] as const export const textFieldKeyboardTypes = [ 'default', 'number', 'decimal', 'email', 'password', 'phone', 'url', ] as const export const textFieldImeActions = [ 'default', 'none', 'go', 'search', 'send', 'previous', 'next', 'done', ] as const export const textFieldCapitalizations = [ 'none', 'characters', 'words', 'sentences', ] as const export const progressVariants = ['linear', 'circular', 'linearWavy', 'circularWavy'] as const export const progressStrokeCaps = ['round', 'butt', 'square'] as const const composeStyleKeys = new Set([ 'backgroundColor', 'foregroundColor', 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'width', 'height', 'fillMaxWidth', 'fillMaxHeight', 'weight', 'cornerRadius', 'opacity', 'borderColor', 'borderWidth', ]) const composeStyleNumberKeys = new Set([ 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'width', 'height', 'cornerRadius', 'borderWidth', ]) const composeStyleColorKeys = new Set([ 'backgroundColor', 'foregroundColor', 'borderColor', ]) const checkboxColorKeys = new Set([ 'checkedColor', 'disabledCheckedColor', 'uncheckedColor', 'disabledUncheckedColor', 'checkmarkColor', ]) const radioColorKeys = new Set([ 'selectedColor', 'unselectedColor', 'disabledSelectedColor', 'disabledUnselectedColor', ]) const cardColorKeys = new Set(['containerColor', 'contentColor']) const listItemColorKeys = new Set([ 'containerColor', 'contentColor', 'leadingContentColor', 'trailingContentColor', 'supportingContentColor', 'overlineContentColor', ]) export function validateListItemProps(props: ComposeListItemProps) { if (props.colors !== undefined) assertComposeColors(props.colors, listItemColorKeys, 'ListItem') if (props.tonalElevation !== undefined) { assertFiniteNumber(props.tonalElevation, 'ListItem tonalElevation') if (props.tonalElevation < 0) throw new Error('Compose ListItem tonalElevation must be nonnegative') } if (props.shadowElevation !== undefined) { assertFiniteNumber(props.shadowElevation, 'ListItem shadowElevation') if (props.shadowElevation < 0) throw new Error('Compose ListItem shadowElevation must be nonnegative') } } export function validateBadgeProps( props: Pick<ComposeBadgeProps, 'containerColor' | 'contentColor'> ) { if (props.containerColor !== undefined) assertComposeColorValue(props.containerColor, 'Badge containerColor') if (props.contentColor !== undefined) assertComposeColorValue(props.contentColor, 'Badge contentColor') } const assistChipColorKeys = new Set([ 'containerColor', 'labelColor', 'leadingIconContentColor', 'trailingIconContentColor', ]) const filterChipColorKeys = new Set([ 'containerColor', 'labelColor', 'iconColor', 'selectedContainerColor', 'selectedLabelColor', 'selectedLeadingIconColor', 'selectedTrailingIconColor', ]) const inputChipColorKeys = new Set([ 'containerColor', 'labelColor', 'leadingIconColor', 'trailingIconColor', 'selectedContainerColor', 'selectedLabelColor', 'selectedLeadingIconColor', 'selectedTrailingIconColor', ]) const suggestionChipColorKeys = new Set([ 'containerColor', 'labelColor', 'iconContentColor', ]) const iconButtonColorKeys = new Set([ 'containerColor', 'contentColor', 'disabledContainerColor', 'disabledContentColor', ]) const toggleButtonColorKeys = new Set([ 'containerColor', 'contentColor', 'checkedContainerColor', 'checkedContentColor', 'disabledContainerColor', 'disabledContentColor', ]) const segmentedButtonColorKeys = new Set([ 'activeBorderColor', 'activeContentColor', 'inactiveBorderColor', 'inactiveContentColor', 'disabledActiveBorderColor', 'disabledActiveContentColor', 'disabledInactiveBorderColor', 'disabledInactiveContentColor', 'activeContainerColor', 'inactiveContainerColor', 'disabledActiveContainerColor', 'disabledInactiveContainerColor', ]) const switchColorKeys = new Set([ 'checkedThumbColor', 'checkedTrackColor', 'checkedBorderColor', 'checkedIconColor', 'uncheckedThumbColor', 'uncheckedTrackColor', 'uncheckedBorderColor', 'uncheckedIconColor', 'disabledCheckedThumbColor', 'disabledCheckedTrackColor', 'disabledCheckedBorderColor', 'disabledCheckedIconColor', 'disabledUncheckedThumbColor', 'disabledUncheckedTrackColor', 'disabledUncheckedBorderColor', 'disabledUncheckedIconColor', ]) const sliderColorKeys = new Set([ 'thumbColor', 'activeTrackColor', 'inactiveTrackColor', 'activeTickColor', 'inactiveTickColor', ]) function assertComposeColorValue(value: unknown, name: string) { const resourcePaths = value && typeof value === 'object' && 'resource_paths' in value ? value.resource_paths : undefined if ( (typeof value !== 'string' || !value.trim()) && (typeof value !== 'number' || !Number.isFinite(value)) && (!Array.isArray(resourcePaths) || resourcePaths.length === 0 || resourcePaths.some((path) => typeof path !== 'string' || !path)) ) throw new Error(`Compose ${name} must be a color value`) } function assertComposeColors( colors: unknown, keys: ReadonlySet<string>, owner: string ) { if (!colors || typeof colors !== 'object' || Array.isArray(colors)) throw new Error(`Compose ${owner} colors must be an object`) for (const [key, value] of Object.entries(colors)) { if (!keys.has(key)) throw new Error(`Compose ${owner} colors does not support ${key}`) if (value !== undefined) assertComposeColorValue(value, `${owner} colors ${key}`) } } export function assertComposeStyle(style: ComposeStyle | undefined) { if (style === undefined) return if (!style || typeof style !== 'object' || Array.isArray(style)) throw new Error('Compose composeStyle must be an object') for (const key of Object.keys(style)) { if (!composeStyleKeys.has(key)) throw new Error(`Compose composeStyle does not support ${key}`) const value = style[key as keyof ComposeStyle] if (value === undefined) continue if (composeStyleNumberKeys.has(key)) { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error(`Compose composeStyle ${key} must be a nonnegative finite number`) continue } if (key === 'opacity') { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) throw new Error('Compose composeStyle opacity must be a number from 0 to 1') continue } if (key === 'weight') { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error('Compose composeStyle weight must be a positive finite number') continue } if (composeStyleColorKeys.has(key)) { assertComposeColorValue(value, `composeStyle ${key}`) continue } if (typeof value !== 'boolean') throw new Error(`Compose composeStyle ${key} must be a boolean`) } } export function assertString( value: unknown, name: string, nonEmpty = false ): asserts value is string { if (typeof value !== 'string' || (nonEmpty && !value.trim())) throw new Error(`Compose ${name} must be${nonEmpty ? ' a non-empty' : ''} string`) } export function assertOptionalString(value: unknown, name: string) { if (value !== undefined) assertString(value, name) } export function assertBoolean(value: unknown, name: string) { if (typeof value !== 'boolean') throw new Error(`Compose ${name} must be a boolean`) } export function assertOptionalBoolean(value: unknown, name: string) { if (value !== undefined) assertBoolean(value, name) } export function assertOneOf<T extends string>( value: unknown, name: string, values: readonly T[] ) { if (!values.includes(value as T)) throw new Error(`Compose ${name} must be one of ${values.join(', ')}`) } export function assertFiniteNumber(value: unknown, name: string) { if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Compose ${name} must be a finite number`) } export function assertFunction(value: unknown, name: string) { if (typeof value !== 'function') throw new Error(`Compose ${name} must be a function`) } export function validateColumnProps(props: ComposeColumnProps) { const horizontalAlignment = props.horizontalAlignment ?? 'start' const verticalArrangement = props.verticalArrangement ?? 'top' assertOneOf(horizontalAlignment, 'Column horizontalAlignment', horizontalAlignments) assertOneOf(verticalArrangement, 'Column verticalArrangement', verticalArrangements) if (props.spacing !== undefined && (!Number.isFinite(props.spacing) || props.spacing < 0)) throw new Error('Compose Column spacing must be a nonnegative finite number') if (props.spacing !== undefined && verticalArrangement.startsWith('space')) throw new Error( 'Compose Column spacing cannot be combined with a space-distribution arrangement' ) } export function validateRowProps(props: ComposeRowProps) { const verticalAlignment = props.verticalAlignment ?? 'top' const horizontalArrangement = props.horizontalArrangement ?? 'start' assertOneOf(verticalAlignment, 'Row verticalAlignment', verticalAlignments) assertOneOf(horizontalArrangement, 'Row horizontalArrangement', horizontalArrangements) if (props.spacing !== undefined && (!Number.isFinite(props.spacing) || props.spacing < 0)) throw new Error('Compose Row spacing must be a nonnegative finite number') if (props.spacing !== undefined && horizontalArrangement.startsWith('space')) throw new Error( 'Compose Row spacing cannot be combined with a space-distribution arrangement' ) } export function validateFlowRowProps(props: ComposeFlowRowProps) { function arrangement(value: unknown, name: string, names: readonly string[]) { if (typeof value === 'string') { if (!names.includes(value)) throw new Error(`Compose FlowRow ${name} is unsupported`) return } if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1 || !('spacedBy' in value) || typeof value.spacedBy !== 'number' || !Number.isFinite(value.spacedBy) || value.spacedBy < 0) throw new Error(`Compose FlowRow ${name} spacedBy must be a nonnegative finite number`) } arrangement(props.horizontalArrangement ?? 'start', 'horizontalArrangement', horizontalArrangements) arrangement(props.verticalArrangement ?? 'top', 'verticalArrangement', verticalArrangements) } export function validateBoxProps(props: ComposeBoxProps) { assertOneOf( props.contentAlignment ?? 'topStart', 'Box contentAlignment', contentAlignments ) } export function validateTextProps(props: ComposeTextProps) { assertString(props.text, 'Text text') if (props.fontSize !== undefined && (!Number.isFinite(props.fontSize) || props.fontSize <= 0)) throw new Error('Compose Text fontSize must be a positive finite number') if (props.fontWeight !== undefined) assertOneOf(props.fontWeight, 'Text fontWeight', fontWeights) if (props.textAlign !== undefined) assertOneOf(props.textAlign, 'Text textAlign', textAlignments) if (props.maxLines !== undefined && (!Number.isInteger(props.maxLines) || props.maxLines <= 0)) throw new Error('Compose Text maxLines must be a positive integer') } export function composeIconGlyph(owner: string, name: string) { if (!Object.hasOwn(composeIconCodepoints, name)) throw new Error( `Compose ${owner} must be a Material Symbols name, got ${JSON.stringify(name)}` ) return String.fromCodePoint(composeIconCodepoints[name as ComposeIconName]) } export function validateIconProps(props: ComposeIconProps) { assertString(props.name, 'Icon name', true) if (props.size !== undefined && (!Number.isFinite(props.size) || props.size <= 0)) throw new Error('Compose Icon size must be a positive finite number') assertOptionalBoolean(props.filled, 'Icon filled') composeIconGlyph('Icon name', props.name) } export function validateButtonProps(props: ComposeButtonProps) { assertString(props.label, 'Button label', true) assertBoolean(props.disabled ?? false, 'Button disabled') assertOneOf(props.variant ?? 'filled', 'Button variant', buttonVariants) assertOneOf(props.tone ?? 'default', 'Button tone', buttonTones) assertBoolean(props.iconFilled ?? false, 'Button iconFilled') if (props.icon !== undefined) composeIconGlyph('Button icon', props.icon) if (props.onPress !== undefined) assertFunction(props.onPress, 'Button onPress') } export function validateIconButtonProps(props: ComposeIconButtonProps) { assertBoolean(props.enabled ?? true, 'IconButton enabled') if (props.onClick !== undefined) assertFunction(props.onClick, 'IconButton onClick') if (props.colors !== undefined) assertComposeColors(props.colors, iconButtonColorKeys, 'IconButton') } export function validateFloatingActionButtonProps(props: ComposeFloatingActionButtonProps & { expanded?: boolean }, variant: string) { assertOneOf(variant, 'FloatingActionButton variant', ['small', 'medium', 'large', 'extended'] as const) assertBoolean(props.expanded ?? true, 'FloatingActionButton expanded') if (props.containerColor !== undefined) assertComposeColorValue(props.containerColor, 'FloatingActionButton containerColor') if (props.onClick !== undefined) assertFunction(props.onClick, 'FloatingActionButton onClick') } export function validateToggleButtonProps(props: ComposeToggleButtonProps) { assertBoolean(props.checked, 'ToggleButton checked') assertBoolean(props.enabled ?? true, 'ToggleButton enabled') if (props.onCheckedChange !== undefined) assertFunction(props.onCheckedChange, 'ToggleButton onCheckedChange') if (props.colors !== undefined) assertComposeColors(props.colors, toggleButtonColorKeys, 'ToggleButton') } export function validateSegmentedButtonProps(props: ComposeSegmentedButtonProps) { if (props.selected !== undefined) assertBoolean(props.selected, 'SegmentedButton selected') if (props.checked !== undefined) assertBoolean(props.checked, 'SegmentedButton checked') assertBoolean(props.enabled ?? true, 'SegmentedButton enabled') if (props.onClick !== undefined) assertFunction(props.onClick, 'SegmentedButton onClick') if (props.onCheckedChange !== undefined) assertFunction(props.onCheckedChange, 'SegmentedButton onCheckedChange') if (props.colors !== undefined) assertComposeColors(props.colors, segmentedButtonColorKeys, 'SegmentedButton') } export function validateSwitchProps(props: ComposeSwitchProps) { assertBoolean(props.isOn, 'Switch isOn') assertBoolean(props.disabled ?? false, 'Switch disabled') assertString(props.label ?? '', 'Switch label') assertFunction(props.onIsOnChange, 'Switch onIsOnChange') if (props.colors !== undefined) assertComposeColors(props.colors, switchColorKeys, 'Switch') } export function validateCheckboxProps(props: ComposeCheckboxProps) { assertBoolean(props.value, 'Checkbox value') assertBoolean(props.disabled ?? false, 'Checkbox disabled') if (props.onCheckedChange !== undefined) assertFunction(props.onCheckedChange, 'Checkbox onCheckedChange') if (props.colors !== undefined) assertComposeColors(props.colors, checkboxColorKeys, 'Checkbox') } export function validateRadioButtonProps(props: ComposeRadioButtonProps) { assertBoolean(props.selected, 'RadioButton selected') assertBoolean(props.disabled ?? false, 'RadioButton disabled') if (props.onClick !== undefined) assertFunction(props.onClick, 'RadioButton onClick') if (props.colors !== undefined) assertComposeColors(props.colors, radioColorKeys, 'RadioButton') } export function validateCardProps( props: ComposeCardProps, kind: 'Card' | 'ElevatedCard' | 'OutlinedCard' ) { if (props.colors !== undefined) assertComposeColors(props.colors, cardColorKeys, kind) if (props.elevation !== undefined) { assertFiniteNumber(props.elevation, `${kind} elevation`) if (props.elevation < 0) throw new Error(`Compose ${kind} elevation must be nonnegative`) } if (kind === 'ElevatedCard' && props.border !== undefined) throw new Error('Compose ElevatedCard does not support border') assertComposeBorder(props.border, kind) } export function validateSurfaceProps(props: ComposeSurfaceProps) { if (props.color !== undefined) assertComposeColorValue(props.color, 'Surface color') if (props.contentColor !== undefined) assertComposeColorValue(props.contentColor, 'Surface contentColor') if (props.tonalElevation !== undefined) { assertFiniteNumber(props.tonalElevation, 'Surface tonalElevation') if (props.tonalElevation < 0) throw new Error('Compose Surface tonalElevation must be nonnegative') } if (props.shadowElevation !== undefined) { assertFiniteNumber(props.shadowElevation, 'Surface shadowElevation') if (props.shadowElevation < 0) throw new Error('Compose Surface shadowElevation must be nonnegative') } assertComposeBorder(props.border, 'Surface') assertOptionalBoolean(props.enabled, 'Surface enabled') assertOptionalBoolean(props.selected, 'Surface selected') assertOptionalBoolean(props.checked, 'Surface checked') if (props.onClick !== undefined) assertFunction(props.onClick, 'Surface onClick') if (props.onCheckedChange !== undefined) assertFunction(props.onCheckedChange, 'Surface onCheckedChange') } function assertComposeBorder(border: ComposeCardProps['border'], kind: string) { if (border === undefined) return if (!border || typeof border !== 'object' || Array.isArray(border)) throw new Error(`Compose ${kind} border must be an object`) for (const key of Object.keys(border)) if (key !== 'width' && key !== 'color') throw new Error(`Compose ${kind} border does not support ${key}`) if (border.width !== undefined) { assertFiniteNumber(border.width, `${kind} border width`) if (border.width < 0) throw new Error(`Compose ${kind} border width must be nonnegative`) } if (border.color !== undefined) assertComposeColorValue(border.color, `${kind} border color`) } export function validateChipProps( props: | ComposeAssistChipProps | ComposeFilterChipProps | ComposeInputChipProps | ComposeSuggestionChipProps, kind: 'AssistChip' | 'FilterChip' | 'InputChip' | 'SuggestionChip' ) { const selected = 'selected' in props ? props.selected : undefined if (kind === 'FilterChip') assertBoolean(selected, `${kind} selected`) else if (kind === 'InputChip' && selected !== undefined) assertBoolean(selected, `${kind} selected`) assertBoolean(props.enabled ?? true, `${kind} enabled`) if (props.onClick !== undefined) assertFunction(props.onClick, `${kind} onClick`) if (props.colors !== undefined) { const keys = kind === 'AssistChip' ? assistChipColorKeys : kind === 'FilterChip' ? filterChipColorKeys : kind === 'InputChip' ? inputChipColorKeys : suggestionChipColorKeys assertComposeColors(props.colors, keys, kind) } if (props.elevation !== undefined) { assertFiniteNumber(props.elevation, `${kind} elevation`) if (props.elevation < 0) throw new Error(`Compose ${kind} elevation must be nonnegative`) } assertComposeBorder(props.border, kind) } export function validateDividerProps( props: ComposeDividerProps, kind: 'HorizontalDivider' | 'VerticalDivider' ) { if (props.thickness !== undefined) { assertFiniteNumber(props.thickness, `${kind} thickness`) if (props.thickness < 0) throw new Error(`Compose ${kind} thickness must be nonnegative`) } if (props.color !== undefined) assertComposeColorValue(props.color, `${kind} color`) } export function validateTextFieldProps(props: ComposeTextFieldProps) { if (typeof props.text !== 'string' && !isSyncState(props.text)) throw new Error('Compose TextField text must be a string or NativeState handle') assertFunction(props.onTextChange, 'TextField onTextChange') assertOptionalString(props.label, 'TextField label') assertOptionalString(props.placeholder, 'TextField placeholder') assertOptionalBoolean(props.disabled, 'TextField disabled') if (props.variant !== undefined) assertOneOf(props.variant, 'TextField variant', textFieldVariants) if (props.keyboardType !== undefined) assertOneOf(props.keyboardType, 'TextField keyboardType', textFieldKeyboardTypes) assertOptionalBoolean(props.secureText, 'TextField secureText') assertOptionalBoolean(props.focused, 'TextField focused') if (props.onFocusChange !== undefined) assertFunction(props.onFocusChange, 'TextField onFocusChange') if (props.imeAction !== undefined) assertOneOf(props.imeAction, 'TextField imeAction', textFieldImeActions) if (props.onSubmit !== undefined) assertFunction(props.onSubmit, 'TextField onSubmit') if ( props.maxLength !== undefined && (!Number.isInteger(props.maxLength) || props.maxLength < 0) ) throw new Error('Compose TextField maxLength must be a nonnegative integer') assertOptionalBoolean(props.multiline, 'TextField multiline') if (props.capitalization !== undefined) assertOneOf(props.capitalization, 'TextField capitalization', textFieldCapitalizations) assertOptionalBoolean(props.autoCorrect, 'TextField autoCorrect') if (props.textAlign !== undefined) assertOneOf(props.textAlign, 'TextField textAlign', textAlignments) } export function validateSliderProps(props: ComposeSliderProps) { const minimumValue = props.minimumValue ?? 0 const maximumValue = props.maximumValue ?? 1 const step = props.step ?? 0 assertFiniteNumber(props.value, 'Slider value') assertFiniteNumber(minimumValue, 'Slider minimumValue') assertFiniteNumber(maximumValue, 'Slider maximumValue') assertFiniteNumber(step, 'Slider step') if (props.lowerLimit !== undefined) assertFiniteNumber(props.lowerLimit, 'Slider lowerLimit') if (props.upperLimit !== undefined) assertFiniteNumber(props.upperLimit, 'Slider upperLimit') if ( (props.lowerLimit !== undefined && !Number.isFinite(Math.fround(props.lowerLimit))) || (props.upperLimit !== undefined && !Number.isFinite(Math.fround(props.upperLimit))) ) throw new Error('Compose Slider limits must be representable by Android Float values') if (minimumValue >= maximumValue) throw new Error('Compose Slider minimumValue must be less than maximumValue') if ( !Number.isFinite(Math.fround(minimumValue)) || !Number.isFinite(Math.fround(maximumValue)) || Math.fround(minimumValue) >= Math.fround(maximumValue) ) throw new Error('Compose Slider range must be representable by Android Float values') if (step < 0) throw new Error('Compose Slider step must be a nonnegative finite number') if (step > 0) { const intervals = (maximumValue - minimumValue) / step const roundedIntervals = Math.round(intervals) if ( Math.abs(intervals - roundedIntervals) > Number.EPSILON * Math.max(1, Math.abs(intervals)) * 8 ) throw new Error('Compose Slider step must evenly divide its range') if (roundedIntervals > 1001) throw new Error('Compose Slider step must produce at most 1001 intervals') } if (props.value < minimumValue || props.value > maximumValue) throw new Error('Compose Slider value must be between minimumValue and maximumValue') if ( Math.max(Math.fround(minimumValue), Math.fround(props.lowerLimit ?? minimumValue)) > Math.min(Math.fround(maximumValue), Math.fround(props.upperLimit ?? maximumValue)) ) throw new Error('Compose Slider limits must overlap the value range') assertFunction(props.onValueChange, 'Slider onValueChange') assertOptionalBoolean(props.disabled, 'Slider disabled') if (props.colors !== undefined) assertComposeColors(props.colors, sliderColorKeys, 'Slider') } export function validateAlertDialogProps(props: ComposeAlertDialogProps) { assertBoolean(props.visible, 'AlertDialog visible') assertOptionalString(props.title, 'AlertDialog title') assertOptionalString(props.message, 'AlertDialog message') assertString(props.confirmLabel, 'AlertDialog confirmLabel', true) assertOptionalString(props.dismissLabel, 'AlertDialog dismissLabel') assertFunction(props.onConfirm, 'AlertDialog onConfirm') assertFunction(props.onDismiss, 'AlertDialog onDismiss') } export function validateDialogProps(props: ComposeDialogProps) { assertBoolean(props.visible, 'Dialog visible') assertFunction(props.onDismiss, 'Dialog onDismiss') } export function validateProgressIndicatorProps(props: ComposeProgressIndicatorProps) { if (props.variant !== undefined) assertOneOf(props.variant, 'ProgressIndicator variant', progressVariants) if ( props.progress != null && (typeof props.progress !== 'number' || !Number.isFinite(props.progress) || props.progress < 0 || props.progress > 1) ) throw new Error('Compose ProgressIndicator progress must be a number from 0 to 1') if (props.color !== undefined) assertComposeColorValue(props.color, 'ProgressIndicator color') if (props.trackColor !== undefined) assertComposeColorValue(props.trackColor, 'ProgressIndicator trackColor') const variant = props.variant ?? 'circular' if (props.strokeCap !== undefined) { assertOneOf(props.strokeCap, 'ProgressIndicator strokeCap', progressStrokeCaps) if (variant !== 'linear' && variant !== 'circular') throw new Error('Compose ProgressIndicator strokeCap requires a regular indicator') } for (const key of ['gapSize', 'strokeWidth', 'stopSize', 'amplitude', 'wavelength', 'waveSpeed'] as const) { const value = props[key] if (value === undefined) continue assertFiniteNumber(value, `ProgressIndicator ${key}`) if (value < 0 || (key === 'amplitude' && value > 1)) throw new Error(`Compose ProgressIndicator ${key} is out of range`) } if (props.gapSize !== undefined && variant !== 'linear' && variant !== 'circular') throw new Error('Compose ProgressIndicator gapSize requires a regular indicator') if (props.strokeWidth !== undefined && variant !== 'circular') throw new Error('Compose ProgressIndicator strokeWidth requires a circular indicator') if (props.stopSize !== undefined && variant !== 'linearWavy') throw new Error('Compose ProgressIndicator stopSize requires a linear wavy indicator') if ((props.amplitude !== undefined || props.wavelength !== undefined || props.waveSpeed !== undefined) && variant !== 'linearWavy' && variant !== 'circularWavy') throw new Error('Compose ProgressIndicator wave settings require a wavy indicator') if (props.drawStopIndicator !== undefined) { if (variant !== 'linear' || props.progress == null) throw new Error('Compose ProgressIndicator drawStopIndicator requires determinate linear progress') const stop = props.drawStopIndicator if (typeof stop !== 'object' || stop === null || Array.isArray(stop)) throw new Error('Compose ProgressIndicator drawStopIndicator must be an object') for (const key of Object.keys(stop)) if (key !== 'color' && key !== 'strokeCap' && key !== 'stopSize') throw new Error(`Compose ProgressIndicator drawStopIndicator has unknown key ${key}`) if (stop.color !== undefined) assertComposeColorValue(stop.color, 'ProgressIndicator drawStopIndicator color') if (stop.strokeCap !== undefined) assertOneOf(stop.strokeCap, 'ProgressIndicator drawStopIndicator strokeCap', progressStrokeCaps) if (stop.stopSize !== undefined) { assertFiniteNumber(stop.stopSize, 'ProgressIndicator drawStopIndicator stopSize') if (stop.stopSize < 0) throw new Error('Compose ProgressIndicator drawStopIndicator stopSize must be nonnegative') } } } export function validateLoadingIndicatorProps(props: ComposeLoadingIndicatorProps | ComposeContainedLoadingIndicatorProps) { if (props.progress != null && (typeof props.progress !== 'number' || !Number.isFinite(props.progress) || props.progress < 0 || props.progress > 1)) throw new Error('Compose LoadingIndicator progress must be a number from 0 to 1') if (props.color !== undefined) assertComposeColorValue(props.color, 'LoadingIndicator color') if ('containerColor' in props && props.containerColor !== undefined) assertComposeColorValue(props.containerColor, 'ContainedLoadingIndicator containerColor') } const datePickerColorKeys = new Set<string>(composeDatePickerColorKeys) const timePickerColorKeys = new Set<string>(composeTimePickerColorKeys) const pickerVariants = ['picker', 'input'] as const function assertDate(value: unknown, name: string) { if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new Error(`Compose ${name} must be a valid Date`) } // material date pickers select whole days as utc midnight millis export function composeUtcDay(date: Date): number { return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) } function validatePickerShared( props: Pick<ComposeDatePickerProps, 'selection' | 'variant' | 'color'>, owner: string ) { assertDate(props.selection, `${owner} selection`) if (props.variant !== undefined) assertOneOf(props.variant, `${owner} variant`, pickerVariants) if (props.color !== undefined) assertComposeColorValue(props.color, `${owner} color`) } export function validateDatePickerProps( props: Omit<ComposeDatePickerProps, 'onSelectionChange'>, owner = 'DatePicker' ) { validatePickerShared(props, owner) assertOptionalBoolean(props.showModeToggle, `${owner} showModeToggle`) if (props.minimumDate !== undefined) assertDate(props.minimumDate, `${owner} minimumDate`) if (props.maximumDate !== undefined) assertDate(props.maximumDate, `${owner} maximumDate`) const day = composeUtcDay(props.selection) const minimum = props.minimumDate && composeUtcDay(props.minimumDate) const maximum = props.maximumDate && composeUtcDay(props.maximumDate) if (minimum !== undefined && maximum !== undefined && minimum > maximum) throw new Error(`Compose ${owner} minimumDate must not be after maximumDate`) if ((minimum !== undefined && day < minimum) || (maximum !== undefined && day > maximum)) throw new Error(`Compose ${owner} selection must be between minimumDate and maximumDate`) if (props.colors !== undefined) assertComposeColors(props.colors, datePickerColorKeys, owner) } export function validateTimePickerProps( props: Omit<ComposeTimePickerProps, 'onSelectionChange'>, owner = 'TimePicker' ) { validatePickerShared(props, owner) assertOptionalBoolean(props.is24Hour, `${owner} is24Hour`) if (props.colors !== undefined) assertComposeColors(props.colors, timePickerColorKeys, owner) } export function validatePickerDialogProps( props: { visible: unknown onConfirm: unknown onDismiss: unknown confirmLabel?: unknown dismissLabel?: unknown }, owner: string ) { assertBoolean(props.visible, `${owner} visible`) assertFunction(props.onConfirm, `${owner} onConfirm`) assertFunction(props.onDismiss, `${owner} onDismiss`) assertOptionalString(props.confirmLabel, `${owner} confirmLabel`) assertOptionalString(props.dismissLabel, `${owner} dismissLabel`) } export const lazyAlignments = ['start', 'end', 'center'] as const export const lazyRowAlignments = ['top', 'bottom', 'center'] as const const lazyContentPaddingKeys = ['start', 'end', 'top', 'bottom'] as const export const imageContentScales: readonly ComposeImageContentScale[] = [ 'fit', 'crop', 'fillBounds', 'fillWidth', 'fillHeight', 'inside', 'none', ] const snackbarColorKeys = new Set([ 'containerColor', 'contentColor', 'actionColor', 'actionContentColor', 'dismissActionContentColor', ]) const snackbarPropKeys = new Set([...snackbarColorKeys, 'actionOnNewLine']) export const snackbarDurations = ['short', 'long', 'indefinite'] as const const sheetPropertyKeys = new Set(['shouldDismissOnClickOutside']) function assertSpacedBy(value: unknown, owner: string, name: string) { if (value === undefined) return undefined if (typeof value === 'string') { assertOneOf( value, `${owner} ${name}`, name === 'horizontalArrangement' ? horizontalArrangements : verticalArrangements ) return value } if ( !value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1 || !('spacedBy' in value) ) throw new Error(`Compose ${owner} ${name} must be a preset or { spacedBy }`) const spacedBy = (value as { spacedBy: unknown }).spacedBy if (typeof spacedBy !== 'number' || !Number.isFinite(spacedBy) || spacedBy < 0) throw new Error(`Compose ${owner} ${name} spacedBy must be a nonnegative finite number`) return spacedBy } function assertLazyContentPadding(value: unknown, owner: string) { if (value === undefined) return if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Compose ${owner} contentPadding must be an object`) for (const [key, entry] of Object.entries(value as Record<string, unknown>)) { if (!(lazyContentPaddingKeys as readonly string[]).includes(key)) throw new Error(`Compose ${owner} contentPadding has unknown key ${key}`) if ( entry !== undefined && (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0) ) throw new Error(`Compose ${owner} contentPadding ${key} must be a nonnegative finite number`) } } export function validateLazyColumnProps(props: ComposeLazyColumnProps) { assertOneOf(props.horizontalAlignment ?? 'start', 'LazyColumn horizontalAlignment', lazyAlignments) assertSpacedBy(props.verticalArrangement ?? 'top', 'LazyColumn', 'verticalArrangement') assertLazyContentPadding(props.contentPadding, 'LazyColumn') } export function validateLazyRowProps(props: ComposeLazyRowProps) { assertOneOf(props.verticalAlignment ?? 'top', 'LazyRow verticalAlignment', lazyRowAlignments) assertSpacedBy(props.horizontalArrangement ?? 'start', 'LazyRow', 'horizontalArrangement') assertLazyContentPadding(props.contentPadding, 'LazyRow') } export function validateImageProps(props: ComposeImageProps) { const source = props.source as unknown const validSource = (typeof source === 'number' && Number.isFinite(source)) || (source !== null && typeof source === 'object' && !Array.isArray(source) && typeof (source as { uri: unknown }).uri === 'string' && (source as { uri: string }).uri.length > 0) if (!validSource) throw new Error('Compose Image source must be a { uri } object or a bundled asset id') if (props.contentScale !== undefined) assertOneOf(props.contentScale, 'Image contentScale', imageContentScales) if (props.alignment !== undefined) assertOneOf(props.alignment, 'Image alignment', contentAlignments) if ( props.contentDescription !== undefined && props.contentDescription !== null && typeof props.contentDescription !== 'string' ) throw new Error('Compose Image contentDescription must be a string or null') if (props.tint !== undefined) assertComposeColorValue(props.tint, 'Image tint') if (props.alpha !== undefined && (typeof props.alpha !== 'number' || !(props.alpha >= 0 && props.alpha <= 1))) throw new Error('Compose Image alpha must be a number from 0 to 1') if (props.onLoad !== undefined) assertFunction(props.onLoad, 'Image onLoad') if (props.onError !== undefined) assertFunction(props.onError, 'Image onError') } export function validateSnackbarProps(props: ComposeSnackbarProps) { for (const [key, value] of Object.entries(props)) { if (key === 'children') continue if (!snackbarPropKeys.has(key)) throw new Error(`Compose Snackbar colors does not support ${key}`) if (key === 'actionOnNewLine') { if (value !== undefined) assertBoolean(value, 'Snackbar actionOnNewLine') } else if (value !== undefined) assertComposeColorValue(value, `Snackbar ${key}`) } } export function validateSnackbarShowOptions(options: ComposeSnackbarShowOptions) { if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Compose SnackbarHost message must be a non-empty string') if (typeof options.message !== 'string' || !options.message.trim()) throw new Error('Compose SnackbarHost message must be a non-empty string') if (options.actionLabel !== undefined && typeof options.actionLabel !== 'string') throw new Error('Compose SnackbarHost actionLabel must be a string') if (options.duration !== undefined) assertOneOf(options.duration, 'SnackbarHost duration', snackbarDurations) if (options.withDismissAction !== undefined) assertBoolean(options.withDismissAction, 'SnackbarHost withDismissAction') } const navigationBarItemColorKeys = new Set([ 'selectedIconColor', 'selectedTextColor', 'indicatorColor', 'unselectedIconColor', 'unselectedTextColor', 'disabledIconColor', 'disabledTextColor', ]) export function validateNavigationBarProps( props: Pick<ComposeNavigationBarProps, 'containerColor' | 'contentColor' | 'tonalElevation'> ) { if (props.containerColor !== undefined) assertComposeColorValue(props.containerColor, 'NavigationBar containerColor') if (props.contentColor !== undefined) assertComposeColorValue(props.contentColor, 'NavigationBar contentColor') if (props.tonalElevation !== undefined) { assertFiniteNumber(props.tonalElevation, 'NavigationBar tonalElevation') if (props.tonalElevation < 0) throw new Error('Compose NavigationBar tonalElevation must be nonnegative') } } export function validateNavigationBarItemProps(props: ComposeNavigationBarItemProps) { assertBoolean(props.selected, 'NavigationBarItem selected') assertFunction(props.onClick, 'NavigationBarItem onClick') assertOptionalString(props.label, 'NavigationBarItem label') assertString(props.icon, 'NavigationBarItem icon', true) composeIconGlyph('NavigationBarItem icon', props.icon) assertOptionalBoolean(props.iconFilled, 'NavigationBarItem iconFilled') assertOptionalBoolean(props.enabled, 'NavigationBarItem enabled') assertOptionalBoolean(props.alwaysShowLabel, 'NavigationBarItem alwaysShowLabel') if (props.colors !== undefined) assertComposeColors(props.colors, navigationBarItemColorKeys, 'NavigationBarItem') } export function validateSearchBarProps(props: ComposeSearchBarProps, owner = 'SearchBar') { assertString(props.query, `${owner} query`) assertFunction(props.onQueryChange, `${owner} onQueryChange`) assertBoolean(props.expanded, `${owner} expanded`) assertFunction(props.onExpandedChange, `${owner} onExpandedChange`) assertOptionalString(props.placeholder, `${owner} placeholder`) assertOptionalBoolean(props.enabled, `${owner} enabled`) if (props.onSearch !== undefined) assertFunction(props.onSearch, `${owner} onSearch`) if (props.containerColor !== undefined) assertComposeColorValue(props.containerColor, `${owner} containerColor`) } const dropdownMenuItemColorKeys = new Set([ 'textColor', 'leadingIconColor', 'trailingIconColor', 'disabledTextColor', 'disabledLeadingIconColor', 'disabledTrailingIconColor', ]) export function validateDropdownMenuProps(props: ComposeDropdownMenuProps) { assertBoolean(props.expanded, 'DropdownMenu expanded') assertFunction(props.onDismissRequest, 'DropdownMenu onDismissRequest') if (props.containerColor !== undefined) assertComposeColorValue(props.containerColor, 'DropdownMenu containerColor') } export function validateDropdownMenuItemProps(props: ComposeDropdownMenuItemProps) { assertString(props.text, 'DropdownMenuItem text', true) assertFunction(props.onClick, 'DropdownMenuItem onClick') assertOptionalBoolean(props.enabled, 'DropdownMenuItem enabled') if (props.leadingIcon !== undefined) { assertString(props.leadingIcon, 'DropdownMenuItem leadingIcon', true) composeIconGlyph('DropdownMenuItem leadingIcon', props.leadingIcon) } if (props.trailingIcon !== undefined) { assertString(props.trailingIcon, 'DropdownMenuItem trailingIcon', true) composeIconGlyph('DropdownMenuItem trailingIcon', props.trailingIcon) } assertOptionalBoolean(props.iconFilled, 'DropdownMenuItem iconFilled') if (props.colors !== undefined) assertComposeColors(props.colors, dropdownMenuItemColorKeys, 'DropdownMenuItem') } export function validateExposedDropdownMenuBoxProps(props: ComposeExposedDropdownMenuBoxProps) { assertBoolean(props.expanded, 'ExposedDropdownMenuBox expanded') assertFunction(props.onExpandedChange, 'ExposedDropdownMenuBox onExpandedChange') assertString(props.value, 'ExposedDropdownMenuBox value') assertOptionalString(props.placeholder, 'ExposedDropdownMenuBox placeholder') assertOptionalBoolean(props.enabled, 'ExposedDropdownMenuBox enabled') } export function validatePullToRefreshBoxProps(props: ComposePullToRefreshBoxProps) { assertBoolean(props.isRefreshing, 'PullToRefreshBox isRefreshing') assertFunction(props.onRefresh, 'PullToRefreshBox onRefresh') assertOptionalBoolean(props.enabled, 'PullToRefreshBox enabled') } export const animatedVisibilityTransitions: readonly ComposeAnimatedVisibilityTransition[] = [ 'fade', 'scale', 'slideVertically', 'expandVertically', ] export function validateTooltipBoxProps(props: ComposeTooltipBoxProps) { assertString(props.text, 'TooltipBox text', true) assertOptionalString(props.title, 'TooltipBox title') assertOptionalBoolean(props.enabled, 'TooltipBox enabled') } export function validateAnimatedVisibilityProps(props: ComposeAnimatedVisibilityProps) { assertBoolean(props.visible, 'AnimatedVisibility visible') if (props.enter !== undefined) assertOneOf(props.enter, 'AnimatedVisibility enter', animatedVisibilityTransitions) if (props.exit !== undefined) assertOneOf(props.exit, 'AnimatedVisibility exit', animatedVisibilityTransitions) } export function validateModalBottomSheetProps(props: ComposeModalBottomSheetProps) { assertFunction(props.onDismissRequest, 'ModalBottomSheet onDismissRequest') if (props.properties !== undefined) { if (!props.properties || typeof props.properties !== 'object' || Array.isArray(props.properties)) throw new Error('Compose ModalBottomSheet properties must be an object') for (const [key, value] of Object.entries(props.properties)) { if (!sheetPropertyKeys.has(key)) throw new Error(`Compose ModalBottomSheet properties has unknown key ${key}`) if (value !== undefined) assertBoolean(value, `ModalBottomSheet properties ${key}`) } } if (props.scrimColor !== undefined) assertComposeColorValue(props.scrimColor, 'ModalBottomSheet scrimColor') assertOptionalBoolean(props.skipPartiallyExpanded, 'ModalBottomSheet skipPartiallyExpanded') assertOptionalBoolean(props.initialFullyExpanded, 'ModalBottomSheet initialFullyExpanded') assertOptionalBoolean(props.showDragHandle, 'ModalBottomSheet showDragHandle') assertOptionalBoolean(props.sheetGesturesEnabled, 'ModalBottomSheet sheetGesturesEnabled') }