Skip to content

Commit 4e537f1

Browse files
authored
refactor: reorganize components for testability (#3)
1 parent 9b6a6a4 commit 4e537f1

File tree

12 files changed

+388
-60
lines changed

12 files changed

+388
-60
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"license": "MIT",
1111
"type": "module",
1212
"scripts": {
13-
"all": "concurrently -g pnpm:check:* pnpm:build",
13+
"all": "concurrently -g pnpm:check:* pnpm:coverage pnpm:build",
1414
"start": "vite dev",
1515
"build": "vite build",
1616
"preview": "vite build && vite preview",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { render, within, screen } from '@testing-library/preact'
3+
4+
import * as subject from '../header.tsx'
5+
6+
describe('header component', () => {
7+
it('should have my name as the main title, in a link to go home', () => {
8+
render(<subject.Header />)
9+
10+
const link = screen
11+
.getAllByRole('link')
12+
.find((element) => element.getAttribute('href') === '/')
13+
14+
const title = within(link!).getByRole('heading', { level: 1 })
15+
16+
expect(title).toHaveTextContent(/michael ?cousins/i)
17+
})
18+
19+
it('should have my description', () => {
20+
render(<subject.Header />)
21+
22+
const description = screen.getByText('/[a-z]+ware engineer/')
23+
const caveat = screen.getByText('* warranty void if coffee is removed')
24+
25+
expect(description).toBeInTheDocument()
26+
expect(caveat).toBeInTheDocument()
27+
})
28+
29+
it('should have my GitHub profile', () => {
30+
render(<subject.Header />)
31+
32+
const result = screen.getByRole('link', { name: 'GitHub' })
33+
const resultIcon = within(result).getByRole('img', { hidden: true })
34+
35+
expect(result).toHaveAttribute('href', 'https://github.com/mcous')
36+
expect(resultIcon).toHaveClass('i-fa6-brands-github')
37+
})
38+
39+
it('should have my resume', () => {
40+
render(<subject.Header />)
41+
42+
const result = screen.getByRole('link', { name: 'Résumé' })
43+
const resultIcon = within(result).getByRole('img', { hidden: true })
44+
45+
expect(result).toHaveAttribute('href', 'http://mike.cousins.io/resume/')
46+
expect(resultIcon).toHaveClass('i-fa6-regular-file-lines')
47+
})
48+
49+
it('should have my email', () => {
50+
render(<subject.Header />)
51+
52+
const result = screen.getByRole('link', { name: 'Email' })
53+
const resultIcon = within(result).getByRole('img', { hidden: true })
54+
55+
expect(result).toHaveAttribute('href', 'mailto:michael@cousins.io')
56+
expect(resultIcon).toHaveClass('i-fa6-regular-envelope')
57+
})
58+
59+
it('should have my LinkedIn', () => {
60+
render(<subject.Header />)
61+
62+
const result = screen.getByRole('link', { name: 'LinkedIn' })
63+
const resultIcon = within(result).getByRole('img', { hidden: true })
64+
65+
expect(result).toHaveAttribute('href', 'https://www.linkedin.com/in/mcous/')
66+
expect(resultIcon).toHaveClass('i-fa6-brands-linkedin-in')
67+
})
68+
69+
it('should have a minimal mode', () => {
70+
render(<subject.Header mode="minimal" />)
71+
72+
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
73+
expect(screen.queryByText(/engineer/)).not.toBeInTheDocument()
74+
expect(screen.queryByText(/warranty void/)).not.toBeInTheDocument()
75+
expect(
76+
screen.queryByRole('link', { name: 'GitHub' })
77+
).not.toBeInTheDocument()
78+
expect(
79+
screen.queryByRole('link', { name: 'Résumé' })
80+
).not.toBeInTheDocument()
81+
expect(
82+
screen.queryByRole('link', { name: 'Email' })
83+
).not.toBeInTheDocument()
84+
expect(
85+
screen.queryByRole('link', { name: 'LinkedIn' })
86+
).not.toBeInTheDocument()
87+
})
88+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { render, screen, within } from '@testing-library/preact'
3+
4+
import * as subject from '../layout.tsx'
5+
6+
describe('default content layout', () => {
7+
it('should define the main content', () => {
8+
render(
9+
<subject.Layout metadata={{ title: '', description: '' }}>
10+
<span data-testid="contents" />
11+
</subject.Layout>
12+
)
13+
14+
const result = screen.getByRole('main')
15+
16+
expect(within(result).getByTestId('contents')).toBeInTheDocument()
17+
})
18+
})

src/components/app.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ComponentType } from 'preact'
2+
3+
import type {
4+
LayoutProps,
5+
PageProps,
6+
Metadata,
7+
} from '../renderer/page-context.ts'
8+
9+
import { Header, type HeaderProps } from './header.tsx'
10+
import { Layout as DefaultLayout } from './layout.tsx'
11+
12+
export interface AppState {
13+
headerProps?: HeaderProps | undefined
14+
metadata: Metadata
15+
Layout?: ComponentType<LayoutProps> | undefined
16+
Page: ComponentType<PageProps>
17+
}
18+
19+
export interface AppProps {
20+
state: { value: AppState }
21+
}
22+
23+
export function App(props: AppProps) {
24+
const {
25+
headerProps = {},
26+
metadata,
27+
Layout = DefaultLayout,
28+
Page,
29+
} = props.state.value
30+
31+
return (
32+
<>
33+
<Header {...headerProps} />
34+
<Layout metadata={metadata}>
35+
<Page />
36+
</Layout>
37+
</>
38+
)
39+
}

src/components/atoms.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { FunctionComponent, RenderableProps } from 'preact'
2+
3+
type Atom<E extends HTMLElement> = FunctionComponent<JSX.HTMLAttributes<E>>
4+
5+
export const parseProps = <
6+
P extends RenderableProps<JSX.HTMLAttributes<never>>
7+
>(
8+
props: P
9+
): { className: string; otherProps: Omit<P, 'class'> } => {
10+
const { class: className, ...otherProps } = props
11+
12+
if (!className) return { className: '', otherProps }
13+
if (typeof className === 'string') return { className, otherProps }
14+
return { className: className.value ?? '', otherProps }
15+
}
16+
17+
export const Link: Atom<HTMLAnchorElement> = (props) => {
18+
const { className, otherProps } = parseProps(props)
19+
20+
return (
21+
<a
22+
class={`text-blue-700 visited:text-purple-700 hover:underline ${className}`}
23+
{...otherProps}
24+
/>
25+
)
26+
}
27+
28+
export const HoverLink: Atom<HTMLAnchorElement> = (props) => {
29+
const { className, otherProps } = parseProps(props)
30+
31+
return (
32+
<a
33+
class={`transition-color hover:text-blue-700 hover:visited:text-purple-700 ${className}`}
34+
{...otherProps}
35+
/>
36+
)
37+
}
38+
39+
export const Copy: Atom<HTMLParagraphElement> = (props) => {
40+
const { className, otherProps } = parseProps(props)
41+
42+
return (
43+
<p class={`text-base leading-relaxed mt4 ${className}`} {...otherProps} />
44+
)
45+
}
46+
47+
export const Heading1: Atom<HTMLHeadingElement> = (props) => {
48+
const { className, otherProps } = parseProps(props)
49+
50+
return <h1 class={`mt-8 text-2xl text-center ${className}`} {...otherProps} />
51+
}
52+
53+
export const Heading2: Atom<HTMLHeadingElement> = (props) => {
54+
const { className, otherProps } = parseProps(props)
55+
56+
return <h2 class={`mt-4 text-xl ${className}`} {...otherProps} />
57+
}
58+
59+
export const Heading3: Atom<HTMLHeadingElement> = (props) => {
60+
const { className, otherProps } = parseProps(props)
61+
62+
return <h2 class={`mt-4 text-lg ${className}`} {...otherProps} />
63+
}
64+
65+
export const OrderedList: Atom<HTMLOListElement> = (props) => {
66+
const { className, otherProps } = parseProps(props)
67+
68+
return (
69+
<ol class={`mt-4 list-decimal mt-4 pl-8 ${className}`} {...otherProps} />
70+
)
71+
}
72+
export const UnorderedList: Atom<HTMLUListElement> = (props) => {
73+
const { className, otherProps } = parseProps(props)
74+
75+
return <ul class={`mt-4 list-disc mt-4 pl-8 ${className}`} {...otherProps} />
76+
}
77+
78+
export const ListItem: Atom<HTMLLIElement> = (props) => {
79+
const { className, otherProps } = parseProps(props)
80+
81+
return <li class={`text-base leading-relaxed ${className}`} {...otherProps} />
82+
}
83+
84+
export const PreformattedText: Atom<HTMLPreElement> = (props) => {
85+
const { className, otherProps } = parseProps(props)
86+
87+
return (
88+
<pre
89+
class={`mt-4 text-xs sm:text-sm w-160 max-w-screen self-center ${className}`}
90+
{...otherProps}
91+
/>
92+
)
93+
}
94+
95+
export const Code: Atom<HTMLElement> = (props) => {
96+
const { className, otherProps } = parseProps(props)
97+
const isBlock = className.includes('hljs')
98+
99+
return (
100+
<code
101+
class={`font-mono ${
102+
isBlock ? 'rounded-lg' : 'rounded px-1 py-0.5 bg-slate-200'
103+
} ${className}`}
104+
{...otherProps}
105+
/>
106+
)
107+
}

src/components/header.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { SocialsNav } from './socials-nav.tsx'
2+
3+
export const HEADER_HERO = 'hero'
4+
export const HEADER_MINIMAL = 'minimal'
5+
6+
export interface HeaderProps {
7+
mode?: typeof HEADER_HERO | typeof HEADER_MINIMAL
8+
}
9+
10+
export function Header(props: HeaderProps): JSX.Element {
11+
const { mode = HEADER_HERO } = props
12+
const isHero = mode === HEADER_HERO
13+
14+
return (
15+
<header
16+
class={`flex flex-col items-center text-center mx-auto ${
17+
isHero ? 'mt-32' : 'mt-8'
18+
}`}
19+
>
20+
<a href="/" class="w-32 h-32 border border-current border-2 rounded-full">
21+
<h1 class="pt-8.5 text-2xl leading-tight">
22+
michael
23+
<br />
24+
cousins
25+
</h1>
26+
</a>
27+
{isHero && (
28+
<div class="mt-4">
29+
<p class="text-xl">/[a-z]+ware engineer/</p>
30+
<p class="font-light">* warranty void if coffee is removed</p>
31+
<SocialsNav class="mt-8 max-h-16" />
32+
</div>
33+
)}
34+
</header>
35+
)
36+
}

src/components/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { LayoutProps } from '../renderer/page-context.tsx'
2+
3+
export function Layout(props: LayoutProps) {
4+
const { children } = props
5+
6+
return <main class="max-w-96 mt-8 mx-auto">{children}</main>
7+
}

src/components/socials-nav.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const SOCIAL_LINKS: IconLinksProps[] = [
1111
},
1212
{
1313
title: 'Email',
14-
href: 'mailto:mike@cousins.io',
14+
href: 'mailto:michael@cousins.io',
1515
icon: 'i-fa6-regular-envelope',
1616
},
1717
{
@@ -32,7 +32,7 @@ function IconLink(props: IconLinksProps): JSX.Element {
3232

3333
return (
3434
<a
35-
class="mx-2 p-3 border border-current border-2 inline-flex items-center justify-center transition-opacity hover:opacity-50"
35+
class="mx-2 p-3 border border-current border-2 inline-flex items-center justify-center transition-color hover:text-blue-700"
3636
title={title}
3737
href={href}
3838
>
@@ -42,12 +42,14 @@ function IconLink(props: IconLinksProps): JSX.Element {
4242
}
4343

4444
export interface SocialsNavProps {
45-
class: string
45+
class?: string
4646
}
4747

4848
export function SocialsNav(props: SocialsNavProps): JSX.Element {
49+
const { class: extraClass = '' } = props
50+
4951
return (
50-
<nav class={props.class}>
52+
<nav class={`text-2xl leading-none ${extraClass}`}>
5153
{SOCIAL_LINKS.map((linkProps, index) => (
5254
<IconLink key={index} {...linkProps} />
5355
))}

src/pages/index.page.tsx

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,10 @@
1-
import { SocialsNav } from '../components/socials-nav.tsx'
2-
3-
export const title = 'Michael Cousins'
4-
5-
export const description = `
6-
Michael Cousins is a software engineer working
7-
in JavaScript, Python, and [insert language here].
8-
He's powered by caffeine and semantically versioned.
9-
`.trim()
1+
export const metadata = {
2+
title: 'Michael Cousins',
3+
description: `Michael Cousins is a software engineer working
4+
in JavaScript, Python, Go, and [insert language here].
5+
He's powered by caffeine and semantically versioned.`,
6+
}
107

118
export function Page(): JSX.Element {
12-
return (
13-
<div class="pt-32 text-center">
14-
<div class="w-32 h-32 mx-auto border border-current border-2 rounded-full">
15-
<h1 class="my-0 pt-8 text-2xl leading-tight font-normal">
16-
mike
17-
<br />
18-
cousins
19-
</h1>
20-
</div>
21-
22-
<div class="mt-4 mb-8 text-xl leading-normal ">
23-
<p class="my-0">/[a-z]+ware engineer/</p>
24-
<p class="my-0 text-base font-light">
25-
* warranty void if coffee is removed
26-
</p>
27-
</div>
28-
29-
<SocialsNav class="my-8 text-2xl leading-none" />
30-
</div>
31-
)
9+
return <></>
3210
}

0 commit comments

Comments
 (0)