diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 854cb73..0000000 --- a/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["next/babel"], - "plugins": [["styled-components", { "ssr": true }]] -} diff --git a/.eslintrc.json b/.eslintrc.json index 037f59a..4f63ca7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,46 +1,14 @@ { - "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], "extends": [ + "next/core-web-vitals", "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "prettier/@typescript-eslint", - "plugin:prettier/recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended" + "plugin:@typescript-eslint/recommended" ], - "plugins": ["@typescript-eslint", "react", "prettier"], - "parserOptions": { - "jsx": true, - "project": "./tsconfig.json" - }, - "settings": { - "react": { - "version": "detect" - } - }, "rules": { - "react/react-in-jsx-scope": "off", - "react/no-unescaped-entities": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-empty-interface": [ - "error", - { - "allowSingleExtends": true - } - ] - }, - "overrides": [ - { - "files": ["*.js"], - "rules": { - "@typescript-eslint/no-var-requires": "off" - } - } - ], - "env": { - "browser": true, - "es6": true, - "node": true, - "jest": true + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-explicit-any": "off" } } diff --git a/.gitignore b/.gitignore index aa3b2f1..e3b3fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,4 @@ yarn-error.log* .env.production.local # vercel -.vercel - -# generated files -/data/image-sizes.json \ No newline at end of file +.vercel \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index b2095be..0000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "semi": false, - "singleQuote": true -} diff --git a/LICENSE b/LICENSE index 8a938b8..66c4105 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Andy Hook +Copyright (c) 2025 Andy Hook Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1adb9f6..a9e6a71 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,16 @@ # andyhook.dev -Source code for my personal portfolio. Built with [Next.js](https://nextjs.org/), [Styled Components](https://styled-components.com/) and [Framer Motion](https://www.framer.com/api/motion/). +Source code for my personal portfolio. Built with [Next.js](https://nextjs.org/), [Tailwind](https://tailwindcss.com/) and [Motion](https://motion.dev/). ## Development To start development: ```sh -yarn dev +pnpm dev ``` -By default this will spin up a local server on `localhost:3000` - -## Image dimensions - -Static image dimensions are generated during a build step, either on each run of the dev server or for every production build. If you add, update or change any images you must run `yarn generate-image-sizes` or restart the dev server to get the latest dimensions and [ImagePath](https://github.com/andy-hook/andyhook.dev/blob/main/data/images.ts) typings. - -## Building - -To build for Node environments: - -```sh -yarn build -``` - -or for a [static export](https://nextjs.org/docs/advanced-features/static-html-export): - -```sh -yarn build-static -``` - -## Testing - -Run the tests: - -```sh -yarn test -``` - -## Type checking and linting - -Type checking and linting is handled by a single script: - -```sh -yarn lint -``` - -## Formatting your code - -Prettier is supported but you'll need to add your own script if you wish to use the cli. - -I personally use [this vscode package](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to format automatically within my editor. +By default this will spin up a local server on `localhost:3001` ## License diff --git a/app/(index)/experience-list.tsx b/app/(index)/experience-list.tsx new file mode 100644 index 0000000..1cc802c --- /dev/null +++ b/app/(index)/experience-list.tsx @@ -0,0 +1,364 @@ +'use client'; + +import * as React from 'react'; +import { PlusIcon } from '@heroicons/react/16/solid'; +import { AnimatePresence, cubicBezier, motion } from 'motion/react'; +import { cx } from '@/cva.config'; + +import * as AccordionPrimitive from '@/components/primitives/accordion'; + +import { useCallbackRef } from '@/components/utils/use-callback-ref'; +import { useScreen } from '@/components/utils/use-screen'; +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; + +import { experience } from '@/data'; +import { useLayoutEffect } from '@/components/utils/use-layout-effect'; +import { ScrambleText } from '@/components/scramble-text'; +import { getColorSlateDark } from '@/theme'; +import * as HoverGroup from '@/components/primitives/hover-group'; +import { ExternalLink } from '@/components/external-link'; +import { FocusRing } from '@/components/focus-ring'; + +const MOTION_TRANSITION = { + ease: cubicBezier(0.5, 0.2, 0.2, 1), + duration: 0.3, + opacity: { ease: 'linear', duration: 0.15 }, +}; + +type ExperienceListElement = React.ComponentRef<'div'>; + +interface ExperienceListProps extends React.ComponentPropsWithoutRef<'div'> {} + +export const ExperienceList = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + const [openItem, onOpenItemChange] = React.useState(''); + const [hoveredItem, setHoveredItem] = React.useState(''); + const animateCompanyName = useScreen({ max: 'md' }); + + return ( +
+
+ + + +
+ +
+ + +
    + {experience.map((entry, i) => { + const firstItem = i === 0; + const lastItem = experience.length - 1 === i; + const key = entry.company; + const open = openItem === key; + const hovered = hoveredItem === key; + const Logo = entry.logoAsset; + + return ( + +
  • + + {(open || hovered) && ( + + )} + + + {!lastItem ? ( + + ) : null} + + + + + {({ yearScrambleRef, companyScrambleRef, titleScrambleRef }) => ( + <> +
    +
    + + {entry.year} + + +
    {entry.year}
    +
    +
    + +
    + +
    + + + + + {entry.company} + + + + + {!open && ( + + + {entry.title} + + + )} + + + +
    +
    + + + +
    +
    + + )} +
    +
    + + onOpenItemChange(key)}> +
    + + + + +

    + {entry.description} +

    + +
    + {[ + ['Home', entry.link], + ['Role', entry.title], + ['Tenure', entry.tenure], + ].map(([label, value]) => ( +
    +
    + {label} +
    + + {value === entry.link ? ( + + {value} + + ) : ( +
    + {value} +
    + )} +
    + ))} +
    +
    +
    +
    +
  • +
    + ); + })} +
