Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Major Enhancements to Styled Function #5

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
9 changes: 8 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from 'react';

import CountButton from '@/components/CountButton';
import StaticButton from '@/components/StaticButton';
import InheritanceDemo from '@/components/InheritanceDemo';
import CompositionDemo from '@/components/CompositionDemo';
import styled from '@/styled.js';

export default function Home() {
Expand All @@ -13,11 +15,16 @@ export default function Home() {
*/}
<CountButton />
<StaticButton />
<InheritanceDemo />
<CompositionDemo />
</Wrapper>
);
}

const Wrapper = styled('main')`
const Wrapper = styled.main`
display: flex;
flex-direction: column;
gap: 12px;
max-width: 38rem;
margin: 0 auto;
padding: 32px;
Expand Down
55 changes: 55 additions & 0 deletions src/components/CompositionDemo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import styled from '../styled';

export default function ComponentsInterpolationDemo() {
return (
<OuterWrapper>
<Wrapper>
<TextWrapper>
Components interpolation demo with wrapper
<Emoji>😅</Emoji>
</TextWrapper>
</Wrapper>
<TextWrapper>
Components interpolation demo without wrapper
<Emoji>😅</Emoji>
</TextWrapper>
</OuterWrapper>
);
}

const OuterWrapper = styled.div`
border: 1px solid red;
border-radius: 8px;
`;

const Wrapper = styled.div`
background-color: hsl(180deg 80% 40%);
border-radius: 8px;
`;

const Emoji = styled.span`
transition: transform 200ms;
display: block;

${Wrapper} & {
transform: rotate(180deg);
}
`;

