diff --git a/packages/@luke-ui/react/src/combobox-field/index.tsx b/packages/@luke-ui/react/src/combobox-field/index.tsx index 5a90a59e..533da0ae 100644 --- a/packages/@luke-ui/react/src/combobox-field/index.tsx +++ b/packages/@luke-ui/react/src/combobox-field/index.tsx @@ -85,7 +85,7 @@ export function ComboboxField(props: ComboboxFieldProps): J const { children, listBoxProps, - loadMoreItem, + loadMoreItem: loadMoreItemProp, loadingState, menuWidth, onLoadMore, @@ -95,10 +95,34 @@ export function ComboboxField(props: ComboboxFieldProps): J ...comboboxInputProps } = restProps; - const isAsync = loadingState != null; - const isInteractive = + const isAsync: boolean = loadingState != null; + const isInteractive: boolean = comboboxInputProps.isDisabled !== true && comboboxInputProps.isReadOnly !== true; + const loadMoreItem: ComboboxListBoxProps['loadMoreItem'] = (() => { + if (loadMoreItemProp != null) return loadMoreItemProp; + if (onLoadMore == null) return null; + + return ( + + + + ); + })(); + + const resolvedEmptyState: ComboboxListBoxProps['renderEmptyState'] = (() => { + if (listBoxProps?.renderEmptyState != null) return listBoxProps.renderEmptyState; + if (!isAsync) return undefined; + + return () => ; + })(); + + const resolvedStyle: ComboboxPopoverProps['style'] = (() => { + if (menuWidth === undefined) return popoverProps?.style; + + return Object.assign({}, popoverProps?.style, { width: menuWidth }); + })(); + return ( size={size} {...comboboxInputProps}> @@ -113,48 +137,11 @@ export function ComboboxField(props: ComboboxFieldProps): J - + {...listBoxProps} - loadMoreItem={ - loadMoreItem ?? - (onLoadMore == null ? null : ( - - - - )) - } - renderEmptyState={ - listBoxProps?.renderEmptyState ?? - (isAsync - ? () => { - switch (loadingState) { - case 'loading': - case 'filtering': { - return ( - - - - ); - } - default: { - return No results; - } - } - } - : undefined) - } + loadMoreItem={loadMoreItem} + renderEmptyState={resolvedEmptyState} > {children} @@ -163,3 +150,19 @@ export function ComboboxField(props: ComboboxFieldProps): J ); } + +function ComboboxEmptyStateContent({ + loadingState, +}: { + loadingState: ComboboxLoadingState | undefined; +}) { + if (loadingState === 'loading' || loadingState === 'filtering') { + return ( + + + + ); + } + + return No results; +} diff --git a/packages/@luke-ui/react/src/heading-context/index.tsx b/packages/@luke-ui/react/src/heading-context/index.tsx index cc127978..3e8a0e38 100644 --- a/packages/@luke-ui/react/src/heading-context/index.tsx +++ b/packages/@luke-ui/react/src/heading-context/index.tsx @@ -50,7 +50,7 @@ export const useHeadingLevel = ( /** Returns true when rendered inside a `Heading`. */ export const useIsWithinHeading = () => useContext(WithinHeadingContext); -type HeadingLevelsProps = { +export type HeadingLevelsProps = { /** Base level override. Defaults to inherited level + 1. */ base?: HeadingLevel; children: ReactNode | ((props: HeadingLevelsRenderProps) => ReactNode); diff --git a/packages/@luke-ui/react/src/heading/index.tsx b/packages/@luke-ui/react/src/heading/index.tsx index 12419f24..54c3624a 100644 --- a/packages/@luke-ui/react/src/heading/index.tsx +++ b/packages/@luke-ui/react/src/heading/index.tsx @@ -1,4 +1,4 @@ -import type { HeadingLevel } from '../heading-context/index.js'; +import type { HeadingLevel, HeadingLevelsProps } from '../heading-context/index.js'; import { HeadingLevels, HeadingPresenceProvider } from '../heading-context/index.js'; import type { TextProps } from '../text/index.js'; import { Text } from '../text/index.js'; @@ -21,7 +21,7 @@ export interface HeadingProps extends DistributiveOmit { /** Heading component with automatic level composition. */ export function Heading(props: HeadingProps) { const { elementType, level, ...textProps } = props; - const baseProps = level === undefined ? {} : ({ base: level } as const); + const baseProps: Pick = level === undefined ? {} : { base: level }; return ( diff --git a/packages/@luke-ui/react/src/numeral/index.tsx b/packages/@luke-ui/react/src/numeral/index.tsx index 662bc8cb..54ab9c26 100644 --- a/packages/@luke-ui/react/src/numeral/index.tsx +++ b/packages/@luke-ui/react/src/numeral/index.tsx @@ -23,6 +23,11 @@ export interface NumeralProps extends Omit< abbreviate?: NumeralAbbreviation; /** Currency code such as `USD`. */ currency?: string; + /** + * Numeric glyph rendering mode. + * @default 'tabular-nums' + */ + fontVariantNumeric?: TextProps['fontVariantNumeric']; /** Number format style. Inferred from `currency`/`unit` when omitted. */ format?: NumeralFormat; /** Extra options passed to `Intl.NumberFormat`. */ @@ -40,78 +45,10 @@ export interface NumeralProps extends Omit< unit?: NonNullable; /** Number to format. */ value: number; - /** - * Numeric glyph rendering mode. - * @default 'tabular-nums' - */ - variant?: Extract< - TextProps['fontVariantNumeric'], - 'diagonal-fractions' | 'ordinal' | 'slashed-zero' | 'tabular-nums' - >; -} - -function isValidPrecisionValue(value: number) { - return Number.isInteger(value) && value >= 0; } -function validateProps(props: NumeralProps) { - const { currency, format, formatOptions, precision, unit } = props; - - const hasCurrency = currency ?? formatOptions?.currency; - const hasUnit = unit ?? formatOptions?.unit; - - if (hasCurrency && hasUnit) { - throw new Error('Numeral cannot format both `currency` and `unit` at once.'); - } - - if (format === 'currency' && hasCurrency === undefined) { - throw new Error('Numeral with format="currency" requires a `currency` code.'); - } - - if (format === 'unit' && hasUnit === undefined) { - throw new Error('Numeral with format="unit" requires a `unit` value.'); - } - - if (precision === undefined) { - return; - } - - if (typeof precision === 'number') { - if (!isValidPrecisionValue(precision)) { - throw new Error('Numeral `precision` must be a non-negative integer or tuple.'); - } - return; - } - - if ( - !isValidPrecisionValue(precision[0]) || - !isValidPrecisionValue(precision[1]) || - precision[0] > precision[1] - ) { - throw new Error('Numeral `precision` tuple must be [min, max] non-negative integers.'); - } -} - -const numeralFormatCache = new Map(); - -function getCachedNumberFormat(locale: Intl.LocalesArgument, options: Intl.NumberFormatOptions) { - const key = `${String(locale)}:${JSON.stringify(options)}`; - let cached = numeralFormatCache.get(key); - if (!cached) { - cached = new Intl.NumberFormat(locale, options); - numeralFormatCache.set(key, cached); - } - return cached; -} - -/** - * Formats a number and renders it with `Text`. - * @throws When `currency` and `unit` are both provided or precision is invalid. - */ +/** Formats a number and renders it with `Text`. */ export function Numeral(props: NumeralProps) { - if (process.env.NODE_ENV !== 'production') { - validateProps(props); - } const { locale: localeFromContext } = useLocale(); const isWithinHeading = useIsWithinHeading(); const { @@ -120,6 +57,7 @@ export function Numeral(props: NumeralProps) { textAlign = 'end', currency, elementType = 'span', + fontVariantNumeric = 'tabular-nums', format, formatOptions, locale, @@ -127,60 +65,38 @@ export function Numeral(props: NumeralProps) { shouldDisableTrim, unit, value, - variant = 'tabular-nums', ...textProps } = props; const resolvedLocale = locale ?? localeFromContext; - const resolvedFormat = format ?? (currency ? 'currency' : unit ? 'unit' : 'decimal'); - - const numeralFormatOptions: Intl.NumberFormatOptions = { - ...formatOptions, - style: resolvedFormat, - }; - if (resolvedFormat === 'currency') { - const resolvedCurrency = currency ?? formatOptions?.currency; - if (resolvedCurrency === undefined) { - throw new Error('Numeral with format="currency" requires a `currency` code.'); - } - numeralFormatOptions.currency = resolvedCurrency; - } - - if (resolvedFormat === 'unit') { - const resolvedUnit = unit ?? formatOptions?.unit; - if (resolvedUnit === undefined) { - throw new Error('Numeral with format="unit" requires a `unit` value.'); - } - numeralFormatOptions.unit = resolvedUnit; - numeralFormatOptions.unitDisplay = formatOptions?.unitDisplay ?? 'narrow'; - } + const resolvedFormat: NumeralFormat = (() => { + if (format != null) return format; + if (currency) return 'currency'; + if (unit) return 'unit'; - if (abbreviate) { - numeralFormatOptions.compactDisplay = abbreviate === 'long' ? 'long' : 'short'; - numeralFormatOptions.notation = 'compact'; - } + return 'decimal'; + })(); - if (precision !== undefined) { - if (typeof precision === 'number') { - numeralFormatOptions.minimumFractionDigits = precision; - numeralFormatOptions.maximumFractionDigits = precision; - } else { - numeralFormatOptions.minimumFractionDigits = precision[0]; - numeralFormatOptions.maximumFractionDigits = precision[1]; - } - } + const numeralFormatOptions = resolveNumeralFormatOptions({ + abbreviate, + currency, + format: resolvedFormat, + formatOptions, + precision, + unit, + }); const content = getCachedNumberFormat(resolvedLocale, numeralFormatOptions).format(value); const resolvedShouldDisableTrim = shouldDisableTrim ?? isWithinHeading; const resolvedColor = color ?? (isWithinHeading ? 'inherit' : undefined); - const colorProps: Pick | {} = + const colorProps: Pick = resolvedColor === undefined ? {} : { color: resolvedColor }; return ( ); } + +function resolvePrecisionRange(precision: NumeralPrecision): readonly [number, number] { + if (typeof precision === 'number') return [precision, precision]; + + return precision; +} + +function resolveNumeralFormatOptions({ + abbreviate, + currency, + format, + formatOptions, + precision, + unit, +}: Pick & { + format: NumeralFormat; +}): Intl.NumberFormatOptions { + const options: Intl.NumberFormatOptions = { ...formatOptions, style: format }; + + if (format === 'currency') { + if (currency === undefined) { + throw new Error('Numeral with format="currency" requires a `currency` code.'); + } + options.currency = currency; + } + + if (format === 'unit') { + if (unit === undefined) { + throw new Error('Numeral with format="unit" requires a `unit` value.'); + } + options.unit = unit; + options.unitDisplay = formatOptions?.unitDisplay ?? 'narrow'; + } + + if (abbreviate) { + options.compactDisplay = abbreviate === 'long' ? 'long' : 'short'; + options.notation = 'compact'; + } + + if (precision !== undefined) { + const [minimumFractionDigits, maximumFractionDigits] = resolvePrecisionRange(precision); + options.minimumFractionDigits = minimumFractionDigits; + options.maximumFractionDigits = maximumFractionDigits; + } + + return options; +} + +const numeralFormatCache = new Map(); + +function getCachedNumberFormat(locale: Intl.LocalesArgument, options: Intl.NumberFormatOptions) { + const key = `${String(locale)}:${JSON.stringify(options)}`; + let cached = numeralFormatCache.get(key); + if (!cached) { + cached = new Intl.NumberFormat(locale, options); + numeralFormatCache.set(key, cached); + } + return cached; +}