Skip to content

Instantly share code, notes, and snippets.

@Salemsky
Created February 14, 2025 15:25
Show Gist options
  • Save Salemsky/ec99bf42f3b4912b2b569f55237837d1 to your computer and use it in GitHub Desktop.
Save Salemsky/ec99bf42f3b4912b2b569f55237837d1 to your computer and use it in GitHub Desktop.
sc responsive button
import { mqw, rem, styled } from '@styled';
import Link from 'next/link';
import { Icon } from './Icon';
import { Label } from './Label';
const PX_SIZE = {
sm: { h: 30, p: 6 },
md: { h: 35, p: 8 },
lg: { h: 40, p: 10 },
};
type Props<T = HTMLButtonElement | HTMLAnchorElement> = Readonly<
Partial<
Pick<Parameters<typeof Link>[0], 'href' | 'rel' | 'target' | 'title'>
> & {
className?: string;
disabled?: boolean;
ellipsis?: Parameters<typeof Label>[0]['ellipsis'];
icon?: Parameters<typeof Icon>[0]['element'];
iconLocation?: 'before' | 'after';
id?: string;
label?: Parameters<typeof Label>[0]['text'];
onClick?: import('react').MouseEventHandler<T>;
ref?: import('react').Ref<T>;
size?: keyof typeof PX_SIZE;
type?: import('react').ComponentProps<'button'>['type'];
}
>;
type StyledButtonProps = Omit<
Props & Required<Pick<Props, 'size'>> & { as?: 'button' | 'a' },
'icon' | 'iconLocation' | 'label' | 'ellipsis'
>;
const StyledButton = styled(Link).withConfig({
shouldForwardProp: (p) => !['size'].includes(p),
})<StyledButtonProps>`
display: inline-flex;
align-items: center;
position: relative;
border: 0;
border-radius: var(--button-border-radius, 4px);
box-sizing: border-box;
background-color: var(--button-background-color, ButtonFace);
color: var(--button-color, inherit);
font-family: inherit;
text-decoration: none;
outline: 0;
margin: 0;
overflow: hidden;
&[disabled] {
cursor: default;
opacity: var(--button-disabled-opacity, 0.6);
}
&:not([disabled]) {
cursor: pointer;
}
&:is(button) {
user-select: none;
}
&:is(a) {
&:link ${Label.selector} {
color: var(--button-link-color-text, LinkText);
text-decoration: var(--button-link-text-decoration, underline);
}
&:visited ${Label.selector} {
color: var(--button-link-color-visited, VisitedText);
}
}
${Icon.selector}:not(:only-child) {
flex-shrink: 0;
}
${Label.selector} {
white-space: nowrap;
text-rendering: optimizeSpeed;
}
${({ size }): import('styled-components').RuleSet => {
const { h, p } = PX_SIZE[size];
return [
mqw({ min: 'mobile' })`
height: calc(var(--button-height-${size}, ${rem(h)}) - var(--button-mobile-height-reduced, ${rem(4)}));
padding-inline: calc(var(--button-padding-x-${size}, ${rem(p)}) - var(--button-mobile-padding-x-reduced, ${rem(2)}));
${Icon.selector}:not(:only-child):first-child {
margin-right: var(--button-mobile-icon-margin, 6px);
}
${Icon.selector}:not(:only-child):last-child {
margin-left: var(--button-mobile-icon-margin, 6px);
}
`,
mqw({ min: 'tablet-portrait' })`
height: var(--button-height-${size}, ${rem(h)});
padding-inline: var(--button-padding-x-${size}, ${rem(p)});
${Icon.selector}:not(:only-child):first-child {
margin-right: var(--button-icon-margin, 8px);
}
${Icon.selector}:not(:only-child):last-child {
margin-left: var(--button-icon-margin, 8px);
}
`,
];
}}
`;
export type ButtonComponent<
P extends object = Props,
S extends 'icon' | 'label' = 'icon' | 'label',
> = {
(p: P): import('react').ReactElement<P>;
selector: string & { [k in S]?: string };
};
export const Button: ButtonComponent = ({
className,
disabled,
ellipsis,
href,
icon,
iconLocation = 'before',
id,
label,
onClick,
ref,
rel,
size = 'md',
target,
title,
type,
}) => {
type = !href ? type || 'button' : undefined;
target =
typeof href === 'string' && href.match(/^https?:/i)
? target || '_blank'
: undefined;
rel = !href || target !== '_blank' ? rel : 'noopener noreferrer';
const click: NonNullable<Props['onClick']> = (ev) => {
!disabled ? onClick?.(ev) : ev.preventDefault();
};
const renderedIcon = icon && <Icon element={icon} size={size} />;
const renderedLabel = label && (
<Label ellipsis={ellipsis} size={size} text={label} />
);
return (
<StyledButton
aria-disabled={disabled}
aria-label={label}
as={!href ? 'button' : 'a'}
className={className}
disabled={disabled}
href={href}
id={id}
onClick={click}
ref={ref}
rel={rel}
size={size}
target={target}
title={title}
type={type}
>
{iconLocation !== 'after' && renderedIcon}
{renderedLabel}
{iconLocation === 'after' && renderedIcon}
</StyledButton>
);
};
Button.selector = 'selector';
Object.defineProperty(Button, Button.selector, {
get(this: typeof StyledButton): ButtonComponent['selector'] {
return Object.assign(
this.styledComponentId
? `.${this.styledComponentId}`
: StyledButton.toString(),
{ icon: Icon.selector, label: Label.selector },
);
},
});
'use client';
import { styled } from '@styled';
import { useRef } from 'react';
import { Button } from './Button.tsx';
const ButtonPrimary = styled(Button)`
background: var(--button-primary-background);
${Button.selector.label} {
color: var(--button-primary-text-color);
}
@media not all and (pointer: coarse) {
&:not([disabled]):hover {
background: var(--button-primary-background-hover);
}
&:not([disabled]):active {
background: var(--button-primary-background-active);
}
}
@media (pointer: coarse) {
&:not([disabled]):active {
background: var(--button-primary-background-active);
}
}
`;
const StyledItem = styled.div`
display: flex;
flex-wrap: wrap;
align-items: center;
position: relative;
margin: 10px 50px 10px 50px;
${ButtonPrimary} {
margin: 10px 5px 10px 5px;
}
${ButtonPrimary}:last-child {
width: 80px;
}
${ButtonPrimary} {
${ButtonPrimary.selector.icon} {
color: purple;
}
${ButtonPrimary.selector.label} {
color: inherit;
}
}
`;
const ButtonsShelf = (): Array<import('react').ReactElement> => {
const params: Array<Parameters<typeof ButtonPrimary>[0]> = [
{
ref: useRef<HTMLButtonElement>(null),
icon: (p) => (
<svg fill="red" {...p}>
<path d="m6 7 6 6 6-6 2 2-8 8-8-8z" />
</svg>
),
onClick(ev: import('react').MouseEvent<HTMLButtonElement>): void {
console.log(ev);
},
},
{
ref: useRef<HTMLAnchorElement>(null),
icon: 'logo-github',
label: 'disabled',
disabled: true,
href: 'http://localhost:8000/',
onClick(): void {
console.log('c');
},
},
{
icon: 'logo-github',
label: 'sm',
size: 'sm',
},
{
icon: 'logo-github',
label: 'md',
size: 'md',
},
{
icon: 'logo-github',
label: 'lg',
size: 'lg',
},
{
label: 'route',
title: 'route',
href: '/about',
},
{
label: 'external',
title: 'external',
href: 'http://localhost:4000/',
},
{
icon: 'logo-github',
label: 'ddddddddddddddddddddddd',
ellipsis: true,
},
];
return params.map((p, i) => <ButtonPrimary key={i} {...p} />);
};
export default function Home(): import('react').ReactElement {
return (
<StyledItem>
<ButtonsShelf />
</StyledItem>
);
}
import { mqw, styled } from '@styled';
import { icons } from './Icons';
const PX_SIZE = {
sm: 20,
md: 24,
lg: 28,
};
type SVGComponent = (typeof icons)[keyof typeof icons];
type Props = Readonly<{
aspectRatio?: number;
element: SVGComponent | keyof typeof icons | undefined;
size?: keyof typeof PX_SIZE;
}>;
type StyledIconProps = Required<Omit<Props, 'aspectRatio' | 'element'>>;
const StyledIcon = styled.div.withConfig({
shouldForwardProp: (p) => !['size'].includes(p),
})<StyledIconProps>`
display: inline-flex;
position: relative;
align-items: center;
overflow: hidden;
user-select: none;
pointer-events: none;
${({ size }): import('styled-components').RuleSet => {
const v = PX_SIZE[size];
return [
mqw({ min: 'mobile' })`
width: calc(var(--icon-size-${size}, ${v}px) - var(--icon-mobile-size-reduced, 4px));
height: calc(var(--icon-size-${size}, ${v}px) - var(--icon-mobile-size-reduced, 4px));
`,
mqw({ min: 'tablet-portrait' })`
width: var(--icon-size-${size}, ${v}px);
height: var(--icon-size-${size}, ${v}px);
`,
];
}}
`;
const StyledIconAspectRatio = styled.div.withConfig({
shouldForwardProp: (p) => !['aspectRatio'].includes(p),
})<Required<Pick<Props, 'aspectRatio'>>>`
position: relative;
width: 100%;
overflow: hidden;
aspect-ratio: ${({ aspectRatio }): number => aspectRatio};
& > * {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
`;
export type IconComponent<P extends object = Props> = {
(p: P): import('react').ReactElement<P> | null;
selector: string;
};
export const Icon: IconComponent = ({
aspectRatio = 1,
element,
size = 'md',
}) => {
if (!element) return null;
const Svg = typeof element === 'string' ? icons[element] : element;
const rendered =
typeof Svg === 'function'
? Svg({
fill: 'currentColor',
width: '100%',
height: '100%',
viewBox: '0 0 24 24',
xmlns: 'http://www.w3.org/2000/svg',
...Svg().props,
})
: Svg;
return (
<StyledIcon size={size}>
<StyledIconAspectRatio aspectRatio={aspectRatio}>
{rendered}
</StyledIconAspectRatio>
</StyledIcon>
);
};
Icon.selector = StyledIcon.toString();
type SVGComponent = (
p?: import('react').SVGProps<SVGSVGElement>,
) => Omit<import('react').ReactSVGElement, 'ref'>;
type Direction = 'left' | 'right' | 'up' | 'down';
const dir: Record<Direction, string> = {
left: 'rotate(90deg)',
right: 'rotate(-90deg)',
up: 'rotate(180deg)',
down: 'rotate(0)',
};
const Arrow: Record<Direction, SVGComponent> = Object.create(null);
for (const k in dir) {
Arrow[k as Direction] = (p): ReturnType<SVGComponent> => (
<svg style={{ transform: dir[k as Direction] }} viewBox="0 0 24 24" {...p}>
<path d="m6 7 6 6 6-6 2 2-8 8-8-8z" />
</svg>
);
}
const LogoGitHub: SVGComponent = (p) => (
<svg viewBox="0 0 24 24" {...p}>
<path d="M12.5.75C6.146.75 1 5.896 1 12.25c0 5.089 3.292 9.387 7.863 10.91.575.101.79-.244.79-.546 0-.273-.014-1.178-.014-2.142-2.889.532-3.636-.704-3.866-1.35-.13-.331-.69-1.352-1.18-1.625-.402-.216-.977-.748-.014-.762.906-.014 1.553.834 1.769 1.179 1.035 1.74 2.688 1.25 3.349.948.1-.747.402-1.25.733-1.538-2.559-.287-5.232-1.279-5.232-5.678 0-1.25.445-2.285 1.178-3.09-.115-.288-.517-1.467.115-3.048 0 0 .963-.302 3.163 1.179.92-.259 1.897-.388 2.875-.388.977 0 1.955.13 2.875.388 2.2-1.495 3.162-1.179 3.162-1.179.633 1.581.23 2.76.115 3.048.733.805 1.179 1.825 1.179 3.09 0 4.413-2.688 5.39-5.247 5.678.417.36.776 1.05.776 2.128 0 1.538-.014 2.774-.014 3.162 0 .302.216.662.79.547C20.709 21.637 24 17.324 24 12.25 24 5.896 18.854.75 12.5.75Z" />
</svg>
);
export const icons = {
'arrow-left': Arrow.left,
'arrow-right': Arrow.right,
'arrow-up': Arrow.up,
'arrow-down': Arrow.down,
'logo-github': LogoGitHub,
};
import { mqw, rem, styled } from '@styled';
const PX_SIZE = {
sm: 10,
md: 12,
lg: 16,
};
type Props = Readonly<{
ellipsis?: boolean;
size?: keyof typeof PX_SIZE;
text: string;
title?: string;
}>;
type StyledLabelProps = Props & Required<Pick<Props, 'size'>>;
const StyledLabel = styled.span.withConfig({
shouldForwardProp: (p) => !['ellipsis', 'size', 'text'].includes(p),
})<StyledLabelProps>`
font-size: inherit;
color: inherit;
${({ ellipsis }): string | false | undefined =>
ellipsis &&
'text-overflow: ellipsis; white-space: nowrap; overflow: hidden;'}
${({ size }): import('styled-components').RuleSet => {
const v = PX_SIZE[size];
return [
mqw({ min: 'mobile' })`
font-size: calc(var(--label-size-${size}, ${rem(v)}) - var(--label-mobile-size-reduced, ${rem(0.5)}));
`,
mqw({ min: 'tablet-portrait' })`
font-size: var(--label-size-${size}, ${rem(v)});
`,
];
}}
`;
export type LabelComponent<P extends object = Props> = {
(p: P): import('react').ReactElement<P>;
selector: string;
};
export const Label: LabelComponent = ({
ellipsis,
size = 'md',
text,
title,
}) => {
return (
<StyledLabel
ellipsis={ellipsis}
size={size}
text={text}
title={ellipsis ? text : title}
>
{text}
</StyledLabel>
);
};
Label.selector = StyledLabel.toString();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment