From 10580a93df19be147662c37b5875216eae9e87be Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 13 Jan 2026 11:47:53 +0100 Subject: [PATCH] refactor(ui): improve useDefinition types Signed-off-by: Johan Persson --- packages/ui/src/components/Box/Box.tsx | 5 +- packages/ui/src/components/Box/definition.ts | 16 ++-- packages/ui/src/components/Box/types.ts | 11 ++- .../ui/src/hooks/useDefinition/helpers.ts | 88 +++++++++---------- packages/ui/src/hooks/useDefinition/types.ts | 55 ++++++++---- .../src/hooks/useDefinition/useDefinition.tsx | 16 ++-- packages/ui/src/utils/utilityClassMap.ts | 10 +-- 7 files changed, 106 insertions(+), 95 deletions(-) diff --git a/packages/ui/src/components/Box/Box.tsx b/packages/ui/src/components/Box/Box.tsx index a58c9c16a2..ed3262ea31 100644 --- a/packages/ui/src/components/Box/Box.tsx +++ b/packages/ui/src/components/Box/Box.tsx @@ -21,11 +21,11 @@ import { BoxDefinition } from './definition'; /** @public */ export const Box = forwardRef((props, ref) => { - const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition( + const { ownProps, dataAttributes, utilityStyle } = useDefinition( BoxDefinition, props, ); - const { classes, as = 'div', surfaceChildren } = ownProps; + const { classes, as, surfaceChildren } = ownProps; return createElement( as, @@ -34,7 +34,6 @@ export const Box = forwardRef((props, ref) => { className: classes.root, style: { ...ownProps.style, ...utilityStyle }, ...dataAttributes, - ...restProps, }, surfaceChildren, ); diff --git a/packages/ui/src/components/Box/definition.ts b/packages/ui/src/components/Box/definition.ts index a535e3fa59..4f07732743 100644 --- a/packages/ui/src/components/Box/definition.ts +++ b/packages/ui/src/components/Box/definition.ts @@ -31,14 +31,6 @@ export const BoxDefinition = defineComponent()({ propDefs: { as: { default: 'div' }, surface: { dataAttribute: true }, - display: {}, - position: {}, - width: {}, - minWidth: {}, - maxWidth: {}, - height: {}, - minHeight: {}, - maxHeight: {}, children: {}, className: {}, style: {}, @@ -58,5 +50,13 @@ export const BoxDefinition = defineComponent()({ 'pt', 'px', 'py', + 'position', + 'display', + 'width', + 'minWidth', + 'maxWidth', + 'height', + 'minHeight', + 'maxHeight', ], }); diff --git a/packages/ui/src/components/Box/types.ts b/packages/ui/src/components/Box/types.ts index 68981ce10d..30ba0e1533 100644 --- a/packages/ui/src/components/Box/types.ts +++ b/packages/ui/src/components/Box/types.ts @@ -21,6 +21,12 @@ import type { Responsive, Surface, SpaceProps } from '../../types'; export type BoxOwnProps = { as?: keyof JSX.IntrinsicElements; surface?: Responsive; + children?: ReactNode; + className?: string; + style?: CSSProperties; +}; + +type BoxUtilityProps = { display?: Responsive<'none' | 'flex' | 'block' | 'inline'>; position?: Responsive< 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky' @@ -31,10 +37,7 @@ export type BoxOwnProps = { height?: Responsive; minHeight?: Responsive; maxHeight?: Responsive; - children?: ReactNode; - className?: string; - style?: CSSProperties; }; /** @public */ -export interface BoxProps extends SpaceProps, BoxOwnProps {} +export interface BoxProps extends SpaceProps, BoxOwnProps, BoxUtilityProps {} diff --git a/packages/ui/src/hooks/useDefinition/helpers.ts b/packages/ui/src/hooks/useDefinition/helpers.ts index 86436a89a9..6f97953af1 100644 --- a/packages/ui/src/hooks/useDefinition/helpers.ts +++ b/packages/ui/src/hooks/useDefinition/helpers.ts @@ -14,57 +14,52 @@ * limitations under the License. */ -import type { CSSProperties } from 'react'; import { breakpoints } from '../useBreakpoint'; import { utilityClassMap } from '../../utils/utilityClassMap'; +import type { UtilityStyle } from './types'; -/** - * Resolve a responsive value based on the current breakpoint - * @param value - The responsive value (string or object with breakpoint keys) - * @param breakpoint - The current breakpoint - * @returns The resolved value for the current breakpoint - */ -export function resolveResponsiveValue( - value: string | Record, - breakpoint: string, -): string | undefined { - if (typeof value === 'string') { +const namedBreakpoints = breakpoints.filter(b => b.id !== 'initial'); + +function isResponsiveObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + namedBreakpoints.some(b => b.id in value) + ); +} + +export function resolveResponsiveValue(value: T, breakpoint: string): T { + if (!isResponsiveObject(value)) { return value; } - if (typeof value === 'object' && value !== null) { - const index = breakpoints.findIndex(b => b.id === breakpoint); + const index = breakpoints.findIndex(b => b.id === breakpoint); - // Look for value at current breakpoint or smaller - for (let i = index; i >= 0; i--) { - if (value[breakpoints[i].id]) { - return value[breakpoints[i].id]; - } - } - - // If no value found, check from smallest breakpoint up - for (let i = 0; i < breakpoints.length; i++) { - if (value[breakpoints[i].id]) { - return value[breakpoints[i].id]; - } + // Look for value at current breakpoint or smaller + for (let i = index; i >= 0; i--) { + const key = breakpoints[i].id; + if (key in value && value[key] !== undefined) { + return value[key] as T; } } - return undefined; + // If no value found, check from smallest breakpoint up + for (let i = 0; i < breakpoints.length; i++) { + const key = breakpoints[i].id; + if (key in value && value[key] !== undefined) { + return value[key] as T; + } + } + + return value; } -/** - * Process utility props and generate utility classes and styles - * @param props - All component props - * @param utilityPropKeys - Array of utility prop names to process - * @returns Object with utilityClasses string and utilityStyle CSSProperties - */ -export function processUtilityProps( +export function processUtilityProps( props: Record, - utilityPropKeys: readonly string[], -): { utilityClasses: string; utilityStyle: CSSProperties } { + utilityPropKeys: readonly Keys[], +): { utilityClasses: string; utilityStyle: UtilityStyle } { const utilityClassList: string[] = []; - const generatedStyle: CSSProperties = {}; + const generatedStyle: Record = {}; const handleUtilityValue = ( key: string, @@ -80,23 +75,20 @@ export function processUtilityProps( } // Check if value is in the list of valid values for this utility - if ( - utilityConfig.values.length > 0 && - utilityConfig.values.includes(val as string | number) - ) { + const values = utilityConfig.values as readonly (string | number)[]; + if (values.length > 0 && values.includes(val as string | number)) { // Generate utility class with value suffix and optional breakpoint prefix const className = prefix ? `${prefix}${utilityConfig.class}-${val}` : `${utilityConfig.class}-${val}`; utilityClassList.push(className); - } else if (utilityConfig.cssVar) { + } else if ('cssVar' in utilityConfig && utilityConfig.cssVar) { // Custom value - add CSS custom property AND utility class name // Only if cssVar is defined (properties with fixed values don't have cssVar) - const cssVarKey = prefix - ? `${utilityConfig.cssVar}-${prefix.slice(0, -1)}` - : utilityConfig.cssVar; - // CSS custom properties need to be set on the style object as strings - (generatedStyle as Record)[cssVarKey] = val; + const cssVar = utilityConfig.cssVar; + const cssVarKey = prefix ? `${cssVar}-${prefix.slice(0, -1)}` : cssVar; + // CSS custom properties need to be set on the style object + generatedStyle[cssVarKey] = val; // Add utility class name (without value suffix) with optional breakpoint prefix const className = prefix @@ -129,6 +121,6 @@ export function processUtilityProps( return { utilityClasses: utilityClassList.join(' '), - utilityStyle: generatedStyle, + utilityStyle: generatedStyle as UtilityStyle, }; } diff --git a/packages/ui/src/hooks/useDefinition/types.ts b/packages/ui/src/hooks/useDefinition/types.ts index bd5e8353ce..81c44aa086 100644 --- a/packages/ui/src/hooks/useDefinition/types.ts +++ b/packages/ui/src/hooks/useDefinition/types.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import type { ReactNode, CSSProperties } from 'react'; +import type { ReactNode } from 'react'; import type { Responsive } from '../../types'; +import type { utilityClassMap } from '../../utils/utilityClassMap'; -// Extract raw value from Responsive wrapper type UnwrapResponsive = T extends Responsive ? U : T; export interface PropDefConfig { @@ -25,6 +25,8 @@ export interface PropDefConfig { default?: UnwrapResponsive; } +export type UtilityPropKey = keyof typeof utilityClassMap; + export interface ComponentConfig< P extends Record, S extends Record, @@ -32,7 +34,8 @@ export interface ComponentConfig< styles: S; classNames: Record; propDefs: { [K in keyof P]: PropDefConfig }; - utilityProps?: string[]; + // readonly for compatibility with const inference from factory + utilityProps?: readonly UtilityPropKey[]; surface?: 'container' | 'leaf'; } @@ -42,28 +45,30 @@ export interface UseDefinitionOptions> { } // Resolve prop type: unwrap Responsive, make non-nullable if default exists +// Uses "inverse check" pattern: check if undefined is assignable to the default type type ResolvePropType< T, Config extends PropDefConfig, -> = Config['default'] extends {} // Check if default is present (not undefined) - ? Exclude, undefined> // Only remove undefined - : UnwrapResponsive; +> = undefined extends Config['default'] + ? UnwrapResponsive // Default is missing/undefined -> keep original type + : Exclude, undefined>; // Default exists -> remove undefined // Build ownProps shape from propDefs +// Iterates over PropDefs keys (not P keys) to preserve literal config types type ResolvedOwnProps< P, - PropDefs extends Record>, + PropDefs extends Record>, > = { - [K in keyof P]: ResolvePropType; + [K in keyof PropDefs & keyof P]: ResolvePropType; }; -// Conditional children type based on surface type ChildrenProps = Surface extends 'container' - ? { surfaceChildren: ReactNode } + ? { surfaceChildren: ReactNode; children?: never } + : Surface extends 'leaf' + ? { children: ReactNode; surfaceChildren?: never } : { children: ReactNode }; -// Data attributes type type DataAttributeKeys = { [K in keyof PropDefs]: PropDefs[K] extends { dataAttribute: true } ? K @@ -74,8 +79,25 @@ type DataAttributes = { [K in DataAttributeKeys as `data-${string & K}`]?: string; } & { 'data-on-surface'?: string }; -// Helper to define the base rest props -type BaseRestProps = Omit; +export type UtilityKeys> = + D['utilityProps'] extends ReadonlyArray ? K : never; + +type UtilityMapType = typeof utilityClassMap; + +// Extract CSS variable key for a given prop (e.g., 'p' -> '--p') +type GetCssVarKey = K extends keyof UtilityMapType + ? UtilityMapType[K] extends { cssVar: infer V extends string } + ? V + : never + : never; + +export type UtilityStyle = { + [K in Keys as GetCssVarKey]?: string | number; +}; + +type ResolvedUtilityStyle> = UtilityStyle< + UtilityKeys +>; export interface UseDefinitionResult< D extends ComponentConfig, @@ -86,11 +108,12 @@ export interface UseDefinitionResult< } & ResolvedOwnProps & ChildrenProps; - restProps: keyof BaseRestProps extends never + // Rest props excludes both propDefs keys AND utility prop keys + restProps: keyof Omit> extends never ? Record - : BaseRestProps; + : Omit>; dataAttributes: DataAttributes; - utilityStyle: CSSProperties; + utilityStyle: ResolvedUtilityStyle; } diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx index 990be4ad1c..08ba1185bd 100644 --- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx +++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx @@ -23,6 +23,7 @@ import type { ComponentConfig, UseDefinitionOptions, UseDefinitionResult, + UtilityKeys, } from './types'; export function useDefinition< @@ -45,7 +46,6 @@ export function useDefinition< const { surface: resolvedSurface } = useSurface(surfaceOptions); return useMemo(() => { - // Step 4a: Separate props const ownPropKeys = new Set(Object.keys(definition.propDefs)); const utilityPropKeys = new Set(definition.utilityProps ?? []); @@ -55,12 +55,11 @@ export function useDefinition< for (const [key, value] of Object.entries(props)) { if (ownPropKeys.has(key)) { ownPropsRaw[key] = value; - } else if (!utilityPropKeys.has(key)) { + } else if (!(utilityPropKeys as Set).has(key)) { restProps[key] = value; } } - // Step 4b: Resolve props, apply defaults, generate data attributes const ownPropsResolved: Record = {}; const dataAttributes: Record = {}; @@ -90,13 +89,10 @@ export function useDefinition< } } - // Step 4c: Process utility props - const { utilityClasses, utilityStyle } = processUtilityProps( - props, - definition.utilityProps ?? [], - ); + const { utilityClasses, utilityStyle } = processUtilityProps< + UtilityKeys + >(props, (definition.utilityProps ?? []) as readonly UtilityKeys[]); - // Step 4d: Assemble classes const utilityTarget = options?.utilityTarget ?? 'root'; const classNameTarget = options?.classNameTarget ?? 'root'; @@ -113,7 +109,6 @@ export function useDefinition< ); } - // Step 4e: Handle children / surfaceChildren let children: ReactNode | undefined; let surfaceChildren: ReactNode | undefined; @@ -129,7 +124,6 @@ export function useDefinition< children = props.children; } - // Step 4f: Return result return { ownProps: { classes, diff --git a/packages/ui/src/utils/utilityClassMap.ts b/packages/ui/src/utils/utilityClassMap.ts index 261d43632d..f70052d588 100644 --- a/packages/ui/src/utils/utilityClassMap.ts +++ b/packages/ui/src/utils/utilityClassMap.ts @@ -50,10 +50,7 @@ const columnsValues = [ 'auto', ] as const; -export const utilityClassMap: Record< - string, - { class: string; cssVar?: string; values: readonly (string | number)[] } -> = { +export const utilityClassMap = { m: { class: 'bui-m', cssVar: '--m', @@ -199,4 +196,7 @@ export const utilityClassMap: Record< class: 'bui-row-span', values: columnsValues, }, -}; +} as const satisfies Record< + string, + { class: string; cssVar?: string; values: readonly (string | number)[] } +>;