Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
```jsx
function Acme({ bem: { className, element } }) {
return <div className={className}>
<h1 class={element`heading`}>Hello</h1>
<h1 className={element`heading`}>Hello</h1>
</div>
}
```
Expand Down Expand Up @@ -66,7 +66,7 @@ function Acme({ bem: { block } }) {

```jsx
function Acme({ bem: { block } }) {
return <div className={mix`me-2 d-flex`}>
return <div className={block``.mix`me-2 d-flex`}>
</div>
}
```
Expand Down Expand Up @@ -94,20 +94,20 @@ function Parent({ bem: { className, element } }) {

```html
<div class="parent">
<div class="child parent__element child--active me2"/>
<div class="child parent__element child--active me-2"/>
</div>
```


### Using elements with modifiers

```jsx
function Acme({ bem: { block, element } }) {
return <div className={block}>
<div class={element`item ${{ selected: true }} me-2`} />
<div class={element`item ${{ variant: 'primary' }}`} />
<div class={element`item ${['theme-dark']}`} />
<div class={element`item`.mix`d-flex`} />
function Acme({ bem: { className, element } }) {
return <div className={className}>
<div className={element`item ${{ selected: true }} me-2`} />
<div className={element`item ${{ variant: 'primary' }}`} />
<div className={element`item ${['theme-dark']}`} />
<div className={element`item`.mix`d-flex`} />
</div>
}
```
Expand Down
4 changes: 2 additions & 2 deletions src/BemFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
Expand Down
24 changes: 24 additions & 0 deletions src/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className={className} data-testid="component" />;
},
);

const Parent = withBem.named(
"Parent",
function Parent({ bem: { className, element } }) {
return (
<div className={className}>
<Child className={element`element`} />
</div>
);
},
);
const { getByTestId } = render(<Parent />);
const { className } = getByTestId("component");

expect(className).toBe("child parent__element");
});
});
22 changes: 14 additions & 8 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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])]);
}

Expand All @@ -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();
Expand Down Expand Up @@ -97,7 +100,10 @@ function createWrappedComponent<P>(
): React.ComponentType<P & OptionalClassName> {
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 <Component {...args} bem={bem} />;
};
WrappedComponent.displayName = `Bem(${name})`;
Expand Down
115 changes: 115 additions & 0 deletions src/showcase.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className}>
<h1 className={element`heading`}>Hello</h1>
</div>
);
},
);

expect(<Acme />).toMatchJSX(
<div className="acme">
<h1 className="acme__heading">Hello</h1>
</div>,
);
});

test("BEM helper as a shorthand if there are no elements", () => {
const Acme = withBem.named("Acme", function ({ bem }) {
return <div className={bem}>Hello</div>;
});

expect(<Acme />).toMatchJSX(<div className="acme">Hello</div>);
});

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 (
<div className={block`${{ toggle }} always-enabled`}>
<button onClick={onClick}>Toggle</button>
</div>
);
});

expect(<Acme />).toMatchJSX(
<div className="acme acme--toggle acme--always-enabled">
<button>Toggle</button>
</div>,
);
});

test("Mixing the block with other classes", () => {
const Acme = withBem.named("Acme", function ({ bem: { block } }) {
return <div className={block``.mix`me-2 d-flex`}></div>;
});

expect(<Acme />).toMatchJSX(<div className="acme me-2 d-flex" />);
});

test("Mixing with parent block", () => {
const Child = withBem.named("Child", function Child({ bem: { block } }) {
return <div className={block`${{ active: true }}`.mix`me-2`} />;
});

const Parent = withBem.named(
"Parent",
function Parent({ bem: { className, element } }) {
return (
<div className={className}>
<Child className={element`element`} />
</div>
);
},
);

expect(<Parent />).toMatchJSX(
<div className="parent">
<div className="child parent__element child--active me-2" />
</div>,
);
});

test("Using elements with modifiers", () => {
const Acme = withBem.named(
"Acme",
function ({ bem: { className, element } }: withBem.props) {
return (
<div className={className}>
<div className={element`item ${{ selected: true }} me-2`} />
<div className={element`item ${{ variant: "primary" }}`} />
<div className={element`item ${["theme-dark"]}`} />
<div className={element`item`.mix`d-flex`} />
</div>
);
},
);

expect(<Acme />).toMatchJSX(
<div className="acme">
<div className="acme__item acme__item--selected me-2" />
<div className="acme__item acme__item--variant-primary" />
<div className="acme__item acme__item--theme-dark" />
<div className="acme__item d-flex" />
</div>,
);
});
});
46 changes: 46 additions & 0 deletions src/toMatchJSX.tsx
Original file line number Diff line number Diff line change
@@ -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<R> {
toMatchJSX(expected: React.JSX.Element): R;
}
}