Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:

strategy:
matrix:
node-version: [14.x, 16.x]
node-version: [20.x, 22.x]

steps:
- uses: actions/checkout@v2
Expand Down
179 changes: 149 additions & 30 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
"description": "shared components for our websites",
"main": "dst/index.js",
"module": "dst/index.esm.js",
"types": "dst/types/index.d.ts",
"scripts": {
"build": "rimraf dst && microbundle src/index.js -o dst/index.js --jsx React.createElement -f modern,es,cjs --jsxFragment React.Fragment",
"watch": "microbundle watch src/index.js -o dst/index.js --jsx React.createElement -f modern,es,cjs --jsxFragment React.Fragment",
"format": "prettier --write 'src/**/*.js' '*.css'"
"clean": "rimraf dst",
"build": "npm run clean && tsc --emitDeclarationOnly && microbundle src/index.ts -o dst/index.js --jsx React.createElement -f modern,es,cjs --jsxFragment React.Fragment",
"watch": "npm run clean && microbundle watch src/index.ts -o dst/index.js --jsx React.createElement -f modern,es,cjs --jsxFragment React.Fragment & tsc --watch --emitDeclarationOnly",
"format": "prettier --write 'src/**/*.{ts,tsx,js}' '*.css'"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -45,8 +47,12 @@
},
"devDependencies": {
"@carbonplan/prettier": "^1.2.0",
"@types/node": "^22.13.10",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"microbundle": "^0.13.0",
"prettier": "^2.2.1",
"rimraf": "3.0.2"
"rimraf": "3.0.2",
"typescript": "^5.8.2"
}
}
65 changes: 45 additions & 20 deletions src/avatar-group.js → src/avatar-group.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react'
import { Box } from 'theme-ui'
import Avatar from './avatar'
import Row from './row'
import { Box, ResponsiveStyleValue } from 'theme-ui'
import Avatar, { AvatarProps } from './avatar'
import Row, { RowProps } from './row'
import Column from './column'
import Group from './group'
import Group, { GroupProps } from './group'

