Skip to content

Commit e11224c

Browse files
committed
explore injecting classNames in a typesafe way
1 parent 3707299 commit e11224c

File tree

4 files changed

+91
-9
lines changed

4 files changed

+91
-9
lines changed

packages/core/src/button/Button.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { useComponentCssInjection } from "@salt-ds/styles";
1+
import {
2+
useComponentCssInjection,
3+
useInjectedClassName,
4+
} from "@salt-ds/styles";
25
import { useWindow } from "@salt-ds/window";
36
import { clsx } from "clsx";
47
import {
@@ -110,11 +113,20 @@ function variantToAppearanceAndColor(
110113
}
111114
}
112115

116+
declare module "@salt-ds/core" {
117+
interface ComponentPropMap {
118+
Button: ButtonProps;
119+
}
120+
}
121+
113122
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
114-
function Button(
115-
{
123+
function Button(props, ref?): ReactElement<ButtonProps> {
124+
const { className, props: cleanProps } = useInjectedClassName(
125+
"Button",
126+
props,
127+
);
128+
const {
116129
children,
117-
className,
118130
disabled,
119131
focusableWhenDisabled,
120132
onKeyUp,
@@ -128,9 +140,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
128140
type: typeProp = "button",
129141
variant = "primary",
130142
...restProps
131-
},
132-
ref?,
133-
): ReactElement<ButtonProps> {
143+
} = cleanProps;
144+
134145
const { active, buttonProps } = useButton({
135146
loading,
136147
disabled,

packages/core/src/form-field-context/FormFieldContext.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { RefObject } from "react";
2+
import type { ValidationStatus, ValidationStatuses } from "../status-indicator";
23
import { createContext } from "../utils";
3-
import { type ValidationStatus, type ValidationStatuses } from "../status-indicator";
44
export interface A11yValueProps {
55
/**
66
* id for FormFieldHelperText
@@ -19,7 +19,8 @@ export interface a11yValueAriaProps {
1919
"aria-describedby": A11yValueProps["helperTextId"] | undefined;
2020
}
2121

22-
export interface FormFieldValidationStatuses extends Omit<ValidationStatuses, "info"> {};
22+
export interface FormFieldValidationStatuses
23+
extends Omit<ValidationStatuses, "info"> {}
2324
export type FormFieldValidationStatus = Exclude<ValidationStatus, "info">;
2425
export const FormFieldValidationStatusValues: FormFieldValidationStatus[] = [
2526
"error",

packages/styles/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./style-injection-provider";
22
export * from "./use-style-injection";
3+
export * from "./useClassNameInjection";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { clsx } from "clsx";
2+
import { createContext, useContext, useMemo } from "react";
3+
4+
export type ClassNameInjector<Props, Keys extends keyof Props> = (
5+
props: Pick<Props, Keys>,
6+
) => string | undefined;
7+
8+
interface ClassNameInjectorEntry<Props> {
9+
fn: (props: Props) => string | undefined;
10+
keys: (keyof Props)[];
11+
}
12+
13+
export type ClassNameInjectionRegistry = Record<
14+
string,
15+
// biome-ignore lint/suspicious/noExplicitAny: refer to ClassNameInjector which derives it's entry type based on the Props
16+
ClassNameInjectorEntry<any>[]
17+
>;
18+
19+
const InjectionContext = createContext<ClassNameInjectionRegistry | null>(null);
20+
21+
export type ClassNameInjectionProviderProps = {
22+
children: React.ReactNode;
23+
value?: ClassNameInjectionRegistry;
24+
};
25+
26+
export function ClassNameInjectionProvider({
27+
children,
28+
value,
29+
}: ClassNameInjectionProviderProps) {
30+
const registry = useMemo(() => value ?? {}, [value]);
31+
return (
32+
<InjectionContext.Provider value={registry}>
33+
{children}
34+
</InjectionContext.Provider>
35+
);
36+
}
37+
38+
export function useInjectedClassName<
39+
// biome-ignore lint/suspicious/noExplicitAny: props are passed through to the callback as-is
40+
Props extends Record<string, any>,
41+
>(component: string, props: Props): { className: string; props: Props } {
42+
const registry = useContext(InjectionContext);
43+
if (!registry) {
44+
return { className: props?.className || "", props };
45+
}
46+
const entries = registry[component] ?? [];
47+
const injected = entries.map((e) => e.fn(props)).filter(Boolean);
48+
const className = clsx(props?.className, injected);
49+
50+
const cleanProps: Props = { ...props };
51+
52+
for (const entry of entries) {
53+
for (const key of entry.keys) {
54+
delete cleanProps[key as string];
55+
}
56+
}
57+
return { className, props: cleanProps };
58+
}
59+
60+
export function registerClassInjector<Props, Keys extends keyof Props>(
61+
registry: ClassNameInjectionRegistry,
62+
component: string,
63+
keys: Keys[],
64+
injector: ClassNameInjector<Props, Keys>,
65+
) {
66+
registry[component] ??= [];
67+
const wrapped = (props: Props) => injector(props as Props);
68+
registry[component] = [...registry[component], { fn: wrapped, keys }];
69+
}

0 commit comments

Comments
 (0)