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
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ build/global.css: node_modules dist/bin/token.js
pnpm exec prettier --write $@

tsconfig.json: node_modules tsconfig-vite.src.json
echo "// last updated: $(shell date)" > $@
pnpm exec tsc -p tsconfig-vite.src.json --showConfig 1>> $@
pnpm exec tsc -p tsconfig-vite.src.json --showConfig 1> $@

build: $(SRCS) node_modules vite.config.ts vite-env.d.ts
NODE_ENV=production pnpm exec vite build
Expand Down
5 changes: 3 additions & 2 deletions lib/button.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ export const buttonClassName = style([
},
]);

export const iconClass = style({
export const iconClassName = style({
aspectRatio: '1/1',
lineHeight: 0,
display: 'inline-flex',
width: '0.7em', // hack, this should be variable based on the font-size
});

export const visiblyHiddenClass = style({
Expand Down
88 changes: 52 additions & 36 deletions lib/button.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {
cloneElement,
forwardRef,
isValidElement,
type FC,
type ForwardedRef,
type ReactElement,
isValidElement,
} from 'react';
import { Box, type BoxProps } from './box.js';
import type { ButtonState, ButtonVariant } from './button.css.js';
Expand All @@ -13,7 +12,7 @@ import {
buttonClassName,
buttonStateClassNames,
buttonVariantClassNames,
iconClass,
iconClassName,
inlineBleedClass,
visiblyHiddenClass,
} from './button.css.js';
Expand All @@ -22,7 +21,7 @@ import { useStringLikeDetector } from './hooks/use-string-like.js';
import { Flex, type FlexProps } from './layout.js';
import { Spinner } from './loaders.js';
import type { Falsy, Merge, ReactHTMLElementsHacked } from './types.js';
import { ExactText } from './typography.js';
import { ExactText, type FontSize } from './typography.js';

export { ButtonState, ButtonVariant };

Expand Down Expand Up @@ -73,11 +72,16 @@ export type ButtonIconProps<
const IconBox: FC<{
icon: ReactElement | FC;
busy?: boolean | Falsy;
}> = ({ icon, busy }) => (
<Box component="span" className={[iconClass, busy && visiblyHiddenClass]}>
capSize?: FontSize;
}> = ({ icon, busy, ...props }) => (
<Box
{...props}
component="span"
className={[iconClassName, busy && visiblyHiddenClass]}
>
{isValidElement<ReactElementDefaultPropsType>(icon)
? cloneElement(icon, { className: iconClass })
: icon({ className: iconClass })}
? icon
: icon({} /* { className: iconClassName } */)}
</Box>
);

Expand All @@ -104,14 +108,14 @@ function getSizeProps(size: ButtonSize | Falsy) {
switch (size) {
case 'small':
return {
space: '1',
space: '2',
fontSize: '0',
paddingBlock: '4',
paddingInline: '5',
} satisfies BoxProps;
case 'large':
return {
space: '2',
space: '3',
fontSize: '3',
paddingBlock: '6',
paddingInline: '7',
Expand Down Expand Up @@ -144,7 +148,7 @@ export const Button = forwardRef(
flexGrow,
state,
size = 'medium',
variant = 'default',
variant,
...props
}: ButtonProps<T>,
ref: ForwardedRef<HTMLElement>,
Expand All @@ -163,6 +167,15 @@ export const Button = forwardRef(
'aria-live': busy ? 'polite' : undefined,
} as const;

const autoButtonTypeVariant =
'type' in props && props.type === 'submit' && !variant
? 'primary'
: variant;

const resolvedVariant = autoButtonTypeVariant || 'default';

const hasStringChildren = isStringLike(children);

return (
<UnstyledButton
ref={ref}
Expand All @@ -178,34 +191,37 @@ export const Button = forwardRef(
inline && inlineBleedClass,

// order is important here, as we want the state to override the variant
state && variant && buttonStateClassNames[variant][state],
state &&
resolvedVariant &&
buttonStateClassNames[resolvedVariant][state],

variant && buttonVariantClassNames[variant],
resolvedVariant && buttonVariantClassNames[resolvedVariant],
]}
>
{iconStart && <IconBox icon={iconStart} busy={busy} />}

{!busy &&
(isStringLike(children) ? (
<ExactText
component="span"
textAlign={textAlign}
capSize={fontSize}
{...busyAttributes}
>
{children}
</ExactText>
) : (
children && (
<Box flexGrow component="span" {...busyAttributes}>
{children}
</Box>
)
))}

{busy && <Spinner />}

{iconEnd && <IconBox icon={iconEnd} busy={busy} />}
{iconStart && (
<IconBox capSize={fontSize} busy={busy} icon={iconStart} />
)}

{!busy && hasStringChildren && (
<ExactText
component="span"
textAlign={textAlign}
capSize={fontSize}
{...busyAttributes}
>
{children}
</ExactText>
)}

{!busy && children && !hasStringChildren && (
<Box flexGrow component="span" {...busyAttributes}>
{children}
</Box>
)}

{busy && <Spinner capSize={fontSize} />}

{iconEnd && <IconBox capSize={fontSize} busy={busy} icon={iconEnd} />}
</UnstyledButton>
);
},
Expand Down
4 changes: 2 additions & 2 deletions lib/design-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ export const DesignSystem = <
>({
className,
integrationMode,
stringLikeComponents,
stringLikeComponents = [],
component = 'div',
...props
}: DesignSystemProps<T>): ReactElement | null => (
<DesignSystemContext.Provider
value={{
className,
...(stringLikeComponents && { stringLikeComponents }),
stringLikeComponents,
}}
>
<Box
Expand Down
10 changes: 4 additions & 6 deletions lib/grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,13 @@ import {
import { Box, type BoxProps } from './box.js';
import { matchViewportVariants } from './component-utils.js';
import { gridClass } from './grid.css.js';
import type { Falsy, Merge, ReactHTMLElementsHacked } from './types.js';
import type { Falsy, ReactHTMLElementsHacked } from './types.js';

export type GridProps<T extends keyof ReactHTMLElementsHacked = 'div'> = Merge<
BoxProps<T>,
{
export type GridProps<T extends keyof ReactHTMLElementsHacked = 'div'> =
BoxProps<T> & {
space?: OrResponsive<Space | Falsy>;
cols?: OrResponsive<Columns>;
}
>;
};

export const Grid = <T extends keyof ReactHTMLElementsHacked = 'div'>({
className,
Expand Down
168 changes: 168 additions & 0 deletions lib/hooks/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import type { FC, PropsWithChildren } from 'react';
import { describe, expect, it } from 'vitest';
import { DesignSystem } from '../main.js';
import { useStringLikeDetector } from './use-string-like.js';

const IsStringLikeTester: FC<PropsWithChildren> = (props) => {
const isStringLike = useStringLikeDetector();
return <div data-testid="result">{String(isStringLike(props.children))}</div>;
};

const StringMcStringFace: FC<PropsWithChildren> = (props) => (
<div>{props.children}</div>
);

describe('Hooks', () => {
it('passes string is string like', async () => {
// ARRANGE
render(
<DesignSystem>
<IsStringLikeTester>test</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('true');
});

it('passes fragments with strings', async () => {
// ARRANGE
render(
<DesignSystem>
<IsStringLikeTester>
<>test</>
<>test</>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('true');
});

it('passes fragments with primitives', async () => {
// ARRANGE
render(
<DesignSystem>
<IsStringLikeTester>
<>123</>
<>test</>
<>{null}</>
<>{undefined}</>
{undefined}
{null}
{true}
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('true');
});

it('passes nested fragments with strings', async () => {
// ARRANGE
render(
<DesignSystem>
<IsStringLikeTester>
<>
<>
<>test</>
</>
</>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('true');
});

it('fails with element', async () => {
// ARRANGE
render(
<DesignSystem>
<IsStringLikeTester>
<h1>NOT STRING LIKE</h1>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('false');
});

it('fails nested fragments with element', async () => {
// ARRANGE
render(
<DesignSystem>
<IsStringLikeTester>
<>
<>
<>
<h1>BAD</h1>
</>
</>
</>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('false');
});

it('passes with custom string like', async () => {
// ARRANGE
render(
<DesignSystem stringLikeComponents={[StringMcStringFace]}>
<IsStringLikeTester>
<StringMcStringFace>WORKS</StringMcStringFace>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('true');
});

it('passes with fragment nested custom string like', async () => {
// ARRANGE
render(
<DesignSystem stringLikeComponents={[StringMcStringFace]}>
<IsStringLikeTester>
<>
<>
<StringMcStringFace>WORKS</StringMcStringFace>
</>
</>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('true');
});

it('fails with fragment nested custom string like + bad', async () => {
// ARRANGE
render(
<DesignSystem stringLikeComponents={[StringMcStringFace]}>
<IsStringLikeTester>
<>
<>
<StringMcStringFace>GOOD</StringMcStringFace>
<h1>BAD</h1>
</>
</>
</IsStringLikeTester>
</DesignSystem>,
);

// ASSERT
expect(screen.getByTestId('result')).toHaveTextContent('false');
});
});
Loading