const sizes = {
xs: [1],
Expand All @@ -13,7 +13,14 @@ const sizes = {
xl: [9],
}

const Blank = ({ overflow, maxWidth }) => {
type SizeKey = keyof typeof sizes

type BlankProps = {
overflow: number
maxWidth?: string | number
}

const Blank = ({ overflow, maxWidth }: BlankProps) => {
return (
<Box
sx={{
Expand Down Expand Up @@ -45,6 +52,21 @@ const Blank = ({ overflow, maxWidth }) => {
)
}

type Alignment = 'left' | 'right'

type StartValue = 'auto' | number | (number | 'auto')[]

export interface AvatarGroupProps extends RowProps, GroupProps {
members: AvatarProps[]
direction?: 'horizontal' | 'vertical'
align?: Alignment | Alignment[]
spacing?: SizeKey | ResponsiveStyleValue<number | string>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh ResponsiveStyleValue is so handy!

limit?: number
width?: string
maxWidth?: string | number
fixedCount?: number
}

const AvatarGroup = ({
members,
direction = 'horizontal',
Expand All @@ -56,45 +78,48 @@ const AvatarGroup = ({
fixedCount,
sx,
...props
}) => {
let gap
if (sizes.hasOwnProperty(spacing)) {
gap = sizes[spacing]
}: AvatarGroupProps) => {
let gap: ResponsiveStyleValue<number | string>

if (typeof spacing === 'string' && spacing in sizes) {
gap = sizes[spacing as SizeKey]
} else {
gap = spacing
gap = spacing as ResponsiveStyleValue<number | string>
}

let start = (idx) => 'auto'
let start = (idx: number): StartValue => 'auto'
if (align) {
if (!Array.isArray(align)) {
align = [align]
}
start = (idx) =>
align.map((d) => {
start = (idx: number): StartValue => {
const alignArray = align as Alignment[]
return alignArray.map((d: Alignment) => {
if (d === 'left') {
return 'auto'
} else if (d === 'right') {
const offset = Math.max(1, fixedCount - members.length + 1)
return (offset + idx) % fixedCount
const offset = Math.max(1, (fixedCount ?? 0) - members.length + 1)
return (offset + idx) % (fixedCount ?? 1)
} else {
throw Error(`alignment '${align}' not recognized`)
throw Error(`alignment '${d}' not recognized`)
}
})
}
}

const excess = members.length > limit
const overflow = members.length - limit + 1
const excess = limit !== undefined && members.length > limit
const overflow = limit !== undefined ? members.length - limit + 1 : 0

return (
<>
{fixedCount && (
<Row columns={fixedCount} gap={gap} sx={sx} {...props}>
{members.map((props, idx) => (
<Column key={idx} start={start(idx)}>
{(!excess || idx < limit - 1) && (
{(!excess || (limit !== undefined && idx < limit - 1)) && (
<Avatar {...props} width={width} maxWidth={maxWidth} />
)}
{excess && idx === limit - 1 && (
{excess && limit !== undefined && idx === limit - 1 && (
<Blank overflow={overflow} maxWidth={maxWidth} />
)}
</Column>
Expand Down
14 changes: 12 additions & 2 deletions src/avatar.js → src/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import React from 'react'
import { Box, Image } from 'theme-ui'
import { Box, Image, BoxProps } from 'theme-ui'

export interface AvatarProps extends BoxProps {
color?: string
width?: string
maxWidth?: string | number
name?: string
github?: string
alt?: string
src?: string
}

const Avatar = ({
color = 'transparent',
Expand All @@ -11,7 +21,7 @@ const Avatar = ({
src,
sx,
...props
}) => {
}: AvatarProps) => {
if (!name && !src && !github) {
console.warn('must specify either name, github, or src')
}
Expand Down
10 changes: 8 additions & 2 deletions src/badge.js → src/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import React from 'react'
import { Box } from 'theme-ui'
import { Box, BoxProps, ThemeUIStyleObject } from 'theme-ui'
import { transparentize } from '@theme-ui/color'

const Badge = ({ sx, children, ...props }) => {
export interface BadgeProps extends BoxProps {
sx?: ThemeUIStyleObject & {
color?: string // ThemeUIStyleObject doesn't have a color property
}
}

const Badge = ({ sx, children, ...props }: BadgeProps) => {
const color = sx && sx.color ? sx.color : 'primary'
return (
<Box
Expand Down
9 changes: 5 additions & 4 deletions src/blockquote.js → src/blockquote.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React, { Children } from 'react'
import React, { Children, PropsWithChildren } from 'react'
import { Box } from 'theme-ui'

export type BlockquoteProps = PropsWithChildren<{}>

const specialChars = ['“', '"', "'", '‘']

const Blockquote = ({ children }) => {
const Blockquote = ({ children }: BlockquoteProps) => {
return (
<Box variant='styles.blockquote'>
{Children.map(children, (d, i) => {
let firstChar = ''
let remaining = children

if (d.props && typeof d.props.children === 'string') {
if (React.isValidElement(d) && typeof d.props.children === 'string') {
firstChar = d.props.children.slice(0, 1)
remaining = d.props.children.slice(1)
} else if (typeof d === 'string') {
Expand Down
71 changes: 52 additions & 19 deletions src/button.js → src/button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import React, { forwardRef, cloneElement } from 'react'
import { Box } from 'theme-ui'
import Link from './link'
import { Box, BoxProps, ThemeUIStyleObject } from 'theme-ui'
import Link, { LinkProps } from './link'
import getSizeStyles from './utils/get-size-styles'

const hasCustomHover = (comp: any): comp is { hover: ThemeUIStyleObject } =>
!!comp?.hover

export interface ButtonProps extends Omit<BoxProps, 'prefix'> {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
align?:
| 'baseline'
| 'sub'
| 'super'
| 'text-top'
| 'text-bottom'
| 'middle'
| 'top'
| 'bottom'
| 'initial'
suffix?: React.ReactElement & { props?: { sx?: ThemeUIStyleObject } }
prefix?: React.ReactElement & { props?: { sx?: ThemeUIStyleObject } }
inverted?: boolean
href?: string
internal?: boolean
sx?: ThemeUIStyleObject & {
color?: string // ThemeUIStyleObject doesn't have a color property
}
}

const Button = (
{
size = 'sm',
Expand All @@ -15,14 +40,14 @@ const Button = (
href,
internal,
...props
},
ref
}: ButtonProps,
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>
) => {
if (!['xs', 'sm', 'md', 'lg', 'xl'].includes(size)) {
throw new Error('Size must be xs, sm, md, lg, or xl')
}

let offset, margin, top, height, width, strokeWidth
let offset, margin, height, width, strokeWidth

const { color, ...sxProp } = sx || {}

Expand Down Expand Up @@ -99,14 +124,16 @@ const Button = (
suffixOffset = offset
}

let clonedPrefix, clonedSuffix

if (prefix) {
prefixHover = {
'&:hover > #prefix-span > #prefix': {
color: hoverColor,
...prefix.type.hover,
...(hasCustomHover(prefix.type) ? prefix.type.hover : {}),
},
}
prefix = cloneElement(prefix, {
clonedPrefix = cloneElement(prefix, {
id: 'prefix',
sx: {
position: 'relative',
Expand All @@ -116,7 +143,7 @@ const Button = (
strokeWidth: strokeWidth,
verticalAlign: prefixAlign,
transition: 'color 0.15s, transform 0.15s',
...prefix.props.sx,
...prefix.props?.sx,
},
})
}
Expand All @@ -125,10 +152,10 @@ const Button = (
suffixHover = {
'&:hover > #suffix-span >#suffix': {
color: hoverColor,
...suffix.type.hover,
...(hasCustomHover(suffix.type) ? suffix.type.hover : {}),
},
}
suffix = cloneElement(suffix, {
clonedSuffix = cloneElement(suffix, {
id: 'suffix',
sx: {
height: height,
Expand All @@ -137,7 +164,7 @@ const Button = (
strokeWidth: strokeWidth,
verticalAlign: suffixAlign,
transition: 'color 0.15s, transform 0.15s',
...suffix.props.sx,
...suffix.props?.sx,
},
})
}
Expand All @@ -152,7 +179,7 @@ const Button = (
display: 'block',
color: baseColor,
padding: [0],
textAlign: 'left',
textAlign: 'left' as const,
cursor: 'pointer',
width: 'fit-content',
'@media (hover: hover) and (pointer: fine)': {
Expand All @@ -172,7 +199,7 @@ const Button = (
id='prefix-span'
sx={{ display: 'inline-block', ...prefixOffset }}
>
{prefix && prefix}
{clonedPrefix}
</Box>
<Box as='span' sx={{ transition: 'color 0.15s' }}>
{children}
Expand All @@ -182,33 +209,39 @@ const Button = (
id='suffix-span'
sx={{ display: 'inline-block', ...suffixOffset }}
>
{suffix && suffix}
{clonedSuffix}
</Box>
</>
)

if (href) {
return (
<Link
ref={ref}
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
internal={internal}
sx={{
...style,
textDecoration: 'none',
}}
{...props}
{...(props as LinkProps)}
>
{Inner}
</Link>
)
} else {
return (
<Box ref={ref} as='button' sx={style} {...props}>
<Box
ref={ref as React.Ref<HTMLButtonElement>}
as='button'
sx={style}
{...props}
>
{Inner}
</Box>
)
}
}

export default forwardRef(Button)
export default forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(
Button
)
Loading
Loading