refactor(ui): improve useDefinition types

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-01-13 11:47:53 +01:00
parent 971b119366
commit 10580a93df
7 changed files with 106 additions and 95 deletions
+2 -3
View File
@@ -21,11 +21,11 @@ import { BoxDefinition } from './definition';
/** @public */
export const Box = forwardRef<HTMLDivElement, BoxProps>((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<HTMLDivElement, BoxProps>((props, ref) => {
className: classes.root,
style: { ...ownProps.style, ...utilityStyle },
...dataAttributes,
...restProps,
},
surfaceChildren,
);
+8 -8
View File
@@ -31,14 +31,6 @@ export const BoxDefinition = defineComponent<BoxOwnProps, typeof styles>()({
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<BoxOwnProps, typeof styles>()({
'pt',
'px',
'py',
'position',
'display',
'width',
'minWidth',
'maxWidth',
'height',
'minHeight',
'maxHeight',
],
});
+7 -4
View File
@@ -21,6 +21,12 @@ import type { Responsive, Surface, SpaceProps } from '../../types';
export type BoxOwnProps = {
as?: keyof JSX.IntrinsicElements;
surface?: Responsive<Surface>;
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<string>;
minHeight?: Responsive<string>;
maxHeight?: Responsive<string>;
children?: ReactNode;
className?: string;
style?: CSSProperties;
};
/** @public */
export interface BoxProps extends SpaceProps, BoxOwnProps {}
export interface BoxProps extends SpaceProps, BoxOwnProps, BoxUtilityProps {}
+40 -48
View File
@@ -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<string, string>,
breakpoint: string,
): string | undefined {
if (typeof value === 'string') {
const namedBreakpoints = breakpoints.filter(b => b.id !== 'initial');
function isResponsiveObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
namedBreakpoints.some(b => b.id in value)
);
}
export function resolveResponsiveValue<T>(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<Keys extends string>(
props: Record<string, any>,
utilityPropKeys: readonly string[],
): { utilityClasses: string; utilityStyle: CSSProperties } {
utilityPropKeys: readonly Keys[],
): { utilityClasses: string; utilityStyle: UtilityStyle<Keys> } {
const utilityClassList: string[] = [];
const generatedStyle: CSSProperties = {};
const generatedStyle: Record<string, unknown> = {};
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<string, unknown>)[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<Keys>,
};
}
+39 -16
View File
@@ -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> = T extends Responsive<infer U> ? U : T;
export interface PropDefConfig<T> {
@@ -25,6 +25,8 @@ export interface PropDefConfig<T> {
default?: UnwrapResponsive<T>;
}
export type UtilityPropKey = keyof typeof utilityClassMap;
export interface ComponentConfig<
P extends Record<string, any>,
S extends Record<string, string>,
@@ -32,7 +34,8 @@ export interface ComponentConfig<
styles: S;
classNames: Record<string, keyof S>;
propDefs: { [K in keyof P]: PropDefConfig<P[K]> };
utilityProps?: string[];
// readonly for compatibility with const inference from factory
utilityProps?: readonly UtilityPropKey[];
surface?: 'container' | 'leaf';
}
@@ -42,28 +45,30 @@ export interface UseDefinitionOptions<D extends ComponentConfig<any, any>> {
}
// 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<any>,
> = Config['default'] extends {} // Check if default is present (not undefined)
? Exclude<UnwrapResponsive<T>, undefined> // Only remove undefined
: UnwrapResponsive<T>;
> = undefined extends Config['default']
? UnwrapResponsive<T> // Default is missing/undefined -> keep original type
: Exclude<UnwrapResponsive<T>, 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<keyof P, PropDefConfig<any>>,
PropDefs extends Record<string, PropDefConfig<any>>,
> = {
[K in keyof P]: ResolvePropType<P[K], PropDefs[K]>;
[K in keyof PropDefs & keyof P]: ResolvePropType<P[K], PropDefs[K]>;
};
// Conditional children type based on surface
type ChildrenProps<Surface extends 'container' | 'leaf' | undefined> =
Surface extends 'container'
? { surfaceChildren: ReactNode }
? { surfaceChildren: ReactNode; children?: never }
: Surface extends 'leaf'
? { children: ReactNode; surfaceChildren?: never }
: { children: ReactNode };
// Data attributes type
type DataAttributeKeys<PropDefs> = {
[K in keyof PropDefs]: PropDefs[K] extends { dataAttribute: true }
? K
@@ -74,8 +79,25 @@ type DataAttributes<PropDefs> = {
[K in DataAttributeKeys<PropDefs> as `data-${string & K}`]?: string;
} & { 'data-on-surface'?: string };
// Helper to define the base rest props
type BaseRestProps<P, PropDefs> = Omit<P, keyof PropDefs>;
export type UtilityKeys<D extends ComponentConfig<any, any>> =
D['utilityProps'] extends ReadonlyArray<infer K extends string> ? K : never;
type UtilityMapType = typeof utilityClassMap;
// Extract CSS variable key for a given prop (e.g., 'p' -> '--p')
type GetCssVarKey<K> = K extends keyof UtilityMapType
? UtilityMapType[K] extends { cssVar: infer V extends string }
? V
: never
: never;
export type UtilityStyle<Keys extends string> = {
[K in Keys as GetCssVarKey<K>]?: string | number;
};
type ResolvedUtilityStyle<D extends ComponentConfig<any, any>> = UtilityStyle<
UtilityKeys<D>
>;
export interface UseDefinitionResult<
D extends ComponentConfig<any, any>,
@@ -86,11 +108,12 @@ export interface UseDefinitionResult<
} & ResolvedOwnProps<P, D['propDefs']> &
ChildrenProps<D['surface']>;
restProps: keyof BaseRestProps<P, D['propDefs']> extends never
// Rest props excludes both propDefs keys AND utility prop keys
restProps: keyof Omit<P, keyof D['propDefs'] | UtilityKeys<D>> extends never
? Record<string, never>
: BaseRestProps<P, D['propDefs']>;
: Omit<P, keyof D['propDefs'] | UtilityKeys<D>>;
dataAttributes: DataAttributes<D['propDefs']>;
utilityStyle: CSSProperties;
utilityStyle: ResolvedUtilityStyle<D>;
}
@@ -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<string>).has(key)) {
restProps[key] = value;
}
}
// Step 4b: Resolve props, apply defaults, generate data attributes
const ownPropsResolved: Record<string, any> = {};
const dataAttributes: Record<string, string | undefined> = {};
@@ -90,13 +89,10 @@ export function useDefinition<
}
}
// Step 4c: Process utility props
const { utilityClasses, utilityStyle } = processUtilityProps(
props,
definition.utilityProps ?? [],
);
const { utilityClasses, utilityStyle } = processUtilityProps<
UtilityKeys<D>
>(props, (definition.utilityProps ?? []) as readonly UtilityKeys<D>[]);
// 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,
+5 -5
View File
@@ -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)[] }
>;