const TextWrapper = styled.span`
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
font-size: 1.3rem;
padding: 12px;

&:hover {
color: hsl(350deg 100% 40%);
}

&:hover ${Emoji} {
transform: scale(1.3);
}
`;
2 changes: 1 addition & 1 deletion src/components/CountButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function CountButton() {
// Currently, this doesn't work, because `cache()` can't be used in
// Client Components. It throws an error, and none of the styles get
// created.
const Button = styled('button')`
const Button = styled.button`
padding: 1rem 2rem;
color: red;
font-size: 1rem;
Expand Down
37 changes: 37 additions & 0 deletions src/components/InheritanceDemo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import styled from '../styled.js';

export default function InheritanceDemo() {
return (
<Wrapper>
<ChildButton primary>Primary child Button</ChildButton>
<ChildButton>Not primary child Button</ChildButton>
<ParentButton primary>Primary parent Button</ParentButton>
<ParentButton>Not primary parent Button</ParentButton>
</Wrapper>
);
}

const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;

const ParentButton = styled.button`
display: block;
padding: 1rem 2rem;
border: none;
border-radius: 4px;
background: ${(props) =>
props.primary ? 'hsl(270deg 100% 30%)' : 'hsl(100deg 100% 30%)'};
color: white;
font-size: 1rem;
cursor: pointer;
`;

const ChildButton = styled(ParentButton)`
border: 5px solid red;
color: black;
font-weight: 600;
`;
7 changes: 4 additions & 3 deletions src/components/StaticButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import React from 'react';
import styled from '../styled.js';

export default function StaticButton() {
return <Button>Static Button</Button>;
return <Button primary>Static Button</Button>;
}

const Button = styled('button')`
const Button = styled.button`
display: block;
padding: 1rem 2rem;
border: none;
border-radius: 4px;
background: hsl(270deg 100% 30%);
background: ${(props) =>
props.primary ? 'hsl(270deg 100% 30%)' : 'hsl(180deg 100% 30%)'};
color: white;
font-size: 1rem;
cursor: pointer;
Expand Down
19 changes: 18 additions & 1 deletion src/components/StyleRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,29 @@ export const cache = React.cache(() => {
return [];
});

const sortFn = (a, b) => {
const aClassCount = a.fullClassName?.split(' ').length;
const bClassCount = b.fullClassName?.split(' ').length;

if (aClassCount === bClassCount) {
// If they have the same class count, maintain the original order.
return 0;
} else {
// Sort by the number of classes in ascending order.
return aClassCount - bClassCount;
}
};

function StyleRegistry({ children }) {
const collectedStyles = cache();
const stylesMap = React.useMemo(
() => collectedStyles.sort(sortFn).map(({ css }) => css),
[collectedStyles]
);

return (
<>
<StyleInserter styles={collectedStyles} />
<StyleInserter styles={stylesMap} />
{children}
</>
);
Expand Down
47 changes: 47 additions & 0 deletions src/helpers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export function removeCSSComments(templateStringsArray) {
return templateStringsArray.map((templateString) =>
templateString.replace(/\/\*[\s\S]*?\*\//g, '')
);
}

export function interpolateProps(value, props) {
const interpolatedValue =
(typeof value === 'function' ? value(props) : value) || '';

if (typeof interpolatedValue !== 'object') return interpolatedValue;

const className = interpolatedValue?.props?.className;
if (className) return '.' + className;

return '';
}

export function formatCSSBlocks(css, className) {
const subBlocksRegex = /(?:^\s*|\n\s*)([.&][^{]+{\s*[^}]*})/gm;
const mainBlock = css.replace(subBlocksRegex, '').trim();

const subBlocks = css.match(subBlocksRegex) || [];

return {
mainBlock,
subBlocks: subBlocks.join('\n').replaceAll('&', `.${className}`).trim(),
styles: `.${className} { ${mainBlock} }`,
};
}

function normalizeCSS(css) {
return css
.replace(/\s+/g, ' ') // Reemplaza cualquier secuencia de espacios en blanco con un solo espacio
.trim(); // Elimina espacios al inicio y al final
}

export function findExistingStyle(stylesArray, css) {
return stylesArray.find((style) => {
const cssContentRegex = /{\s*([^}]*)\s*}/;
const match = style.css.match(cssContentRegex);

const cssContent = match ? normalizeCSS(match[1]) : '';

return cssContent === normalizeCSS(css);
});
}
113 changes: 96 additions & 17 deletions src/styled.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,104 @@
import React from 'react';

import { cache } from './components/StyleRegistry';
import { areObjectsEqual } from './utils';
import {
removeCSSComments,
interpolateProps,
formatCSSBlocks,
findExistingStyle,
} from './helpers';

/**
* @typedef {(css: TemplateStringsArray) => React.FunctionComponent<React.HTMLProps<HTMLElement>>} StyledFunction
* @typedef {Record<keyof JSX.IntrinsicElements, StyledFunction> & { (tag: keyof JSX.IntrinsicElements | React.ComponentType<any>): StyledFunction }} StyledInterface
*/

/**
* Creating a Proxy around the `styled` function.
* This allows us to intercept property accesses (like styled.div)
* and treat them as function calls (like styled('div')).
*
* @type {StyledInterface}
*/
const styled = new Proxy(
function (Tag) {
// The original styled function that creates a styled component
return (templateStrings, ...interpolatedProps) => {
return function StyledComponent(props) {
const collectedStyles = cache();

const id = React.useId().replace(/:/g, '');
const generatedClassName = `styled-${id}`;

const { className: propsClassName, children, ...restProps } = props;

const cleanedCSS = removeCSSComments(templateStrings);

const interpolatedCSS = cleanedCSS.reduce(
(acc, current, i) =>
acc + current + interpolateProps(interpolatedProps[i], props),
''
);

// TODO: Ideally, this API would use dot notation (styled.div) in
// addition to function calls (styled('div')). We should be able to
// use Proxies for this, like Framer Motion does.
export default function styled(Tag) {
return (css) => {
return function StyledComponent(props) {
let collectedStyles = cache();
const {
styles: finalCSS,
mainBlock,
subBlocks,
} = formatCSSBlocks(interpolatedCSS, generatedClassName);

// Instead of using the filename, I'm using the `useId` hook to
// generate a unique ID for each styled-component.
const id = React.useId().replace(/:/g, '');
const generatedClassName = `styled-${id}`;
const matchedStyle = findExistingStyle(collectedStyles, mainBlock, Tag);

const styleContent = `.${generatedClassName} { ${css} }`;
const hasParentComponent = typeof Tag === 'function';

collectedStyles.push(styleContent);
// If tag is another styled-component, we need to get that className in order to use it from the child.
const parentClassName = hasParentComponent
? Tag(props)?.props?.className
: '';

return <Tag className={generatedClassName} {...props} />;
// If there's no parentClassName, that space gets removed when trimmed
const fullClassName = `${parentClassName} ${generatedClassName}`.trim();

if (matchedStyle) {
// If they have the same styles and props, just use the same full class name (parent className + )
if (areObjectsEqual(matchedStyle.props, restProps)) {
return <Tag className={matchedStyle.fullClassName} {...props} />;
}

// If they have the same styles but different props, use the current parent class name, and the matched style one
const className =
`${parentClassName} ${matchedStyle.className}`.trim();

return <Tag className={className} {...props} />;
}

collectedStyles.push({
fullClassName,
className: generatedClassName,
props: restProps,
css: finalCSS,
});

if (subBlocks) {
collectedStyles.push({
css: subBlocks,
});
}

return <Tag className={fullClassName} {...props} />;
};
};
};
}
},
{
get: function (target, prop) {
// Intercepting property access on the `styled` function.
if (typeof prop === 'string') {
// If the property is a string, call the original `styled` function with the property name as the tag.
return target(prop);
}
// Default behavior for other properties
return Reflect.get(target, prop);
},
}
);

export default styled;
16 changes: 16 additions & 0 deletions src/utils/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function areObjectsEqual(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);

if (keys1.length !== keys2.length) {
return false;
}

for (const key of keys1) {
if (obj1[key] !== obj2[key]) {
return false;
}
}

return true;
}