+
+
+
+
+ ); + }, +); + +ExperienceList.displayName = 'ExperienceList'; + +function getCompanyTitleColor(openItem: string, hoveredItem: string, key: string) { + const open = openItem === key; + const hovered = hoveredItem === key; + const listHasOpen = openItem !== ''; + const listHasHovered = hoveredItem !== ''; + + if (listHasOpen) { + return getColorSlateDark(open || hovered ? 12 : 10); + } + + return getColorSlateDark(hovered || !listHasHovered ? 12 : 10); +} + +/* -----------------------------------------------------------------------------------------------*/ + +type ExperienceListItemTriggerElement = React.ComponentRef; + +interface ExperienceListItemTriggerProps + extends Omit, 'children'> { + children(refs: { + yearScrambleRef: React.RefObject | null>; + companyScrambleRef: React.RefObject | null>; + titleScrambleRef: React.RefObject | null>; + }): React.ReactNode; + open: boolean; + value: string; +} + +const ExperienceListItemTrigger = React.forwardRef< + ExperienceListItemTriggerElement, + ExperienceListItemTriggerProps +>(({ children, open, value, ...props }, forwardedRef) => { + const yearScrambleRef = React.useRef>(null); + const companyScrambleRef = React.useRef>(null); + const titleScrambleRef = React.useRef>(null); + + return ( + { + if (mode === 'hovered' && !open) { + yearScrambleRef.current?.replay(); + companyScrambleRef.current?.replay(); + titleScrambleRef.current?.replay(); + } + }} + > + + {children({ yearScrambleRef, companyScrambleRef, titleScrambleRef })} + + + ); +}); + +ExperienceListItemTrigger.displayName = 'ExperienceListItemTrigger'; + +/* -----------------------------------------------------------------------------------------------*/ + +type ExperienceListItemContentElement = React.ComponentRef; + +interface ExperienceListItemContentProps + extends React.ComponentPropsWithoutRef { + onBeforeMatch(): void; +} + +const ExperienceListItemContent = React.forwardRef< + ExperienceListItemContentElement, + ExperienceListItemContentProps +>(({ children, onBeforeMatch, ...props }, forwardedRef) => { + const handleBeforeMatch = useCallbackRef(onBeforeMatch); + const innerRef = React.useRef(null); + + useLayoutEffect(() => { + const innerContent = innerRef.current; + + if (innerContent) { + innerContent.setAttribute('hidden', 'until-found'); + innerContent.addEventListener('beforematch', handleBeforeMatch); + return () => { + innerContent.removeEventListener('beforematch', handleBeforeMatch); + }; + } + }, [handleBeforeMatch]); + + return ( + + { + if (variant === 'open') { + innerRef.current?.removeAttribute('hidden'); + } + }} + onAnimationComplete={(variant) => { + if (variant === 'closed') { + innerRef.current?.setAttribute('hidden', 'until-found'); + } + }} + > + + {children} + + + + ); +}); + +ExperienceListItemContent.displayName = 'ExperienceListItemContent'; diff --git a/app/(index)/experience.tsx b/app/(index)/experience.tsx new file mode 100644 index 0000000..b17c6f8 --- /dev/null +++ b/app/(index)/experience.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; + +import { Line } from '@/components/line'; +import { Container } from '@/components/container'; +import { Hatch } from '@/components/hatch'; + +import { ExperienceList } from './experience-list'; +import { DownloadButton } from '@/components/download-button'; +import { Gutter } from '@/components/gutter'; + +type ExperienceElement = React.ComponentRef<'section'>; + +interface ExperienceProps extends React.ComponentPropsWithoutRef<'section'> {} + +export const Experience = React.forwardRef( + (props, forwardedRef) => { + return ( +
+ + +
+

+
More than a decade building for the web
+

+ +
+

+ Design is at the heart of everything I do, I believe that a close relationship + between visual design, UX and front-end engineering expertise leads to a better + customer experience within digital products. +

+

+ As a specialist in modular design systems and component libraries, I work to + bridge the gap between design and engineering disciplines and am a catalyst for + fast, iterative processes within agile product teams. My technical experience + spans a wealth of front-end technologies ranging from modern SPA frameworks to + run-time performance profiling, testing and accessibility. +

+
+ +
+ + + + + + + +
+ + + + + +
+
+ + + + +
+
+ ); + }, +); + +Experience.displayName = 'Experience'; diff --git a/app/(index)/page.tsx b/app/(index)/page.tsx new file mode 100644 index 0000000..4acc6be --- /dev/null +++ b/app/(index)/page.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { Work } from './work'; +import { Experience } from './experience'; +import { Testimonials } from './testimonials'; +import { RouterTransition } from '../router'; + +export default function Home() { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/app/(index)/testimonials.tsx b/app/(index)/testimonials.tsx new file mode 100644 index 0000000..bb769fe --- /dev/null +++ b/app/(index)/testimonials.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; +import { cx } from '@/cva.config'; +import { getTestimonialById } from '@/data'; + +import { Container } from '@/components/container'; +import { Quote } from '@/components/quote'; +import { Line } from '@/components/line'; +import { Author } from '@/components/author'; +import { Gutter } from '@/components/gutter'; + +const vlad = getTestimonialById('vlad'); +const benoit = getTestimonialById('benoit'); +const michael = getTestimonialById('michael'); +const andrew = getTestimonialById('andrew'); +const brett = getTestimonialById('brett'); + +type TestimonialsElement = React.ComponentRef<'section'>; + +interface TestimonialsProps extends React.ComponentPropsWithoutRef<'section'> {} + +export const Testimonials = React.forwardRef( + (props, forwardedRef) => { + return ( +
+ + +
+

+
Trusted by world-class teams
+

+ +

+ I’ve worked with some amazing people throughout the years, here is what they have to + say +

+
+
+ + +
+ + + + + + + + + +
+
+
+
+ ); + }, +); + +Testimonials.displayName = 'Testimonials'; + +/* -----------------------------------------------------------------------------------------------*/ + +type TestimonialGridElement = React.ComponentRef<'div'>; + +interface TestimonialGridProps extends React.ComponentPropsWithoutRef<'div'> {} + +const TestimonialGrid = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + return ( +
+ + +
+
    +
  • +
    + +
    +
    + + + +
    + + {vlad && ( +
    + + {vlad.excerpt} + + + + +
    + +
    +
    + )} +
    +
  • + +
  • + {benoit && ( + + + + )} + + + +
  • + + {michael && ( +
  • + + + + + +
  • + )} + + {andrew && ( +
  • + + + + + +
  • + )} + + {brett && ( +
  • + + + +
  • + )} +
+
+
+ ); + }, +); + +TestimonialGrid.displayName = 'TestimonialGrid'; + +/* -----------------------------------------------------------------------------------------------*/ + +type TestimonialGridItemElement = React.ComponentRef<'figure'>; + +interface TestimonialGridItemProps extends React.ComponentPropsWithoutRef<'figure'> { + content: string; +} + +const TestimonialGridItem = React.forwardRef( + ({ className, content, children, ...props }, forwardedRef) => { + return ( +
+ + {content} + + +
{children}
+
+ ); + }, +); + +TestimonialGridItem.displayName = 'TestimonialGridItem'; + +/* -----------------------------------------------------------------------------------------------*/ + +type QuoteMarkElement = React.ComponentRef<'svg'>; + +interface QuoteMarkProps extends React.ComponentPropsWithoutRef<'svg'> {} + +const QuoteMark = React.forwardRef((props, forwardedRef) => { + const uid = React.useId(); + + const gradientId = `decorative_quote_gradient_${uid}`; + + return ( + + + + + + + + + + ); +}); + +QuoteMark.displayName = 'QuoteMark'; diff --git a/app/(index)/work.tsx b/app/(index)/work.tsx new file mode 100644 index 0000000..10177ac --- /dev/null +++ b/app/(index)/work.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; + +import { Container } from '@/components/container'; +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; + +import { SocialLink } from '@/components/social-link'; +import * as HoverGroup from '@/components/primitives/hover-group'; +import { WorkItem } from '@/components/work-item'; +import { getProjectById } from '@/data'; +import { RouterTransition } from '../router'; +import { cx } from '@/cva.config'; +import { Gutter } from '@/components/gutter'; + +const radix = getProjectById('radix'); +const aragon = getProjectById('aragon'); +const blocks = getProjectById('blocks'); +const dash = getProjectById('dash'); + +type WorkElement = React.ComponentRef<'section'>; + +interface WorkProps extends React.ComponentPropsWithoutRef<'section'> {} + +export const Work = React.forwardRef((props, forwardedRef) => { + return ( +
+ + +
+ + + +
+ +
+ Software Engineer +
+
+ +

+ Building next-generation user interfaces out of the UK +

+
+ + +
    + {(['github', 'linkedin', 'dribbble', 'twitter', 'instagram'] as const).map( + (platform) => ( +
  • + +
  • + ), + )} +
+
+
+
+ + +
+
+ + + + + + +
+ +
+ + +
    +
  • + + + {radix && } +
  • + +
  • + + + {aragon && } +
  • + +
  • {blocks && }
  • + +
  • + + + + {dash && } +
  • +
+
+
+ + + +
+ ); +}); + +Work.displayName = 'Work'; + +/* -----------------------------------------------------------------------------------------------*/ + +type HeroMarkElement = React.ComponentRef<'svg'>; + +interface HeroMarkProps extends React.ComponentPropsWithoutRef<'svg'> {} + +const HeroMark = React.forwardRef((props, forwardedRef) => { + const uid = React.useId(); + + const radialGradientId = `hero_mark_radial_gradient_${uid}`; + const verticalGradientId1 = `hero_mark_vertical_gradient_1_${uid}`; + const verticalGradientId2 = `hero_mark_vertical_gradient_2_${uid}`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +HeroMark.displayName = 'HeroMark'; diff --git a/app/[projectId]/_components/content-section.tsx b/app/[projectId]/_components/content-section.tsx new file mode 100644 index 0000000..7011974 --- /dev/null +++ b/app/[projectId]/_components/content-section.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; +import { Gutter } from '@/components/gutter'; +import { Container } from '@/components/container'; +import { RouterTransition } from '@/app/router'; + +type ContentSectionElement = React.ComponentRef<'section'>; + +interface ContentSectionProps extends React.ComponentPropsWithoutRef<'section'> { + title: string; +} + +export const ContentSection = React.forwardRef( + ({ children, title, ...props }, forwardedRef) => { + return ( +
+ + + +

+ + + + + +
{title}
+

+ +
+ {children} +
+
+
+
+
+ ); + }, +); + +ContentSection.displayName = 'ContentSection'; diff --git a/app/[projectId]/_components/content/aragon.tsx b/app/[projectId]/_components/content/aragon.tsx new file mode 100644 index 0000000..8ad7d0a --- /dev/null +++ b/app/[projectId]/_components/content/aragon.tsx @@ -0,0 +1,108 @@ +import { ContentSection } from '../content-section'; +import { ImageSection } from '../image-section'; +import { ImageGroupSection } from '../image-group-section'; +import { TooltipLink } from '../tooltip-link'; +import { + aragonIntroImage, + aragonComponentsImage, + aragonNetworkDashboardHomeImage, + aragonNetworkDashboardProposalImage, + aragonNetworkDashboardAgreementImage, + aragonUpgradeHomeImage, + aragonUpgradeConverterImage, + aragonUpgradeCompleteImage, +} from '@/images'; + +export default function AragonContent() { + return ( + <> + +

+ I joined the Aragon team in 2020 as a Senior Engineer to help further their mission of + revolutionising governance. From the very start it was clear that high quality, responsive + and delightful user interfaces were a crucial element of the project and that the team + values technically competent engineers equally versed in design. +

+

+ The utility of a{' '} + + Decentralized Autonomous Organisation + {' '} + (DAO) is well understood within the Ethereum community, but for the uninitiated there is a + lot to unpack. The team knew that onboarding an entire class of first-time crypto users to + the decentralized governance concept couldn't be achieved overnight and Aragons + messaging has taken multiple iterations over the years with this in mind. +

+

+ As part of the front-end engineering team I heavily contributed to the initial prototype + of the Aragon Network Dashboard, launched a highly praised ANT Upgrade Portal, furthered + the adoption of TypeScript in front-end code, improved and maintained a variety of open + source packages, pushed for a bigger emphasis on Agile development and mentored junior + team members. +

+
+ + + + +

+ The project is well known in the DAO space for executing to a very high standard, the + exceptional quality of their brand design and user experience stand out in an industry + that is not known for being particularly user friendly. +

+

+ I was lucky enough to work with two amazing design talents, Patricia Davila and Adrián + García, user experience, and brand respectively. They are some of the most talented + designers I've worked with and deserve high praise for the visual fidelity and + intuitive experience offered within Aragons products. +

+

+ An important component of a high performing product team is a strong relationship between + engineers and designers. Catching every edge case, accounting for all possible UI states + and fine tuning a flow to fit within technological limitations requires smooth + communication between disciplines and engineers who understand all sides of the coin – + technology, design, users and business. My biggest impact in this regard was an ability to + execute on this vision, ensuring every detail and interaction was of the highest quality. +

+
+ + + + +

+ Quality is often considered diametrically opposed to delivery speed, and in a lot of + circumstances this can be the case, however, my take on this is to ask the question of + why? why are we building this now? what's the simplest feature we can ship today that + adds value for users? These are important questions to ask, a mutual understanding of + expectations within the team and a tight scope can unlock a team to push the quality of + what is delivered while fostering an iterative development culture that empowers a team to + rapidly evolve at a predictable cadance. +

+

+ From a technology perspective I'm a believer in the use of static type systems such + as TypeScript for + improving velocity over time, the confidence that type systems provide when refactoring, + and the implicit documentation provided by strict typings go a long way to battling code + entropy (and make it a whole lot easier to onboard new hires) +

+

+ By following these principles we were able to deliver high impact initiatives beyond + expectation and ahead of schedule. The launch of the ANT Upgrade Portal was a great + example of this and proved the benefits to the team. +

+
+ + + + ); +} diff --git a/app/[projectId]/_components/content/blocks.tsx b/app/[projectId]/_components/content/blocks.tsx new file mode 100644 index 0000000..2dd8fb7 --- /dev/null +++ b/app/[projectId]/_components/content/blocks.tsx @@ -0,0 +1,73 @@ +import { ImageGroupSection } from '../image-group-section'; +import { ContentSection } from '../content-section'; +import { ImageSection } from '../image-section'; +import { TooltipLink } from '../tooltip-link'; +import { + blocksDetailImage, + blocksHomeImage, + blocksIntroImage, + blocksListImage, + blocksTransactionsImage, +} from '@/images'; + +export default function BlocksContent() { + return ( + <> + +

+ I designed and built the first version of Blocks in early 2020 as part of the technical + assessment process at Aragon, I wanted to design a tightly scoped app that was visually + exciting and offered opportunities for interesting interactions. While the utility of the + app is not comparable to the best of existing tools, it still gave me the opportunity to + learn more about Ethereum and + experiment with some new front-end techniques. +

+

+ An interesting byproduct of this project was being able to receive critique and feedback + on my implementation during the assessment, this effectively turned the process into an + additional knowledge stream and provided learnings which I could cycle back into the code, + further improving it for my own needs. While I invested time above and beyond what is + typical, I found it exciting to work on and helpful for professional growth. +

+
+ + + + +

+ Blocks was originally using{' '} + Web3.js and a{' '} + MetaMask provider to fetch block + information, this was the simplest way to get started but had big drawbacks – primarily + the need to have a wallet installed and connected before being able to fetch data. + Migrating to Ethers.js offered + a more intuitive, opinionated API while using Infura, Alchemy and Etherscan backed nodes + solved the wallet provider problem. That said, many of these concepts were new to me, and + while the typical centralised stack is mature and fast, Web3 initially felt cumbersome and + difficult to use, which is only made more obvious from the proliferation of excellently + documented, low cost data services available with the former. +

+

+ Another aspect I had to consider was that interacting with a chain directly from a client + requires that the front-end handles much of the data massaging and manipulation when a + centralised backend service would normally handle this work. I found myself having to be + careful not to carelessly perform calculations with standard Number types and make ample + use of Big Number formatting libraries to guard against safe range errors. +

+

+ Tech moves fast though, and we now have fantastic projects such as{' '} + The Graph offering a data + abstraction layer powered by Subgraphs and GraphQL, we even have native support for BigInt + directly in the browser, greatly reducing bundle size and standardising around a single + API. It's these types of learnings and exposure to new technology that make investing + in personal projects rewarding and valuable. +

+
+ + + + ); +} diff --git a/app/[projectId]/_components/content/dash.tsx b/app/[projectId]/_components/content/dash.tsx new file mode 100644 index 0000000..d404ca9 --- /dev/null +++ b/app/[projectId]/_components/content/dash.tsx @@ -0,0 +1,102 @@ +import { ImageGroupSection } from '../image-group-section'; +import { ContentSection } from '../content-section'; +import { ImageSection } from '../image-section'; +import { + dashIntroImage, + dashDesignSystemImage, + dashUILoginImage, + dashUISearchImage, + dashUIEditImage, + dashUIDetailImage, + dashUIAdminImage, +} from '@/images'; +import { TooltipLink } from '../tooltip-link'; +export default function DashContent() { + return ( + <> + +

+ In 2018 I joined the Bright team to evolve and elevate the user interface of Dash, a new + streamlined digital asset management product that the team were busy preparing to ship as + an MVP. +

+

+ Digital assets are not to be confused with digital currencies in this context. Digital + assets can be anything from images and videos to documents and spreadsheets, anything an + organisation would need to align around and provide easy access to, whether for marketing, + operations or commerce. The unique selling point with Dash is the ability to easily find + and share exactly what you need. A large library of assets can be effortlessly categorised + and organised through the use of facial recognition, in scene object tagging and metadata + extraction. +

+

+ For Dash, the team were very clear that they really wanted to have a delightful, + high-quality user experience and felt their visual design at the time, didn't quite + deliver on that promise. I ultimately contributed to heavily redesigning the visual + aesthetic and helped ship a consistent, portable UI kit, styling system and various high + impact features on the front-end. +

+
+ + + + +

+ The first area that I looked at was how the team were approaching visual style within the + application, Dash is an Angular SPA + supported by a micro-service backend architecture and made heavy use of Google{' '} + Material design principles and + their associated UI primitives. While Material principles are solid and offer great + documentation for aligning a team, the visual style itself lacks unique character and + often illicits a heavy association with Google products. +

+

+ Having taken the time to understand the problem space, get familiar with the codebase and + liase with the brand team, I drew up a set of guiding principles and aspirational designs + that would influence the visual style and align it with a yet to be revealed company wide + rebrand. +

+

+ The codebase at that time did feature an initial design token implementation but the + application of it was spotty and inconsistent. I worked with the team to build out a + revised set of tokens by extending and improving the existing{' '} + Sass (SCSS) utilities, from + spacing and typographic scales to colour palettes and a custom theming system, this would + go on to be exposed to end-users via a white labelling feature in the dashboard. +

+
+ + + + +

+ Over time we continued to build out the component system while also shipping features and + iterating based on customer feedback. We would later encounter technical limitations such + as poor performance within our list view with large collections. I worked with the team to + successfully implement a virtual viewport which enabled us to clamp the number of rendered + nodes and improve performance. +

+

+ As a small team with a lean start-up mentality, we had to tackle each and every problem + with ruthless prioritisation and make the right choices of when to favour features over + improving our developer experience or pay down technical debt. This is the reality of + quickly shipping value to users and is even more crucial as we were in the validation + stage and needed to quickly test product decisions to find fit. I feel proud to have been + able to provide high impact UI engineering and deliver on one of the teams core pillars in + delivering a slick, delightful user experience. +

+
+ + + + ); +} diff --git a/app/[projectId]/_components/content/radix.tsx b/app/[projectId]/_components/content/radix.tsx new file mode 100644 index 0000000..0afc6c5 --- /dev/null +++ b/app/[projectId]/_components/content/radix.tsx @@ -0,0 +1,3 @@ +export default function RadixContent() { + return <>; +} diff --git a/app/[projectId]/_components/image-group-section.tsx b/app/[projectId]/_components/image-group-section.tsx new file mode 100644 index 0000000..ae76400 --- /dev/null +++ b/app/[projectId]/_components/image-group-section.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { cva } from '@/cva.config'; +import { ImageWithMetadata, ProjectId } from '@/types'; + +import { Gutter } from '@/components/gutter'; +import { Container } from '@/components/container'; +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; +import { RouterImage, RouterTransition } from '@/app/router'; + +const imageGroupSectionBackground = cva({ + base: 'bg-gradient-to-br px-4 md:px-12 lg:px-16 rounded-2xl md:rounded-3xl overflow-hidden', + variants: { + project: { + radix: 'from-radix-3 to-radix-1', + aragon: 'from-aragon-5 to-aragon-1', + blocks: 'from-blocks-3 to-blocks-1', + dash: 'from-dash-3 to-dash-1', + }, + }, +}); + +type ImageGroupSectionElement = React.ComponentRef<'section'>; + +interface ImageGroupSectionProps extends React.ComponentPropsWithoutRef<'section'> { + images: ImageWithMetadata[]; + project: ProjectId; +} + +export const ImageGroupSection = React.forwardRef( + ({ images, project, ...props }, forwardedRef) => { + return ( +
+ + +
+ + + +
+ {images.map((image) => { + return ( +
+ + + + +
+ +
+
+ ); + })} +
+
+
+
+
+
+ ); + }, +); + +ImageGroupSection.displayName = 'ImageGroupSection'; diff --git a/app/[projectId]/_components/image-section.tsx b/app/[projectId]/_components/image-section.tsx new file mode 100644 index 0000000..b7e9964 --- /dev/null +++ b/app/[projectId]/_components/image-section.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Gutter } from '@/components/gutter'; +import { Line } from '@/components/line'; +import { Container } from '@/components/container'; +import { RouterImage, RouterTransition } from '@/app/router'; +import { ImageWithMetadata } from '@/types'; + +type ImageSectionElement = React.ComponentRef<'section'>; + +interface ImageSectionProps extends React.ComponentPropsWithoutRef<'section'> { + image: ImageWithMetadata; +} + +export const ImageSection = React.forwardRef( + ({ image, ...props }, forwardedRef) => { + return ( +
+ + + +
+ + + + + + + +
+ +
+
+
+
+
+
+ ); + }, +); + +ImageSection.displayName = 'ImageSection'; diff --git a/app/[projectId]/_components/team-list.tsx b/app/[projectId]/_components/team-list.tsx new file mode 100644 index 0000000..95bdca4 --- /dev/null +++ b/app/[projectId]/_components/team-list.tsx @@ -0,0 +1,147 @@ +'use client'; + +import * as React from 'react'; +import { cx } from '@/cva.config'; + +import { Tooltip } from '@/components/tooltip'; +import { motion } from 'motion/react'; +import { MouseHover } from '@/components/primitives/mouse-hover'; +import { useComposedRefs } from '@/components/utils/compose-refs'; +import { RouterImage, RouterLink } from '@/app/router'; +import { ImageWithMetadata } from '@/types'; +import { FocusRing } from '@/components/focus-ring'; + +/* ------------------------------------------------------------------------------------------------- + * TeamList + * -----------------------------------------------------------------------------------------------*/ + +type TeamListElement = React.ComponentRef; + +interface TeamListProps extends React.ComponentPropsWithoutRef { + team: { avatar: ImageWithMetadata; name: string; role: string; bio: string }[]; + additionalCount: number; +} + +export const TeamList = React.forwardRef( + ({ className, team, additionalCount, ...props }, forwardedRef) => { + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const [expanded, setExpanded] = React.useState(false); + + return ( + { + const hasFocus = ref.current?.contains(document.activeElement); + setExpanded(hasFocus || hovered); + }} + > + setExpanded(true)} + onBlur={() => setExpanded(false)} + animate={expanded ? 'expanded' : 'initial'} + > + {team.map((member, i) => ( + +
+ {member.name} +
+
{member.role}
+ + } + > + + + +
+ ))} + +
+ +{additionalCount} +
+
+
+
+ ); + }, +); + +TeamList.displayName = 'TeamList'; + +/* -----------------------------------------------------------------------------------------------*/ + +type TeamListItemElement = React.ComponentRef; + +interface TeamListItemProps + extends Omit, 'content'> { + content: React.ReactNode; + children: React.ReactNode; + teamCount: number; + memberIndex: number; +} + +const TeamListItem = React.forwardRef( + ({ content, children, teamCount, memberIndex, ...itemProps }, forwardedRef) => { + const [active, setActive] = React.useState(false); + + const expandedOffset = -(teamCount - 1 - memberIndex) * 12 - 36; + + return ( + + + setActive(true)} + onBlur={() => setActive(false)} + onValueChange={setActive} + > + + {children} + + + + + ); + }, +); + +TeamListItem.displayName = 'TeamListItem'; diff --git a/app/[projectId]/_components/tooltip-link.tsx b/app/[projectId]/_components/tooltip-link.tsx new file mode 100644 index 0000000..08924f3 --- /dev/null +++ b/app/[projectId]/_components/tooltip-link.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { Tooltip } from '@/components/tooltip'; +import { ExternalLink } from '@/components/external-link'; +import { ExternalFavicon } from '@/components/external-favicon'; + +type TooltipLinkElement = React.ComponentRef; + +interface TooltipLinkProps extends React.ComponentPropsWithoutRef { + href: string; +} + +export const TooltipLink = React.forwardRef( + (props, forwardedRef) => { + const parsedUrl = React.useMemo( + () => props.href.replace(/^(?:https?:\/\/)?(?:www\.)?|\/$/g, ''), + [props.href], + ); + + return ( + +
+ +
+
+
{parsedUrl}
+
+ + } + > + +
+ ); + }, +); + +TooltipLink.displayName = 'TooltipLink'; diff --git a/app/[projectId]/page.tsx b/app/[projectId]/page.tsx new file mode 100644 index 0000000..6aa1258 --- /dev/null +++ b/app/[projectId]/page.tsx @@ -0,0 +1,299 @@ +import * as React from 'react'; +import { notFound } from 'next/navigation'; +import { UserIcon, CalendarIcon } from '@heroicons/react/16/solid'; + +import { projects } from '@/data'; + +import { Container } from '@/components/container'; +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; +import { Quote } from '@/components/quote'; +import { Gutter } from '@/components/gutter'; +import { Author } from '@/components/author'; +import { WorkItem } from '@/components/work-item'; +import { TeamList } from './_components/team-list'; + +import * as HoverGroup from '@/components/primitives/hover-group'; + +import AragonContent from './_components/content/aragon'; +import BlocksContent from './_components/content/blocks'; +import DashContent from './_components/content/dash'; +import RadixContent from './_components/content/radix'; +import { RouterImage, RouterTransition } from '../router'; +import { Metadata } from 'next'; + +export async function generateMetadata(props: { + params: Promise<{ projectId: string }>; +}): Promise { + const { projectId } = await props.params; + const project = projects.find((project) => project.id === projectId); + + if (!project) return {}; + + return { + title: `Andy Hook – ${project.title}`, + description: project.subtitle, + }; +} + +const projectContent: Record = { + aragon: AragonContent, + blocks: BlocksContent, + dash: DashContent, + radix: RadixContent, +}; + +export async function generateStaticParams() { + return projects.map((project) => ({ + projectId: project.id, + })); +} + +export default async function ProjectPage(props: { params: Promise<{ projectId: string }> }) { + const { projectId } = await props.params; + const project = projects + .filter((project) => !project.externalUrl) + .find((project) => project.id === projectId); + + if (!project) notFound(); + + // Get the appropriate content component for this project + const Content = projectContent[projectId]; + + const projectMeta = [ + [ + UserIcon, + <> + Role: + {project.role} + , + ], + [ + CalendarIcon, + <> + Tenure: + {project.tenure} + , + ], + ] as const; + + const moreProjects = projects.filter((project) => project.id !== projectId); + + const renderedTeam = project.team.map(({ avatar, name, role }) => ({ + avatar, + description: `${name} – ${role}`, + })); + + return ( +
+
+ + +

+
+ + +
{project.title}
+
+
+ + +
+
+ {project.subtitle} +
+
+ + +
+

+
+
+ + + +
+ + +
    + {projectMeta.map(([Icon, text], i) => ( +
  • +
    + +
    +
    + {text} +
    +
  • + ))} +
+ + {renderedTeam.length > 0 ? ( + + ) : null} +
+
+
+
+ + + +
+
+ +
+
+
+ + + +
+
+
+ + + + + +
    + {project.technologies.map((technology) => ( +
  • + {technology} +
  • + ))} +
+
+ +

+ {project.intro} +

+
+
+
+
+
+
+ + + +
+ + + +
+ + + + + + {project.testimonial.full} + + +
+ +
+
+
+
+
+
+ +
+ + + +
+ + + + +
    + {moreProjects.map((project, i) => { + const firstItem = i === 0; + const lastItem = i === moreProjects.length - 1; + + return ( +
  • +
    + {!firstItem && ( + + )} + {!lastItem && ( + + )} +
    + +
    + {!firstItem && } + {!lastItem && } +
    + + +
  • + ); + })} +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..8e48a70 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..e58c987 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind utilities; + +/* Prevent tailwind breaking 'until-found' element visibility */ +[hidden='until-found'] { + display: revert-layer; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..202bc61 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,339 @@ +import './globals.css'; + +import * as React from 'react'; +import type { Metadata } from 'next'; +import { Manrope, IBM_Plex_Serif } from 'next/font/google'; +import { cx } from '@/cva.config'; + +import { Container } from '@/components/container'; + +import * as Sidebar from './sidebar'; + +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; +import { Gutter } from '@/components/gutter'; +import { Copy } from '@/components/copy'; + +import * as TooltipPrimitive from '@/components/primitives/tooltip'; +import { SocialLink } from '@/components/social-link'; +import { DeviceProvider } from '@/components/utils/use-device'; +import { Theme } from './theme'; +import { RouterLink, RouterProvider, RouterTransition } from './router'; +import { headers } from 'next/headers'; +import { FocusRing } from '@/components/focus-ring'; + +const displayFont = IBM_Plex_Serif({ + weight: ['300', '400', '500'], + subsets: ['latin'], + display: 'swap', + variable: '--font-display', +}); + +const bodyFont = Manrope({ + subsets: ['latin'], + display: 'swap', + variable: '--font-body', +}); + +export const metadata: Metadata = { + title: 'Andy Hook – Software Engineer', + description: 'Building next-generation user interfaces out of the UK', +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const resolvedHeaders = await headers(); + return ( + + + + + + + + +
+ + + + + + +
+ + {children} + + +
+ + +
+ + + + + + + + + + ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +type HeaderElement = React.ComponentRef<'header'>; + +interface HeaderProps extends React.ComponentPropsWithoutRef<'header'> {} + +const Header = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + return ( +
+ + + + + Andy Hook + + + + +
+ ); + }, +); + +Header.displayName = 'Header'; + +/* -----------------------------------------------------------------------------------------------*/ + +type FooterElement = React.ComponentRef<'footer'>; + +interface FooterProps extends React.ComponentPropsWithoutRef<'footer'> {} + +const Footer = React.forwardRef((props, forwardedRef) => { + return ( +
+ + +
+
+
+
+
+
+
+
+ + + + + +
+ Get in touch +
+ +
+ hello@andyhook.dev +
+ +
+ + +
+ + + +
    + {(['github', 'linkedin', 'dribbble', 'twitter', 'instagram'] as const).map( + (platform) => ( +
  • + +
  • + ), + )} +
+
+
+
+ + +
+ ); +}); + +Footer.displayName = 'Footer'; + +/* -----------------------------------------------------------------------------------------------*/ + +type FooterMarkElement = React.ComponentRef<'svg'>; + +interface FooterMarkProps extends React.ComponentPropsWithoutRef<'svg'> {} + +export const FooterMark = React.forwardRef( + (props, forwardedRef) => { + const uid = React.useId(); + + const verticalGradientId1 = `footer_mark_vertical_gradient_1_${uid}`; + const verticalGradientId2 = `footer_mark_vertical_gradient_2_${uid}`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); + }, +); + +FooterMark.displayName = 'FooterMark'; + +/* -----------------------------------------------------------------------------------------------*/ + +type BackgroundElement = React.ComponentRef<'div'>; + +interface BackgroundProps extends React.ComponentPropsWithoutRef<'div'> {} + +const Background = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + const uid = React.useId(); + const patternId = `root_background_pattern_${uid}`; + + return ( +
+ + + + + + + + + +
+ + + + + + + + + + +
+ ); + }, +); + +Background.displayName = 'Background'; diff --git a/app/opengraph-image.jpg b/app/opengraph-image.jpg new file mode 100644 index 0000000..fff39ab Binary files /dev/null and b/app/opengraph-image.jpg differ diff --git a/app/router.tsx b/app/router.tsx new file mode 100644 index 0000000..40b523b --- /dev/null +++ b/app/router.tsx @@ -0,0 +1,444 @@ +'use client'; + +import * as React from 'react'; +import NextLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { AnimatePresence, motion, useInView } from 'motion/react'; +import { projects } from '@/data'; +import { cx } from '@/cva.config'; +import NextImage from 'next/image'; + +import { createContext } from '@/components/utils/create-context'; +import { useComposedRefs } from '@/components/utils/compose-refs'; +import { Line } from '@/components/line'; +import { Hatch } from '@/components/hatch'; +import { Spinner } from '@/components/spinner'; +import { ImageWithMetadata } from '@/types'; + +const GENTLE_TRANSITION = { + type: 'spring', + stiffness: 110, + damping: 20, + mass: 1, +}; + +const SNAPPY_TRANSITION = { + type: 'spring', + stiffness: 250, + damping: 30, + mass: 1, +}; + +type TransitionState = 'initial' | 'idle' | 'cover' | 'loading' | 'enter' | 'intro'; + +/* ------------------------------------------------------------------------------------------------- + * RouterProvider + * -----------------------------------------------------------------------------------------------*/ + +type RouterContextValue = { + onLinkClick: (path: string) => void; + state: TransitionState; + onImageMount: (id: string) => void; + onImageLoad: (id: string) => void; +}; + +const [RouterProviderImpl, useRouterContext] = createContext('Router'); + +const RouterProvider: React.FC = (props) => { + const { children } = props; + const router = useRouter(); + const [state, setState] = React.useState('initial'); + const [imagesReadyMap, setImagesReadyMap] = React.useState | null>(null); + const [navigationPending, startTransition] = React.useTransition(); + const [screenFilled, setScreenFilled] = React.useState(false); + const [path, setPath] = React.useState(''); + + const spinnerVariant = state === 'initial' ? 'dark' : getSpinnerVariantFromPath(path); + const allImagesReady = imagesReadyMap && [...imagesReadyMap.values()].every((value) => value); + + const title = React.useMemo(() => { + const defaultTitle = 'Home'; + if (!path) return defaultTitle; + + const project = projects.find((project) => project.id === path); + return project?.title ?? defaultTitle; + }, [path]); + + React.useEffect(() => { + if (screenFilled && !navigationPending) { + setState('loading'); + setScreenFilled(false); + } + }, [screenFilled, navigationPending]); + + React.useEffect(() => { + if (allImagesReady) { + if (state === 'initial') { + setTimeout(() => setState('intro'), 500); + } + if (state === 'loading') { + setTimeout(() => setState('enter'), 50); + } + + setImagesReadyMap(null); + } + }, [allImagesReady, state]); + + return ( + { + setState('cover'); + setPath(href); + }} + onImageMount={(id) => { + if (state === 'initial' || state === 'loading') { + setImagesReadyMap((prev) => { + const prevMap = new Map(prev ? prev : undefined); + prevMap.set(id, false); + return prevMap; + }); + } + }} + onImageLoad={(id) => { + if (state === 'initial' || state === 'loading') { + setImagesReadyMap((prev) => { + const prevMap = new Map(prev ? prev : undefined); + prevMap.set(id, true); + return prevMap; + }); + } + }} + > +
+ {children} +
+ + {(state === 'initial' || state === 'cover') && ( + + + + )} + + + + {state === 'initial' && ( + { + if (definition === 'intro') setState('idle'); + }} + className="fixed inset-0 bg-gradient-to-tl from-slate-1 to-slate-2 z-40 pointer-events-none will-change-motion" + transition={{ duration: 0.6 }} + /> + )} + + + + {(state === 'cover' || state === 'loading') && ( + + { + if (definition === 'cover') { + startTransition(() => router.push(`/${path}`)); + setScreenFilled(true); + } else if (definition === 'enter') { + setPath(''); + setState('idle'); + } + }} + className="absolute inset-0 bg-slate-light-1 overflow-hidden shadow-lg flex items-center justify-center will-change-motion" + transition={SNAPPY_TRANSITION} + > + +
+
+
+ {title} + +
+ + + + + + + + + + + + +
+
+
+
+
+ )} +
+
+ ); +}; + +/* ------------------------------------------------------------------------------------------------- + * RouterLink + * -----------------------------------------------------------------------------------------------*/ + +type RouterLinkElement = React.ComponentRef; +type NextLinkProps = React.ComponentPropsWithoutRef; +interface RouterLinkProps extends NextLinkProps { + href: string; +} + +const RouterLink = React.forwardRef((props, forwardedRef) => { + const context = useRouterContext(); + + const isExternal = props.href.startsWith('https://'); + const externalProps = isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {}; + + return ( + { + props.onClick?.(event); + + if (isExternal) return; + + if (!event.isDefaultPrevented() && !isModifiedEvent(event)) { + event.preventDefault(); + context.onLinkClick(props.href); + } + }} + {...externalProps} + /> + ); +}); + +RouterLink.displayName = 'RouterLink'; + +/* ------------------------------------------------------------------------------------------------- + * RouterTransition + * -----------------------------------------------------------------------------------------------*/ + +type RouterTransitionElement = React.ComponentRef; + +const BASE_DISTANCE = 50; +interface RouterTransitionProps extends React.ComponentPropsWithoutRef { + multiplier: number; +} + +const RouterTransition = React.forwardRef( + (props, forwardedRef) => { + const { multiplier, ...transitionProps } = props; + const ref = React.useRef(null); + const context = useRouterContext(); + const composedRefs = useComposedRefs(forwardedRef, ref); + const isInView = useInView(ref, { once: true }); + + return ( + + ); + }, +); +RouterTransition.displayName = 'RouterTransition'; + +/* ------------------------------------------------------------------------------------------------- + * RouterImage + * -----------------------------------------------------------------------------------------------*/ + +type RouterImageElement = React.ComponentRef; + +interface RouterImageProps + extends Omit, 'src' | 'alt'> { + image: ImageWithMetadata; +} + +const RouterImage = React.forwardRef( + (props, forwardedRef) => { + const { image, className, ...imageProps } = props; + const ref = React.useRef(null); + const id = React.useId(); + const context = useRouterContext(); + const composedRefs = useComposedRefs(forwardedRef, ref); + const [isLoaded, setIsLoaded] = React.useState(false); + const [isInView, setInView] = React.useState(null); + + React.useEffect(() => { + if (ref.current) { + const observer = new IntersectionObserver((entries) => { + const [entry] = entries; + setInView(entry.isIntersecting); + observer.disconnect(); + }); + + observer.observe(ref.current); + return () => observer.disconnect(); + } + }, []); + + const { onImageMount, onImageLoad } = context; + + React.useEffect(() => { + onImageMount(id); + }, [onImageMount, id]); + + // If navigating to the same route, the image will already be in cache + // so onLoad won't fire, so we react to the global loading state as well + // If an image is offset it also won't fire onLoad, so we immediately mark as ready + React.useEffect(() => { + const outOfView = isInView === false; + const isReady = ref.current?.complete || outOfView; + if (isReady) onImageLoad(id); + }, [onImageLoad, id, isInView]); + + return ( +
+ + { + onImageLoad(id); + setIsLoaded(true); + imageProps.onLoad?.(event); + }} + className="select-none object-cover relative" + /> + +
+ ); + }, +); +RouterImage.displayName = 'RouterImage'; + +/* -----------------------------------------------------------------------------------------------*/ + +const getSpinnerVariantFromPath = (path: string) => { + if (path === 'radix') return 'radix'; + if (path === 'aragon') return 'aragon'; + if (path === 'blocks') return 'blocks'; + if (path === 'dash') return 'dash'; + return 'light'; +}; + +const isModifiedEvent = (event: React.MouseEvent) => { + const eventTarget = event.currentTarget; + const target = eventTarget.getAttribute('target'); + return ( + (target && target !== '_self') || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + (event.nativeEvent && event.nativeEvent.which === 2) + ); +}; + +const useRouterState = () => { + const context = useRouterContext(); + return context.state; +}; + +export { RouterProvider, RouterLink, RouterTransition, RouterImage, useRouterState }; diff --git a/app/sidebar.tsx b/app/sidebar.tsx new file mode 100644 index 0000000..de167e6 --- /dev/null +++ b/app/sidebar.tsx @@ -0,0 +1,520 @@ +'use client'; + +import * as React from 'react'; +import * as Floating from '@floating-ui/react'; +import { cva, cx } from '@/cva.config'; + +import * as ScrollArea from '@/components/primitives/scroll-area'; + +import { useLayoutEffect } from '@/components/utils/use-layout-effect'; + +import { AnimatePresence, cubicBezier, motion, useInView } from 'motion/react'; +import { createContext } from '@/components/utils/create-context'; +import { useComposedRefs } from '@/components/utils/compose-refs'; +import { Copy } from '@/components/copy'; +import { SocialLink } from '@/components/social-link'; +import { Line } from '@/components/line'; +import { MouseHover } from '@/components/primitives/mouse-hover'; +import { ProjectId } from '@/types'; +import { getProjectById } from '@/data'; +import { RouterLink, useRouterState } from './router'; +import { useDevice } from '@/components/utils/use-device'; +import { FocusRing } from '@/components/focus-ring'; + +const MOTION_TRANSITION = { + ease: cubicBezier(0.5, 0.2, 0.2, 1), + duration: 0.35, +}; + +function useFloating({ open, onOpenChange }: { open: boolean; onOpenChange(open: boolean): void }) { + const { context, refs } = Floating.useFloating({ + open, + onOpenChange, + }); + + const { setReference, setFloating } = refs; + + const { getReferenceProps, getFloatingProps } = Floating.useInteractions([ + Floating.useClick(context), + Floating.useRole(context), + Floating.useDismiss(context, { outsidePressEvent: 'mousedown' }), + ]); + + return React.useMemo( + () => ({ context, getReferenceProps, getFloatingProps, setReference, setFloating }), + [context, getReferenceProps, getFloatingProps, setReference, setFloating], + ); +} + +/* ------------------------------------------------------------------------------------------------- + * Sidebar + * -----------------------------------------------------------------------------------------------*/ + +type SidebarContextValue = { + open: boolean; + sidebarWidth: number | undefined; + onExitAnimationComplete(): void; + headingId: string; + descriptionId: string; + floating: ReturnType; +}; + +const SIDEBAR_NAME = 'Sidebar'; + +const [SidebarProvider, useSidebarContext] = createContext(SIDEBAR_NAME); + +export const Sidebar = ({ children }: { children: React.ReactNode }) => { + const [open, setOpen] = React.useState(false); + const [sidebarWidth, setSidebarWidth] = React.useState(); + const id = React.useId(); + const floating = useFloating({ open, onOpenChange: setOpen }); + const routerState = useRouterState(); + + // Close sidebar when navigating + React.useEffect(() => { + if (routerState === 'cover') setOpen(false); + }, [routerState]); + + // Match fixed element offset applied by floating ui lockScroll + useLayoutEffect(() => { + if (open) { + const scrollbarWidth = parseInt(document.body.style.paddingRight) || undefined; + setSidebarWidth(open ? scrollbarWidth : undefined); + } + }, [open]); + + return ( + setSidebarWidth(undefined)} + headingId={id} + descriptionId={id} + floating={floating} + > + {children} + + ); +}; + +Sidebar.displayName = 'Sidebar'; + +/* ------------------------------------------------------------------------------------------------- + * SidebarTrigger + * -----------------------------------------------------------------------------------------------*/ + +type SidebarTriggerElement = React.ComponentRef<'button'>; + +interface SidebarTriggerProps extends React.ComponentPropsWithoutRef<'button'> {} + +const SidebarTrigger = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + const context = useSidebarContext(); + const composedRefs = useComposedRefs( + (node) => context.floating.setReference(node), + forwardedRef, + ); + + return ( + + + + ); + }, +); + +SidebarTrigger.displayName = 'SidebarTrigger'; + +/* ------------------------------------------------------------------------------------------------- + * SidebarMenu + * -----------------------------------------------------------------------------------------------*/ + +type SidebarMenuElement = React.ComponentRef<'div'>; + +interface SidebarMenuProps extends React.ComponentPropsWithoutRef<'div'> {} + +const SidebarMenu = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + const context = useSidebarContext(); + const composedRefs = useComposedRefs( + (node) => context.floating.setFloating(node), + forwardedRef, + ); + + return ( + + {context.open && ( + +
+ + +
+ + + + + +
+
+
+ + + +
+
+
+
+
+
+
+
+
+ )} +
+ ); + }, +); + +SidebarMenu.displayName = 'SidebarMenu'; + +/* -----------------------------------------------------------------------------------------------*/ + +type SidebarMenuContentElement = React.ComponentRef<'div'>; + +interface SidebarMenuContentProps extends React.ComponentPropsWithoutRef<'div'> {} + +const SidebarMenuContent = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + const context = useSidebarContext(); + + return ( +
+

+ Navigation +

+

+ Select an item to navigate +

+ +
+
+
+

+ Work +

+ + + {(['radix', 'aragon', 'blocks', 'dash'] as const) + .map((projectId) => getProjectById(projectId)) + .filter((project): project is NonNullable => project !== null) + .map((project) => ( + + + + ))} + +
+
+ +
+
+ +

+ Get in touch +

+ +
+ hello@andyhook.dev +
+ +
+ + +
    + {(['github', 'linkedin', 'dribbble', 'twitter', 'instagram'] as const).map( + (platform) => ( +
  • + +
  • + ), + )} +
+
+
+
+
+
+ ); + }, +); + +SidebarMenuContent.displayName = 'SidebarMenuContent'; + +/* -----------------------------------------------------------------------------------------------*/ + +const sidebarProjectLinkLine = cva({ + base: 'h-full absolute top-0 right-0 rounded-full bg-gradient-to-tl', + variants: { + projectId: { + radix: 'from-radix-1 to-radix-5', + aragon: 'from-aragon-1 to-aragon-5', + blocks: 'from-blocks-1 to-blocks-5', + dash: 'from-dash-1 to-dash-4', + }, + }, +}); + +type SidebarProjectLinkElement = React.ComponentRef; + +interface SidebarProjectLinkProps + extends Omit, 'href'> { + projectId: ProjectId; + title: string; + path: string; +} + +const SidebarProjectLink = React.forwardRef( + ({ className, path, title, projectId, ...props }, forwardedRef) => { + const [hovered, setHovered] = React.useState(false); + + return ( + + + + + + {title} + + +
+
+ +
+
+
+
+
+
+ ); + }, +); + +SidebarProjectLink.displayName = 'SidebarProjectLink'; + +/* ------------------------------------------------------------------------------------------------- + * SidebarOverlay + * -----------------------------------------------------------------------------------------------*/ + +type SidebarOverlayElement = React.ComponentRef<'div'>; + +interface SidebarOverlayProps extends React.ComponentPropsWithoutRef<'div'> {} + +const SidebarOverlay = React.forwardRef( + (props, forwardedRef) => { + const context = useSidebarContext(); + const device = useDevice(); + + return ( + + {context.open && ( + + { + if (definition === 'hidden') { + context.onExitAnimationComplete(); + } + }} + /> + + )} + + ); + }, +); + +SidebarOverlay.displayName = 'SidebarOverlay'; + +/* ------------------------------------------------------------------------------------------------- + * SidebarAnimation + * -----------------------------------------------------------------------------------------------*/ + +type SidebarAnimationElement = React.ComponentRef; + +interface SidebarAnimationProps extends React.ComponentPropsWithoutRef {} + +const SidebarAnimation = React.forwardRef( + ({ className, ...props }, forwardedRef) => { + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const context = useSidebarContext(); + const isInView = useInView(ref); + + return ( + + ); + }, +); + +SidebarAnimation.displayName = 'SidebarAnimation'; + +/* -----------------------------------------------------------------------------------------------*/ + +export const Root = Sidebar; +export const Menu = SidebarMenu; +export const Overlay = SidebarOverlay; +export const Trigger = SidebarTrigger; +export const Animation = SidebarAnimation; diff --git a/app/theme.tsx b/app/theme.tsx new file mode 100644 index 0000000..be5389b --- /dev/null +++ b/app/theme.tsx @@ -0,0 +1,45 @@ +'use client'; + +import * as React from 'react'; +import { usePathname } from 'next/navigation'; + +import { ProjectId } from '@/types'; +import { getProjectById } from '@/data'; + +import { cx } from '@/cva.config'; +import { getThemeColorValues } from '@/theme'; + +/* ------------------------------------------------------------------------------------------------- + * Theme + * -----------------------------------------------------------------------------------------------*/ + +type ThemeElement = React.ComponentRef<'div'>; + +interface ThemeProps extends React.ComponentPropsWithoutRef<'div'> {} + +const Theme = React.forwardRef((props, forwardedRef) => { + const { className, ...themeProps } = props; + const pathname = usePathname(); + + const colorVariables = React.useMemo(() => { + const parsedPathname = pathname.replace('/', '') as ProjectId; + const projectId = getProjectById(parsedPathname)?.id; + + return getThemeColorValues(projectId); + }, [pathname]); + + return ( +
+ ); +}); + +Theme.displayName = 'Theme'; + +/* -----------------------------------------------------------------------------------------------*/ + +export { Theme }; diff --git a/components/About/About.tsx b/components/About/About.tsx deleted file mode 100644 index 1955e63..0000000 --- a/components/About/About.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react' -import { inclusiveDown, inclusiveUp } from '../../style/responsive' -import Button from '../Button/Button' -import ExperienceList from '../ExperienceList/ExperienceList' -import Icon from '../Icon/Icon' -import SocialProof from '../SocialProof/SocialProof' -import TextHeading from '../Text/TextHeading' -import TextParagraph from '../Text/TextParagraph' - -function About(): JSX.Element { - return ( - <> -
-
- - More than 10 years building for the web - -
- -
- - Great user experience is the core driving everything I do, I believe - that close collaboration between design, research and front-end - engineering leads to superior customer experiences within digital - products. - - - - As a specialist in modular design systems and component libraries, I - work to bridge the gap between design and engineering disiplines and - act as a catalyst for fast, iterative design processes within agile - product teams. My technical expertise spans a wealth of front-end - technologies ranging from modern SPA application development to - accessibility optimisation and front-end testing. - -
- -
- -
-
- - - - - ) -} - -export default About diff --git a/components/AccessibleIcon/AccessibleIcon.tsx b/components/AccessibleIcon/AccessibleIcon.tsx deleted file mode 100644 index 700d0ec..0000000 --- a/components/AccessibleIcon/AccessibleIcon.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// Accessible icon implementation heavily inspired by the fantastic Radix UI team -// https://github.com/radix-ui/primitives/blob/main/packages/react/accessible-icon/src/AccessibleIcon.tsx -import React from 'react' - -type AccessibleIconProps = { - /** - * The accessible label for the icon. This label will be visually hidden but announced to screen - * reader users, similar to `alt` text for `img` tags. - */ - label: string - children: React.ReactNode -} - -function AccessibleIcon({ children, label }: AccessibleIconProps): JSX.Element { - const child = React.Children.only(children) - return ( - <> - {React.cloneElement(child as React.ReactElement, { - // accessibility - 'aria-hidden': 'true', - focusable: 'false', // See: https://allyjs.io/tutorials/focusing-in-svg.html#making-svg-elements-focusable - })} - - {label} - - - ) -} - -export default AccessibleIcon diff --git a/components/Button/Button.tsx b/components/Button/Button.tsx deleted file mode 100644 index cc1a510..0000000 --- a/components/Button/Button.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { motion } from 'framer-motion' -import React from 'react' -import { useTheme } from '../../hooks/useTheme/useTheme' -import { spring } from '../../style/motion' -import { inclusiveUp } from '../../style/responsive' -import { - setCropAndLineHeight, - setTextStyle, - typeScale, -} from '../../style/typography' -import InteractionBase from '../InteractionBase/InteractionBase' - -type ButtonProps = { - href?: string - newTab?: boolean - children: React.ReactNode - onClick?: () => void - icon?: React.ReactNode -} - -function Button({ - href, - children, - newTab, - onClick, - icon, -}: ButtonProps): JSX.Element { - const theme = useTheme() - - const radius = theme.radius.pill - - return ( - - - -
-
- {icon && ( -
-
-
- {icon} -
-
-
- )} - - {children} -
-
-
-
- ) -} - -export default Button diff --git a/components/ExperienceList/ExperienceList.tsx b/components/ExperienceList/ExperienceList.tsx deleted file mode 100644 index 23e07ed..0000000 --- a/components/ExperienceList/ExperienceList.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import React from 'react' -import { ImagePath } from '../../data/images' -import { inclusiveDown, inclusiveUp } from '../../style/responsive' -import Panel from '../Panel/Panel' -import TextBase from '../Text/TextBase' -import TextHeading from '../Text/TextHeading' - -type ExperienceEntry = { - year: string - logoSrc: ImagePath - title: string - company: string -} - -const ENTRIES: ExperienceEntry[] = [ - { - year: '2021', - logoSrc: 'logos/modulz-mark.svg', - title: 'Senior Product Engineer', - company: 'Modulz', - }, - { - year: '2020', - logoSrc: 'logos/aragon-mark.svg', - title: 'Senior UI Engineer', - company: 'Aragon One', - }, - { - year: '2018', - logoSrc: 'logos/bright-mark.svg', - title: 'UI Engineer', - company: 'Bright Interactive', - }, - { - year: '2016', - logoSrc: 'logos/brandwatch-mark.svg', - title: 'Senior Front-End Developer', - company: 'Brandwatch', - }, - { - year: '2012', - logoSrc: 'logos/tjc-mark.svg', - title: 'Front-End Developer', - company: 'Jamieson Consultancy', - }, - { - year: '2009', - logoSrc: 'logos/andyhook-mark.svg', - title: 'Digital Designer', - company: 'Freelance', - }, -] - -function ExperienceList(): JSX.Element { - return ( -
    - {ENTRIES.map((item, i) => ( -
  • - - {/* Employment year */} -
    - - {item.year} - -
    - - {/* Logo mark */} -
    - -
    - - {/* Company name */} -
    - - {item.company} - -
    - - {/* Job title */} -
    - - {item.title} - -
    -
    -
  • - ))} -
- ) -} - -export default ExperienceList diff --git a/components/Footer/Footer.tsx b/components/Footer/Footer.tsx deleted file mode 100644 index e78d477..0000000 --- a/components/Footer/Footer.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react' -import { inclusiveDown, inclusiveUp } from '../../style/responsive' -import { setTextStyle } from '../../style/typography' -import InteractionBase from '../InteractionBase/InteractionBase' -import LayoutGutter from '../Layout/LayoutGutter' -import LayoutLimiter from '../Layout/LayoutLimiter' -import LayoutRow from '../Layout/LayoutRow' -import SocialIcons from '../SocialIcons/SocialIcons' -import GradientText from '../GradientText/GradientText' -import TextHeading from '../Text/TextHeading' -import TextBase from '../Text/TextBase' -import { removeWidow } from '../../style/utils' -import LayoutShade from '../Layout/LayoutShade' -import { META } from '../../data/meta' -import Signature from '../Signature/Signature' -import { useTheme } from '../../hooks/useTheme/useTheme' - -function Footer(): JSX.Element { - const theme = useTheme() - - return ( -
- - - - -
- - Let’s build something awesome - - - - Start by{' '} - - - {removeWidow('saying hello')} - - - -
- - -
- - -
-
-
-
- ) -} - -export default Footer diff --git a/components/GradientText/GradientText.tsx b/components/GradientText/GradientText.tsx deleted file mode 100644 index fa2c6b6..0000000 --- a/components/GradientText/GradientText.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import { css, keyframes } from 'styled-components' -import { useTheme } from '../../hooks/useTheme/useTheme' - -const textShimmerAnimation = css` - background-size: 500% 500%; - - animation: ${keyframes` - 0%{ - background-position: 100% 100%; - } - 100%{ - background-position: 0% 0%; - } - `} 3s linear infinite; -` - -type GradientTextProps = { - children: string -} - -function GradientText({ children, ...props }: GradientTextProps): JSX.Element { - const { accent } = useTheme() - - return ( - - {children} - - ) -} - -export default GradientText diff --git a/components/Icon/Icon.tsx b/components/Icon/Icon.tsx deleted file mode 100644 index 512b00e..0000000 --- a/components/Icon/Icon.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import { IconName, ICON_PATHS } from '../../data/icons' - -type IconProps = { - name: IconName -} - -function Icon({ name, ...props }: IconProps): JSX.Element { - const iconPath = ICON_PATHS[name] - - return ( -
- - - -
- ) -} - -export default Icon diff --git a/components/ImageBase/ImageBase.tsx b/components/ImageBase/ImageBase.tsx deleted file mode 100644 index dcaf3ac..0000000 --- a/components/ImageBase/ImageBase.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react' -import Image from 'next/image' -import { AnimatePresence, motion } from 'framer-motion' -import { BreakpointName, breakpoints } from '../../style/responsive' -import { useTheme } from '../../hooks/useTheme/useTheme' -import { spring } from '../../style/motion' -import { css, keyframes } from 'styled-components' -import { imageData, ImageProperties } from '../../data/images' -import { loadingShimmerGradientFromColor } from '../../style/utils' -import { useTrackLoading } from '../../hooks/useLoadPercentage/useLoadPercentage' - -const shimmerAnimation = css` - background-size: 500% 500%; - - animation: ${keyframes` - 0%{ - background-position: 100% 100%; - opacity: 0.75; - } - 100%{ - background-position: 0% 0%; - opacity: 0; - } - `} 2s linear infinite; -` - -type BackgroundColorPreset = 'light' | 'dark' - -type ImageBaseProps = { - scaleRender?: number - scaleRenderFromBp?: [BreakpointName, number] - quality?: number - backgroundColor?: BackgroundColorPreset | string - onLoad?: () => void - visibleOpacity?: number - loading?: 'eager' | 'lazy' -} & ImageProperties - -function ImageBase({ - imagePath, - alt, - backgroundColor = 'dark', - scaleRender = 100, - quality = 100, - scaleRenderFromBp, - onLoad, - visibleOpacity = 1, - loading = 'eager', - ...props -}: ImageBaseProps): JSX.Element { - const { background, foreground } = useTheme() - const [imageLoaded, setImageLoaded] = useState(false) - const { trackLoaded } = useTrackLoading(imagePath) - - const image = imageData[imagePath] - - // There is no need to optimise and render srcset for a scalable SVG - const unoptimized = useMemo(() => imagePath.endsWith('.svg'), [imagePath]) - - const sizesMediaString = useMemo(() => { - if (!scaleRenderFromBp) { - return `${scaleRender}vw` - } - - const [breakpointName, breakpointScaleValue] = scaleRenderFromBp - - return `(min-width: ${breakpoints[breakpointName]}) ${breakpointScaleValue}vw, ${scaleRender}vw` - }, [scaleRender, scaleRenderFromBp]) - - const fireLoadEvents = useCallback(() => { - if (!imageLoaded) { - setImageLoaded(true) - trackLoaded() - onLoad && onLoad() - } - }, [onLoad, trackLoaded, imageLoaded]) - - // When using loading strategy "eager", next/image won't reliably fire onLoad events when retrieving from client cache - // To work around this we need to check the "complete" property on the image element and run the same set of callbacks - const handleLoadFromCache = useCallback( - (wrapperRef) => { - // We are unable to set a ref to the underlying image element directly so we must access it via querySelector on the wrapper - // We are querying by srcset attribute to differentiate from the placeholder image that next/image adds to reserve space - // https://github.com/vercel/next.js/discussions/18386 - const image = wrapperRef?.querySelector('img[srcset]') as HTMLImageElement - - if (image && image.complete) { - fireLoadEvents() - } - }, - [fireLoadEvents] - ) - - const handleLoadEvent = useCallback( - (e) => { - // The next/image placeholder image triggers a duplicate event - // We only want to trigger the load handler when the actual image is loaded, hence making sure the source of the target element triggering the event is not base64. - // See https://github.com/vercel/next.js/issues/20368#issuecomment-757446007 - if (e.target.src.indexOf('data:image/gif;base64') < 0) { - fireLoadEvents() - } - }, - [fireLoadEvents] - ) - - const { gradientStop, gradientStopAlpha, sourceColor } = useMemo(() => { - const backgroundPreset: Record = { - light: foreground('extraHigh'), - dark: background('medium'), - } - - const isPreset = backgroundColor === 'light' || backgroundColor === 'dark' - - return loadingShimmerGradientFromColor( - isPreset - ? backgroundPreset[backgroundColor as BackgroundColorPreset] - : backgroundColor - ) - }, [background, foreground, backgroundColor]) - - return ( -
- - {!imageLoaded && ( - -
- - )} - - - {alt} - -
- ) -} - -export default ImageBase diff --git a/components/InteractionBase/InteractionBase.spec.tsx b/components/InteractionBase/InteractionBase.spec.tsx deleted file mode 100644 index 848cbbd..0000000 --- a/components/InteractionBase/InteractionBase.spec.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react' -import { axe } from 'jest-axe' -import type { RenderResult } from '@testing-library/react' -import { render, fireEvent } from '../../utils/testing' -import InteractionBase from './InteractionBase' - -const BUTTON_TEXT = 'Button text' -const EXTERNAL_ATTRIBUTES = [ - ['rel', 'noopener noreferrer'], - ['target', '_blank'], -] - -describe('given a default InteractionBase', () => { - let rendered: RenderResult - let element: HTMLElement | null - const handleClick = jest.fn() - - beforeEach(() => { - rendered = render( - {BUTTON_TEXT} - ) - }) - - it('should have no accessibility violations', async () => { - expect(await axe(rendered.container)).toHaveNoViolations() - }) - - it('should render as a button element', () => { - element = rendered.queryByRole('button') - - expect(element).toBeInTheDocument() - }) - - it('should call onClick when clicked', () => { - fireEvent.click(rendered.getByText(BUTTON_TEXT)) - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it('should not call onClick when disabled', () => { - handleClick.mockReset() - - rendered.rerender( - - {BUTTON_TEXT} - - ) - - fireEvent.click(rendered.getByText(BUTTON_TEXT)) - - expect(handleClick).not.toBeCalled() - }) - - it('should apply outline offsets correctly', () => { - element = rendered.getByText(BUTTON_TEXT) - - fireEvent.focus(element) - - rendered.rerender( - {BUTTON_TEXT} - ) - - const outlineElement = element.querySelector('span') - - expect(outlineElement).toHaveStyleRule('top', '-1em') - expect(outlineElement).toHaveStyleRule('bottom', '-1em') - expect(outlineElement).toHaveStyleRule('left', '-1em') - expect(outlineElement).toHaveStyleRule('right', '-1em') - - rendered.rerender( - {BUTTON_TEXT} - ) - - expect(outlineElement).toHaveStyleRule('top', '-1em') - expect(outlineElement).toHaveStyleRule('bottom', '-1em') - expect(outlineElement).toHaveStyleRule('left', '-0.5em') - expect(outlineElement).toHaveStyleRule('right', '-0.5em') - }) -}) - -describe('given an InteractionBase with external href', () => { - let rendered: RenderResult - let element: HTMLElement | null - - beforeEach(() => { - rendered = render( - {BUTTON_TEXT} - ) - }) - - it('should have no accessibility violations', async () => { - expect(await axe(rendered.container)).toHaveNoViolations() - }) - - it('should render as a link element', () => { - element = rendered.queryByRole('link') - - expect(element).toBeInTheDocument() - }) - - it('should apply correct attributes', () => { - element = rendered.queryByRole('link') - - EXTERNAL_ATTRIBUTES.forEach(([attribute, property]) => { - expect(element).toHaveAttribute(attribute, property) - }) - }) - - it('should render as a button and not apply external attributes when disabled', () => { - rendered.rerender( - - {BUTTON_TEXT} - - ) - - element = rendered.queryByRole('button') - - EXTERNAL_ATTRIBUTES.forEach(([attribute, property]) => { - expect(element).not.toHaveAttribute(attribute, property) - }) - }) -}) - -describe('given an InteractionBase with internal href', () => { - let rendered: RenderResult - let element: HTMLElement | null - - beforeEach(() => { - rendered = render( - {BUTTON_TEXT} - ) - }) - - it('should render as a link element', () => { - element = rendered.queryByRole('link') - - expect(element).toBeInTheDocument() - }) - - it('should only apply external attributes when passed newTab', () => { - element = rendered.queryByRole('link') - - EXTERNAL_ATTRIBUTES.forEach(([attribute, property]) => { - expect(element).not.toHaveAttribute(attribute, property) - }) - - rendered.rerender( - - {BUTTON_TEXT} - - ) - - EXTERNAL_ATTRIBUTES.forEach(([attribute, property]) => { - expect(element).toHaveAttribute(attribute, property) - }) - }) -}) diff --git a/components/InteractionBase/InteractionBase.tsx b/components/InteractionBase/InteractionBase.tsx deleted file mode 100644 index ef8a48a..0000000 --- a/components/InteractionBase/InteractionBase.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import React, { useCallback, useMemo } from 'react' -import styled from 'styled-components' -import { useFocusVisible } from '../../hooks/useFocusVisible/useFocusVisible' -import { useTheme } from '../../hooks/useTheme/useTheme' -import { useRouter } from 'next/router' -import { isExternalURL, noop } from '../../utils/general' -import { Theme } from '../../style/theme' -import { spring } from '../../style/motion' - -type InteractionBaseProps = { - children: React.ReactNode - href?: string - disabled?: boolean - offset?: number | [number, number] - radius?: keyof Theme['radius'] - newTab?: boolean - onClick?: (event: React.MouseEvent) => void -} - -type ElementProps = [ - 'button' | 'a', - { - href?: string - rel?: string - target?: string - disabled?: boolean - } -] - -function getLinkProps(href: string, newTab: boolean): ElementProps { - const external = isExternalURL(href) || newTab - - const externalProps = external - ? { - rel: 'noopener noreferrer', - target: '_blank', - } - : {} - - return [ - 'a', - { - href: href, - ...externalProps, - }, - ] -} - -function getButtonProps(disabled: boolean): ElementProps { - return [ - 'button', - { - disabled, - }, - ] -} - -function isModifiedEvent(event: React.MouseEvent): boolean { - const { target } = event.currentTarget as HTMLAnchorElement - return ( - (target && target !== '_self') || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey || // triggers resource download - (event.nativeEvent && event.nativeEvent.which === 2) - ) -} - -function InteractionBase({ - children, - href, - onClick, - disabled = false, - offset = 0, - radius = 'base', - newTab = false, - ...props -}: InteractionBaseProps): JSX.Element { - const router = useRouter() - const { focusVisible, onFocus, onBlur } = useFocusVisible() - const theme = useTheme() - - const handleOnClick = useCallback( - (event: React.MouseEvent) => { - if (onClick) { - onClick(event) - } - - if (isModifiedEvent(event)) { - return - } - - // Use internal router for all relative links - if (href && !isExternalURL(href)) { - event.preventDefault() - router.push(href) - } - }, - [onClick, href, router] - ) - - const [elementTag, elementProps] = - href && !disabled ? getLinkProps(href, newTab) : getButtonProps(disabled) - - const [offsetX, offsetY] = useMemo(() => { - if (typeof offset === 'number') { - return [offset, offset] - } - - return offset - }, [offset]) - - return ( - - {children} - - {focusVisible && ( - - )} - - - ) -} - -const StyledInteractiveElement = styled.button` - position: relative; -` - -export default InteractionBase diff --git a/components/Layout/LayoutGutter.tsx b/components/Layout/LayoutGutter.tsx deleted file mode 100644 index ac0b6f0..0000000 --- a/components/Layout/LayoutGutter.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' -import { inclusiveUp } from '../../style/responsive' - -type LayoutGutterProps = { - children: React.ReactNode -} - -function LayoutGutter({ children, ...props }: LayoutGutterProps): JSX.Element { - return ( -
- {children} -
- ) -} - -export default LayoutGutter diff --git a/components/Layout/LayoutLimiter.tsx b/components/Layout/LayoutLimiter.tsx deleted file mode 100644 index 8e61753..0000000 --- a/components/Layout/LayoutLimiter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react' -import { rem } from 'polished' -import { useTheme } from '../../hooks/useTheme/useTheme' - -type LimiterWidth = 'small' | 'medium' | 'large' - -interface LayoutLimiterProps { - size?: 'small' | 'medium' | 'large' - children: React.ReactNode - divider?: boolean -} - -const LIMITER_WIDTHS: Record = { - small: rem('900px'), - medium: rem('1550px'), - large: rem('1900px'), -} - -function LayoutLimiter({ - size = 'medium', - divider, - children, - ...props -}: LayoutLimiterProps): JSX.Element { - const theme = useTheme() - const width = LIMITER_WIDTHS[size] - - return ( -
- {children} -
- ) -} - -export default LayoutLimiter diff --git a/components/Layout/LayoutRow.tsx b/components/Layout/LayoutRow.tsx deleted file mode 100644 index 7a89ec3..0000000 --- a/components/Layout/LayoutRow.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import { css } from 'styled-components' -import { inclusiveUp } from '../../style/responsive' - -type LayoutRowProps = { - trimTop?: boolean - trimBottom?: boolean - children: React.ReactNode -} - -function LayoutRow({ - children, - trimTop, - trimBottom, - ...props -}: LayoutRowProps): JSX.Element { - return ( -
- {children} -
- ) -} - -const topSpaceStyle = css` - padding-top: 4rem; - - ${inclusiveUp('sm')} { - padding-top: 7rem; - } - - ${inclusiveUp('md')} { - padding-top: 10rem; - } - - ${inclusiveUp('lg')} { - padding-top: 12rem; - } -` - -const bottomSpaceStyle = css` - padding-bottom: 4rem; - - ${inclusiveUp('sm')} { - padding-bottom: 7rem; - } - - ${inclusiveUp('md')} { - padding-bottom: 10rem; - } - - ${inclusiveUp('lg')} { - padding-bottom: 12rem; - } -` - -export default LayoutRow diff --git a/components/Layout/LayoutShade.tsx b/components/Layout/LayoutShade.tsx deleted file mode 100644 index 9ed3426..0000000 --- a/components/Layout/LayoutShade.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import { useTheme } from '../../hooks/useTheme/useTheme' - -type LayoutShadeProps = { - children: React.ReactNode - borderTop?: boolean - borderBottom?: boolean -} - -function LayoutShade({ - children, - borderTop, - borderBottom, - ...props -}: LayoutShadeProps): JSX.Element { - const theme = useTheme() - - const borderStyle = ` - ${theme.borderWidth.regular} solid - ${theme.background('extraHigh')} - ` - - return ( -
- {children} -
- ) -} - -export default LayoutShade diff --git a/components/Link/Link.tsx b/components/Link/Link.tsx deleted file mode 100644 index eb60805..0000000 --- a/components/Link/Link.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { useTheme } from '../../hooks/useTheme/useTheme' -import InteractionBase from '../InteractionBase/InteractionBase' - -type LinkProps = { - children: React.ReactNode - href: string -} - -function Link({ children, href }: LinkProps): JSX.Element { - const theme = useTheme() - - return ( - - {children} - - ) -} - -export default Link diff --git a/components/LoadingIndicator/LoadingIndicator.tsx b/components/LoadingIndicator/LoadingIndicator.tsx deleted file mode 100644 index b170e69..0000000 --- a/components/LoadingIndicator/LoadingIndicator.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { useRouter } from 'next/router' -import { motion } from 'framer-motion' -import { spring } from '../../style/motion' -import { useTheme } from '../../hooks/useTheme/useTheme' -import { setLightness } from 'polished' -import { useLoadPercentage } from '../../hooks/useLoadPercentage/useLoadPercentage' - -function LoadingIndicator(): JSX.Element { - const [loadComplete, setLoadComplete] = useState(false) - const [hidden, setHidden] = useState(false) - const router = useRouter() - const theme = useTheme() - const percentLoaded = useLoadPercentage() - - useEffect(() => { - if (percentLoaded === 100) { - setLoadComplete(true) - } - }, [percentLoaded]) - - // We only fill the bar half the way using the loaded percentage - // This let's us rapidly fill the remainder once everything has loaded - // This improves perceived performance - // https://developer.mozilla.org/en-US/docs/Learn/Performance/Perceived_performance - const barPosition = loadComplete ? 0 : -100 + percentLoaded / 2 - - return ( -