one
Version:
One is a React framework for web, iOS, and Android apps.
1,110 lines (1,038 loc) • 45.8 kB
text/typescript
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')
}