refactor(ui): improve useDefinition types
Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)[] }
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user