From b27c6ee6ef2c2b4ac6adc14cfdbf34b7e90c3a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20=C5=81ebkowski?= Date: Wed, 19 Nov 2025 22:53:52 +0100 Subject: [PATCH] add a showcase test update readme typos & inconsistencies fix automix with parent element --- Readme.md | 18 +++---- src/BemFactory.ts | 4 +- src/index.spec.tsx | 24 +++++++++ src/index.tsx | 22 +++++--- src/showcase.spec.tsx | 115 ++++++++++++++++++++++++++++++++++++++++++ src/toMatchJSX.tsx | 46 +++++++++++++++++ 6 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 src/showcase.spec.tsx create mode 100644 src/toMatchJSX.tsx diff --git a/Readme.md b/Readme.md index 7d0e9c3..bd30e46 100644 --- a/Readme.md +++ b/Readme.md @@ -18,7 +18,7 @@ ```jsx function Acme({ bem: { className, element } }) { return
-

Hello

+

Hello

} ``` @@ -66,7 +66,7 @@ function Acme({ bem: { block } }) { ```jsx function Acme({ bem: { block } }) { - return
+ return
} ``` @@ -94,7 +94,7 @@ function Parent({ bem: { className, element } }) { ```html
-
+
``` @@ -102,12 +102,12 @@ function Parent({ bem: { className, element } }) { ### Using elements with modifiers ```jsx -function Acme({ bem: { block, element } }) { - return
-
-
-
-
+function Acme({ bem: { className, element } }) { + return
+
+
+
+
} ``` diff --git a/src/BemFactory.ts b/src/BemFactory.ts index 67d5891..0716df4 100644 --- a/src/BemFactory.ts +++ b/src/BemFactory.ts @@ -3,13 +3,13 @@ import classNames, { ClassName } from "./classNames"; export default class BemFactory { constructor( private readonly name: string, - private autoMix: string | undefined = undefined, + private autoMix: object | string = "", ) {} block(...modifiers: ClassName[]): string { return classNames( this.name, - this.autoMix, + String(this.autoMix), this.prefixWith(this.name, modifiers), ); } diff --git a/src/index.spec.tsx b/src/index.spec.tsx index a583be2..701837c 100644 --- a/src/index.spec.tsx +++ b/src/index.spec.tsx @@ -99,4 +99,28 @@ describe("withBem", () => { "alpha__bravo px-2", ); }); + + test("automatically mixing with parent block casts to string", () => { + const Child = withBem.named( + "Child", + function Child({ bem: { className } }) { + return
; + }, + ); + + const Parent = withBem.named( + "Parent", + function Parent({ bem: { className, element } }) { + return ( +
+ +
+ ); + }, + ); + const { getByTestId } = render(); + const { className } = getByTestId("component"); + + expect(className).toBe("child parent__element"); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 708beac..72025ba 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from "react"; -import classNames, { ClassNameHash } from "./classNames"; +import classNames, { ClassName } from "./classNames"; import BemFactory from "./BemFactory"; -type TemplateArgs = [TemplateStringsArray, ...ClassNameHash[]]; -type TemplateArgsZipped = (string | ClassNameHash)[]; +type TemplateArgs = [TemplateStringsArray, ...ClassName[]]; +type TemplateArgsZipped = (string | ClassName)[]; type TemplateFn = (...args: TemplateArgs) => string; type MixableTemplateFn = (...args: TemplateArgs) => Mixable; type TemplateFnZipped = (args: TemplateArgsZipped) => string; @@ -14,17 +14,17 @@ type Mixable = string & { }; function taggedLiteral(fn: TemplateFnZipped): TemplateFn { - function* zip(strings: string[], params: ClassNameHash[]) { + function* zip(strings: string[], params: ClassName[]) { yield strings.shift() as string; while (strings.length) { - yield params.shift() as ClassNameHash; + yield params.shift() as ClassName; yield strings.shift() as string; } } return ( modifiers: TemplateStringsArray | undefined = undefined, - ...dynamic: ClassNameHash[] + ...dynamic: ClassName[] ) => fn([...zip([...(modifiers || [])], [...dynamic])]); } @@ -48,7 +48,10 @@ type BemHelper = string & { mix: TemplateFn; }; -function helperFactory(name: string, autoMix: string | undefined): BemHelper { +function helperFactory( + name: string, + autoMix: object | string | undefined, +): BemHelper { const snakeName = name.replace(/([a-z])(?=[A-Z])/g, "$1-").toLowerCase(); const bemFactory = new BemFactory(snakeName, autoMix); const className = bemFactory.toString(); @@ -97,7 +100,10 @@ function createWrappedComponent

( ): React.ComponentType

{ const WrappedComponent = (args: P) => { const parentMix = (args as OptionalClassName)?.className; - const bem = useMemo(() => helperFactory(name, parentMix), [parentMix]); + const bem = useMemo( + () => helperFactory(name, parentMix), + [String(parentMix)], + ); return ; }; WrappedComponent.displayName = `Bem(${name})`; diff --git a/src/showcase.spec.tsx b/src/showcase.spec.tsx new file mode 100644 index 0000000..c4108b6 --- /dev/null +++ b/src/showcase.spec.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { withBem } from "./index"; +import { describe, expect, test } from "@jest/globals"; +import { toMatchJSX } from "./toMatchJSX"; + +expect.extend({ + toMatchJSX, +}); + +describe("showcase", () => { + test("Simplest way to create a block with some elements", () => { + const Acme = withBem.named( + "Acme", + function ({ bem: { className, element } }) { + return ( +

+

Hello

+
+ ); + }, + ); + + expect().toMatchJSX( +
+

Hello

+
, + ); + }); + + test("BEM helper as a shorthand if there are no elements", () => { + const Acme = withBem.named("Acme", function ({ bem }) { + return
Hello
; + }); + + expect().toMatchJSX(
Hello
); + }); + + test("Adding block modifiers", () => { + const Acme = withBem.named("Acme", function ({ bem: { block } }) { + const [toggle, setToggle] = React.useState(true); + const onClick = React.useCallback( + () => setToggle((current) => !current), + [setToggle], + ); + + return ( +
+ +
+ ); + }); + + expect().toMatchJSX( +
+ +
, + ); + }); + + test("Mixing the block with other classes", () => { + const Acme = withBem.named("Acme", function ({ bem: { block } }) { + return
; + }); + + expect().toMatchJSX(
); + }); + + test("Mixing with parent block", () => { + const Child = withBem.named("Child", function Child({ bem: { block } }) { + return
; + }); + + const Parent = withBem.named( + "Parent", + function Parent({ bem: { className, element } }) { + return ( +
+ +
+ ); + }, + ); + + expect().toMatchJSX( +
+
+
, + ); + }); + + test("Using elements with modifiers", () => { + const Acme = withBem.named( + "Acme", + function ({ bem: { className, element } }: withBem.props) { + return ( +
+
+
+
+
+
+ ); + }, + ); + + expect().toMatchJSX( +
+
+
+
+
+
, + ); + }); +}); diff --git a/src/toMatchJSX.tsx b/src/toMatchJSX.tsx new file mode 100644 index 0000000..fdda99d --- /dev/null +++ b/src/toMatchJSX.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { expect } from "@jest/globals"; +import { render } from "@testing-library/react"; + +function isReactJSXElement(received: unknown): received is React.ReactElement { + return ( + typeof received === "object" && + received !== null && + "$$typeof" in received && + received.$$typeof === Symbol.for("react.element") + ); +} + +export function toMatchJSX(received: unknown, expected: unknown) { + if (false === isReactJSXElement(received)) { + return { + pass: false, + message: () => "Expected a JSX element", + }; + } + + if (false === isReactJSXElement(expected)) { + return { + pass: false, + message: () => "Expected a JSX element", + }; + } + + expect(render(received).asFragment().firstChild).toEqual( + render(expected).asFragment().firstChild, + ); + + return { + pass: true, + message: () => "loo", + }; +} + +declare module "expect" { + interface AsymmetricMatchers { + toMatchJSX(expected: React.JSX.Element): void; + } + interface Matchers { + toMatchJSX(expected: React.JSX.Element): R; + } +}