Skip to content

Commit a4686b8

Browse files
committed
Expose modifier props checking
1 parent 832efdd commit a4686b8

5 files changed

Lines changed: 81 additions & 41 deletions

File tree

packages/component/src/Attachment/Text/private/ActivityButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ import {
1616
} from 'valibot';
1717

1818
import useStyleSet from '../../../hooks/useStyleSet';
19-
import { ComponentIcon, type IconType } from '../../../Icon';
19+
import { ComponentIcon, componentIconPropsSchema } from '../../../Icon';
2020

2121
const activityButtonPropsSchema = pipe(
2222
object({
2323
children: optional(reactNode()),
2424
className: optional(string()),
2525
'data-testid': optional(string()),
2626
disabled: optional(boolean()),
27-
icon: optional(custom<IconType>(value => typeof value === 'string')),
27+
icon: componentIconPropsSchema.entries.icon,
2828
onClick: optional(custom<() => void>(value => safeParse(function_(), value).success)),
2929
text: optional(string())
3030
}),
Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
11
import { validateProps } from 'botframework-webchat-react-valibot';
22
import { useStyles } from 'botframework-webchat-styles/react';
33
import cx from 'classnames';
4-
import React, { memo, type ComponentProps } from 'react';
4+
import React, { memo } from 'react';
55
import { literal, object, optional, pipe, readonly, string, type InferInput } from 'valibot';
66

77
import createIconComponent from '../Utils/createIconComponent';
88
import styles from './ComponentIcon.module.css';
99

10-
const componentIconPropsSchema = pipe(
10+
const baseComponentIconPropsSchema = pipe(
1111
object({
1212
'aria-hidden': optional(literal('true')),
1313
'aria-label': optional(string()),
14-
appearance: optional(string()),
1514
className: optional(string()),
16-
direction: optional(string()),
17-
icon: optional(string()),
18-
role: optional(string()),
19-
size: optional(string())
15+
role: optional(string())
2016
}),
2117
readonly()
2218
);
2319

24-
type ComponentIconProps = InferInput<typeof componentIconPropsSchema>;
25-
26-
function BaseComponentIcon(props: ComponentIconProps) {
20+
function BaseComponentIcon(props: InferInput<typeof baseComponentIconPropsSchema>) {
2721
const {
2822
className,
2923
'aria-hidden': ariaHidden,
3024
'aria-label': ariaLabel,
3125
role
32-
} = validateProps(componentIconPropsSchema, props);
26+
} = validateProps(baseComponentIconPropsSchema, props);
3327

3428
const classNames = useStyles(styles);
3529

@@ -43,11 +37,23 @@ function BaseComponentIcon(props: ComponentIconProps) {
4337
);
4438
}
4539

46-
const ComponentIcon = createIconComponent(styles, BaseComponentIcon);
47-
48-
type IconType = ComponentProps<typeof ComponentIcon>['icon'];
40+
const { component: ComponentIcon, modifierPropsSchema: componentIconModifiersPropsSchema } = createIconComponent(
41+
styles,
42+
['appearance', 'direction', 'icon'],
43+
BaseComponentIcon
44+
);
4945

5046
ComponentIcon.displayName = 'ComponentIcon';
5147

48+
const componentIconPropsSchema = pipe(
49+
object({
50+
...baseComponentIconPropsSchema.entries,
51+
...componentIconModifiersPropsSchema.entries
52+
}),
53+
readonly()
54+
);
55+
56+
type ComponentIconProps = InferInput<typeof componentIconPropsSchema>;
57+
5258
export default memo(ComponentIcon);
53-
export { componentIconPropsSchema, type ComponentIconProps, type IconType };
59+
export { componentIconPropsSchema, type ComponentIconProps };
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { default as ComponentIcon, type IconType } from './ComponentIcon';
1+
export { default as ComponentIcon, componentIconPropsSchema } from './ComponentIcon';
Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,70 @@
11
import { useStyles } from 'botframework-webchat-styles/react';
22
import { validateProps } from 'botframework-webchat-react-valibot';
33
import cx from 'classnames';
4-
import { object, optional, pipe, readonly, picklist } from 'valibot';
4+
import { object, optional, picklist, pipe, readonly, type OptionalSchema, type PicklistSchema } from 'valibot';
55
import React, { type ComponentType } from 'react';
66

77
type Prefixes<T> = T extends `${infer P}--${string}` ? P : never;
88

99
type SuffixesOf<Prefix extends string, T> = T extends `${Prefix}--${infer S}` ? S : never;
1010

11-
type VariantMap<T> = {
11+
type ModifierMap<T> = {
1212
[P in Prefixes<keyof T>]?: SuffixesOf<P, keyof T>;
1313
};
1414

15-
function createPropsSchema(styles: CSSModuleClasses) {
15+
function createPropsSchema<
16+
const TCSSModfuleClasses extends CSSModuleClasses,
17+
const TModifiers extends Array<keyof ModifierMap<TCSSModfuleClasses>>
18+
>(styles: TCSSModfuleClasses, modifiers: TModifiers) {
19+
type CSSModuleModifiers = ModifierMap<TCSSModfuleClasses>;
20+
1621
const props = Object.keys(styles).reduce((acc, key) => {
17-
const [base, modifier] = key.split('--');
18-
if (modifier) {
22+
const [base, modifier] = key.split('--') as [keyof CSSModuleModifiers, string | undefined];
23+
if (modifier && modifiers.includes(base)) {
1924
acc.has(base) || acc.set(base, new Set());
2025
acc.get(base).add(modifier);
2126
}
2227
return acc;
23-
}, new Map<string, Set<string>>());
28+
}, new Map<keyof CSSModuleModifiers, Set<string>>());
29+
2430
return pipe(
2531
object(
2632
Object.fromEntries(
2733
Array.from(props.entries()).map(([base, modifiers]) => [base, optional(picklist(Array.from(modifiers)))])
28-
)
34+
) as unknown as {
35+
[key in TModifiers[number]]: OptionalSchema<
36+
PicklistSchema<Array<CSSModuleModifiers[key]>, undefined>,
37+
undefined
38+
>;
39+
}
2940
),
3041
readonly()
3142
);
3243
}
3344

34-
export default function createIconComponent<T extends { className?: string | undefined }, K extends CSSModuleClasses>(
35-
styles: K,
36-
BaseIcon: ComponentType<T>
37-
) {
38-
const propsSchema = createPropsSchema(styles);
39-
return (props => {
45+
export default function createIconComponent<
46+
const TProps extends { className?: string | undefined },
47+
const TModifiers extends Array<keyof ModifierMap<TCSSModfuleClasses>>,
48+
const TCSSModfuleClasses extends CSSModuleClasses
49+
>(styles: TCSSModfuleClasses, modifiers: TModifiers, BaseIcon: ComponentType<TProps>) {
50+
type CSSModuleModifiers = ModifierMap<TCSSModfuleClasses>;
51+
52+
// Do not bail if no CSS modules TypeScript plugin is provided.
53+
type FinalCSSModuleModifiers = keyof CSSModuleModifiers extends never
54+
? Record<keyof TModifiers[number], string>
55+
: Pick<CSSModuleModifiers, TModifiers[number]>;
56+
57+
const modifierPropsSchema = createPropsSchema(styles, modifiers);
58+
59+
const component = (props => {
4060
const { className, ...rest } = props;
41-
const validatedProps = validateProps(propsSchema, props);
61+
const validatedProps = validateProps(modifierPropsSchema, props);
4262
const classNames = useStyles(styles);
4363

4464
const classes = Object.entries(validatedProps).map(([key, value]) => classNames[`${key}--${value}`]);
4565

46-
return <BaseIcon className={cx(className, classes)} {...(rest as T)} />;
47-
}) as ComponentType<VariantMap<K> & T>;
66+
return <BaseIcon className={cx(className, classes)} {...(rest as TProps)} />;
67+
}) as ComponentType<FinalCSSModuleModifiers & TProps>;
68+
69+
return { component, modifierPropsSchema };
4870
}

packages/fluent-theme/src/components/icon/FluentIcon.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { object, optional, pipe, readonly, string, type InferInput } from 'valib
77

88
import styles from './FluentIcon.module.css';
99

10-
const FluentIconPropsSchema = pipe(
10+
const baseFluentIconPropsSchema = pipe(
1111
object({
1212
appearance: optional(string()),
1313
className: optional(string()),
@@ -18,10 +18,8 @@ const FluentIconPropsSchema = pipe(
1818
readonly()
1919
);
2020

21-
type FluentIconProps = InferInput<typeof FluentIconPropsSchema>;
22-
23-
function BaseFluentIcon(props: FluentIconProps) {
24-
const { className } = validateProps(FluentIconPropsSchema, props);
21+
function BaseFluentIcon(props: InferInput<typeof baseFluentIconPropsSchema>) {
22+
const { className } = validateProps(baseFluentIconPropsSchema, props);
2523

2624
const classNames = useStyles(styles);
2725

@@ -34,9 +32,23 @@ function BaseFluentIcon(props: FluentIconProps) {
3432
return <div className={cx(classNames['fluent-icon'], className)} style={maskStyle} />;
3533
}
3634

37-
const FluentIcon = createIconComponent(styles, BaseFluentIcon);
35+
const { component: FluentIcon, modifierPropsSchema } = createIconComponent(
36+
styles,
37+
['appearance', 'icon'],
38+
BaseFluentIcon
39+
);
3840

3941
FluentIcon.displayName = 'FluentIcon';
4042

43+
const fluentIconPropsSchema = pipe(
44+
object({
45+
...baseFluentIconPropsSchema.entries,
46+
...modifierPropsSchema.entries
47+
}),
48+
readonly()
49+
);
50+
51+
type FluentIconProps = InferInput<typeof fluentIconPropsSchema>;
52+
4153
export default memo(FluentIcon);
42-
export { FluentIconPropsSchema, type FluentIconProps };
54+
export { fluentIconPropsSchema, type FluentIconProps };

0 commit comments

Comments
 (0)