Last active
August 8, 2024 18:23
-
-
Save jirfag/290a5a44f90ff67884843af2b70cb60f to your computer and use it in GitHub Desktop.
React/Gatsby Table of Contents
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { createRef } from "react" | |
import throttle from "lodash/throttle" | |
// Import styled components (see the Styles section below). | |
import { | |
TocDiv, | |
TocLink, | |
TocIcon, | |
TocTitle, | |
TocToggleOpener, | |
TocToggleCloser, | |
TocListItem, | |
TocListBullet, | |
} from "./styles" | |
import { HeadingTree, TraverseResult, IHeadingData } from "./heading-tree" | |
import HeadingNode from "./heading-node" | |
interface IProps { | |
containerSelector: string // Selector for a content container | |
levels?: number[] // Needed heading levels, by default [2, 3, 4] | |
prebuiltHeadings?: IHeadingData[] // Already extracted page headings to speed up | |
title?: string // Title, default is "Contents" | |
throttleTimeMs?: number // Scroll handler throttle time, default is 300 | |
// Note: offsetToBecomeActive must not be zero because at least in my chrome browser | |
// element.scrollTo() sets window.scrollY = element.offsetTop - 1 | |
// and some routers use this function to scroll to window.location.hash. | |
// The default value is 30 (px). | |
offsetToBecomeActive?: number | |
} | |
interface IActiveHeadings { | |
[key: number]: boolean | |
} | |
interface IState { | |
open: boolean | |
headingTree?: HeadingTree | |
activeParents: IActiveHeadings | |
activeNode?: HeadingNode | |
container?: HTMLElement | |
} | |
export default class Toc extends React.Component<IProps, IState> { | |
private wrapperRef = createRef<HTMLDivElement>() | |
private clickEventListenerWasAdded = false | |
private handleScrollThrottled: () => void | |
private domObserver: MutationObserver | |
constructor(props: IProps) { | |
super(props) | |
this.state = { | |
open: false, | |
headingTree: null, | |
activeParents: {}, | |
activeNode: null, | |
container: null, | |
} | |
this.handleClickOutside = this.handleClickOutside.bind(this) | |
this.handleResize = this.handleResize.bind(this) | |
} | |
componentWillUnmount() { | |
this.handleClose(false) | |
window.removeEventListener(`scroll`, this.handleScrollThrottled) | |
window.removeEventListener(`resize`, this.handleResize) | |
} | |
componentDidMount() { | |
const container = this.parseHeadings() | |
this.setupEventListeners(container) | |
} | |
private setupEventListeners(container: HTMLElement) { | |
const startedAt = performance.now() | |
let handleScroll: any | |
if (typeof window === "undefined" || !window.MutationObserver) { | |
console.info(`No window or mutationobserver, falling back to recalculating offsets on scroll`) | |
handleScroll = () => { | |
this.recalcOffsets() | |
this.handleScrollImpl() | |
} | |
this.domObserver = null | |
} else { | |
handleScroll = this.handleScrollImpl.bind(this) | |
this.domObserver = new MutationObserver(mutations => { | |
console.info( | |
`Toc: content container "${this.props.containerSelector}" mutation detected, recalculating offsets`, | |
mutations | |
) | |
this.recalcOffsets() | |
}) | |
this.domObserver.observe(container, { | |
attributes: true, | |
childList: true, | |
subtree: true, | |
characterData: true, | |
}) | |
} | |
this.handleScrollThrottled = throttle(handleScroll, this.props.throttleTimeMs || 300) | |
window.addEventListener(`scroll`, this.handleScrollThrottled) | |
window.addEventListener(`resize`, this.handleResize) | |
console.info(`Set up toc event listeners in ${performance.now() - startedAt}ms`) | |
} | |
private buildActiveParents(activeNode: HeadingNode): IActiveHeadings { | |
let curNode = activeNode | |
const activeParents = {} | |
if (this.state.headingTree) { | |
activeParents[this.state.headingTree.getRoot().key] = true | |
} | |
while (curNode !== null) { | |
activeParents[curNode.key] = true | |
curNode = curNode.parent | |
} | |
return activeParents | |
} | |
private handleResize() { | |
console.info(`Handling resize event`) | |
this.recalcOffsets() | |
} | |
private recalcOffsets() { | |
if (this.state.headingTree) { | |
this.state.headingTree.markOffsetCacheStale() | |
} | |
} | |
private handleScrollImpl() { | |
const startedAt = performance.now() | |
const activeNode = this.findActiveNode() | |
const elapsedMs = performance.now() - startedAt | |
if (elapsedMs >= 5) { | |
console.info(`Scroll handler: looking for active heading took ${elapsedMs}ms`) | |
} | |
if (activeNode !== this.state.activeNode) { | |
const activeParents = this.buildActiveParents(activeNode) | |
this.setState({ activeNode, activeParents }) | |
} | |
} | |
private handleClickOutside(event: MouseEvent) { | |
if (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target as Node)) { | |
this.setState({ open: false }) | |
} | |
} | |
private handleOpen() { | |
if (!this.clickEventListenerWasAdded) { | |
document.addEventListener("mousedown", this.handleClickOutside) | |
this.clickEventListenerWasAdded = true | |
} | |
this.setState({ open: true }) | |
} | |
private handleClose(canSetState: boolean) { | |
if (this.clickEventListenerWasAdded) { | |
document.removeEventListener("mousedown", this.handleClickOutside) | |
this.clickEventListenerWasAdded = false | |
} | |
if (canSetState) { | |
this.setState({ open: false }) | |
} | |
} | |
private handleHeadingClick(ev: any, h: HeadingNode) { | |
event.preventDefault() | |
const elemTopOffset = h.cachedOffsetTop | |
window.history.replaceState({}, "", `#${h.id}`) | |
window.scrollTo(0, elemTopOffset) | |
this.handleClose(true) | |
this.setState({ activeNode: h, activeParents: this.buildActiveParents(h) }) | |
} | |
private parseHeadings() { | |
const startedAt = performance.now() | |
const container = document.querySelector(this.props.containerSelector) as HTMLElement | |
if (!container) { | |
throw Error(`failed to find container by selector "${this.props.containerSelector}"`) | |
} | |
let headings = this.props.prebuiltHeadings | |
if (headings) { | |
const isSSR = typeof window === "undefined" | |
if (isSSR) { | |
// Just to validate, in client-side code it will be lazy. | |
headings.forEach(h => { | |
h.htmlNode = h.htmlNode || document.getElementById(h.id) | |
if (!h.htmlNode) { | |
throw Error(`no heading with id "${h.id}"`) | |
} | |
}) | |
} | |
} else { | |
const levels = this.props.levels || [2, 3, 4] | |
const headingSelector = levels.map(level => `h${level}`).join(`, `) | |
const htmlNodes: HTMLElement[] = Array.from(container.querySelectorAll(headingSelector)) | |
headings = htmlNodes.map((node, i) => ({ | |
value: node.innerText, | |
depth: Number(node.nodeName[1]), | |
id: node.id, | |
htmlNode: node, | |
})) | |
} | |
const tree = new HeadingTree(headings) | |
console.info( | |
`Built headings tree in ${performance.now() - startedAt}ms from ${ | |
this.props.prebuiltHeadings ? "prebuilt headings" : "DOM" | |
}` | |
) | |
this.setState({ headingTree: tree, container }) | |
return container | |
} | |
private findActiveNode(): HeadingNode | null { | |
if (!this.state.headingTree) { | |
return null | |
} | |
const offsetToBecomeActive = this.props.offsetToBecomeActive || 30 | |
const curScrollPos = window.scrollY + offsetToBecomeActive | |
let activeNode = null | |
let lastNode = null | |
this.state.headingTree.traverseInPreorder((h: HeadingNode) => { | |
if (curScrollPos > h.cachedOffsetTop) { | |
lastNode = h | |
return TraverseResult.Continue | |
} | |
activeNode = lastNode | |
return TraverseResult.Stop | |
}) | |
if (activeNode === null && lastNode !== null && this.state.container) { | |
// Mark last heading active only if we didn't scroll after the end of the container. | |
if (window.scrollY <= this.state.container.offsetTop + this.state.container.offsetHeight) { | |
return lastNode | |
} | |
} | |
return activeNode | |
} | |
private renderHeadings() { | |
if (!this.state.headingTree) { | |
return | |
} | |
const items = [] | |
this.state.headingTree.traverseInPreorder(h => { | |
const isActive = this.state.activeNode && this.state.activeNode.key === h.key | |
items.push( | |
<TocListItem depth={h.depth} active={isActive} key={h.key}> | |
<TocListBullet depth={h.depth} active={isActive} /> | |
<TocLink href={`#${h.id}`} active={isActive} depth={h.depth} onClick={ev => this.handleHeadingClick(ev, h)}> | |
{h.title} | |
</TocLink> | |
</TocListItem> | |
) | |
return this.state.activeParents[h.key] ? TraverseResult.Continue : TraverseResult.NoChildren | |
}) | |
return items | |
} | |
render() { | |
return ( | |
<> | |
<TocToggleOpener open={this.state.open} onClick={this.handleOpen.bind(this)} /> | |
<TocDiv ref={this.wrapperRef} open={this.state.open}> | |
<TocTitle> | |
<TocIcon /> | |
{this.props.title || `Contents`} | |
<TocToggleCloser onClick={() => this.handleClose(true)} /> | |
</TocTitle> | |
<nav> | |
<ul>{this.renderHeadings()}</ul> | |
</nav> | |
</TocDiv> | |
</> | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/utils/emotion.d.ts | |
import "@emotion/react" | |
import { ITheme } from "./theme" | |
declare module "@emotion/react" { | |
export interface Theme extends ITheme {} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/components/toc/heading-node.ts | |
export default class HeadingNode { | |
title: string | |
children: HeadingNode[] | |
parent?: HeadingNode | |
private offsetCacheVersion: number | null | |
cachedOffsetTop: number | null | |
private htmlNode: HTMLElement | null | |
depth: number // relative depth, starts from 0 | |
id: string | |
key: number // faster comparison than by id | |
constructor( | |
htmlNode: HTMLElement | null, | |
value: string, | |
depth: number, | |
id: string, | |
key: number, | |
offsetCacheVersion: number | |
) { | |
this.htmlNode = htmlNode | |
this.title = value | |
this.parent = null | |
this.children = [] | |
this.depth = depth | |
this.id = id | |
this.key = key | |
this.offsetCacheVersion = offsetCacheVersion - 1 | |
// Don't call this.refetchOffsetIfNeeded(offsetCacheVersion) to save initial expensive fetch: | |
// there are a more reflows during intial page loading, we need to delay offset fetching. | |
} | |
lazyLoad(curCacheVersion: number) { | |
if (curCacheVersion === this.offsetCacheVersion) { | |
return | |
} | |
if (!this.htmlNode) { | |
this.htmlNode = document.getElementById(this.id) | |
if (!this.htmlNode) { | |
throw Error(`no heading with id "${this.id}"`) | |
} | |
} | |
this.cachedOffsetTop = this.htmlNode.offsetTop | |
this.offsetCacheVersion = curCacheVersion | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/components/toc/heading-tree.ts | |
import HeadingNode from "./heading-node" | |
export enum TraverseResult { | |
Continue = 1, | |
NoChildren = 2, | |
Stop = 3, | |
} | |
export interface IHeadingData { | |
depth: number // h2 => 2, h3 => 3, etc | |
value: string | |
id: string | |
htmlNode?: HTMLElement | |
} | |
export class HeadingTree { | |
private root?: HeadingNode | |
private offsetCacheVersion: number | |
constructor(headings: IHeadingData[]) { | |
// Make depths of nodes relative | |
const minDepth = Math.min(...headings.map(h => h.depth)) | |
const offsetCacheVersion = 0 | |
const headingNodes: HeadingNode[] = headings.map((h, i) => { | |
return new HeadingNode(h.htmlNode, h.value, h.depth - minDepth, h.id, i, offsetCacheVersion) | |
}) | |
const nodeStack: HeadingNode[] = [new HeadingNode(null, "", -1, "", -1, offsetCacheVersion)] // init with root node | |
headingNodes.forEach(node => { | |
while (nodeStack.length && nodeStack[nodeStack.length - 1].depth >= node.depth) { | |
nodeStack.pop() | |
} | |
nodeStack[nodeStack.length - 1].children.push(node) | |
node.parent = nodeStack[nodeStack.length - 1] | |
nodeStack.push(node) | |
}) | |
this.root = nodeStack[0] | |
this.offsetCacheVersion = offsetCacheVersion | |
} | |
getRoot() { | |
return this.root | |
} | |
markOffsetCacheStale() { | |
this.offsetCacheVersion++ | |
} | |
traverseInPreorder(f: (h: HeadingNode) => TraverseResult) { | |
const visitChildren = (h: HeadingNode): TraverseResult => { | |
for (const child of h.children) { | |
if (visit(child) === TraverseResult.Stop) { | |
return TraverseResult.Stop | |
} | |
} | |
return TraverseResult.Continue | |
} | |
const visit = (h: HeadingNode): TraverseResult => { | |
h.lazyLoad(this.offsetCacheVersion) | |
const res = f(h) | |
if (res !== TraverseResult.Continue) { | |
return res | |
} | |
return visitChildren(h) | |
} | |
visitChildren(this.root) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/components/toc/index.ts | |
export { default } from "./component" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/utils/mediaQuery.js | |
import startCase from "lodash/startCase" | |
const min = width => `only screen and (min-width: ${width}px)` | |
const max = width => `only screen and (max-width: ${width - 1}px)` | |
const mediaQuery = { | |
screens: { | |
// values are in pixels | |
small: 576, | |
medium: 768, | |
large: 992, | |
}, | |
} | |
for (const key of Object.keys(mediaQuery.screens)) { | |
const Key = startCase(key) | |
for (const [func, name] of [ | |
[min, `min`], | |
[max, `max`], | |
]) { | |
// css query | |
const query = func(mediaQuery.screens[key]) | |
mediaQuery[name + Key] = `@media ` + query | |
// js query (see window.matchMedia) | |
mediaQuery[name + Key + `Js`] = query | |
} | |
} | |
export default mediaQuery |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/utils/styled.tsx | |
import styled from "@emotion/styled" | |
import { ITheme } from "./theme" | |
export default styled | |
export interface IProps { | |
theme: ITheme | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/components/toc/styles.ts | |
import styled, { IProps } from "utils/styled" | |
import { css } from "@emotion/react" | |
import { FaList as BookContentIcon } from "react-icons/fa" | |
import { FaTimes as CrossIcon } from "react-icons/fa" | |
import mediaQuery from "utils/mediaQuery" | |
import { ITocListProps, ITocToggleProps } from "./types" | |
const openTocDiv = (props: IProps) => css` | |
background: ${props.theme.colors.background}; | |
color: ${props.theme.colors.text.primary}; | |
padding: ${props.theme.space[3]} ${props.theme.space[5]}; | |
border-radius: ${props.theme.space[2]}; | |
box-shadow: 0 0 1em rgba(0, 0, 0, 0.5); | |
border: 1px solid ${props.theme.colors.ui.border}; | |
` | |
export const TocDiv = styled.div<ITocToggleProps>` | |
height: max-content; | |
z-index: 3; | |
line-height: 2em; | |
right: ${(props) => props.theme.space[4]}; | |
margin-top: ${(props) => props.theme.space[3]}; | |
${mediaQuery.maxMedium} { | |
overscroll-behavior: none; | |
nav { | |
max-height: 60vh; | |
overflow-y: scroll; | |
} | |
position: fixed; | |
bottom: 1em; | |
left: 1em; | |
visibility: ${(props) => (props.open ? `visible` : `hidden`)}; | |
opacity: ${(props) => (props.open ? 1 : 0)}; | |
transition: 0.3s; | |
background: white; | |
max-width: 20em; | |
${(props) => (props.open ? openTocDiv : `height: 0;`)} | |
} | |
${mediaQuery.minMedium} { | |
font-size: ${(props) => props.theme.fontSizes[1]}; | |
position: sticky; | |
top: ${(props) => props.theme.space[7]}; | |
padding-left: ${(props) => props.theme.space[4]}; | |
max-width: 100%; | |
} | |
` | |
export const TocTitle = styled.p` | |
margin: 0; | |
padding-bottom: ${(props) => props.theme.space[2]}; | |
display: grid; | |
grid-auto-flow: column; | |
grid-template-columns: auto auto 1fr; | |
align-items: center; | |
font-size: ${(props) => props.theme.fontSizes[3]}; | |
font-weight: ${(props) => props.theme.fontWeights[`bold`]}; | |
font-family: ${(props) => props.theme.fonts[`heading`]}; | |
line-height: ${(props) => props.theme.lineHeights[`dense`]}; | |
` | |
export const TocLink = styled.a` | |
font-weight: ${(props: ITocListProps) => props.active && `bold`}; | |
display: block; | |
box-shadow: none; | |
` | |
const listItemShiftWidthEm = 1.5 | |
const bulletRadiusPx = 4 | |
export const TocListBullet = styled.span` | |
position: absolute; | |
border-color: #f0f0f2; | |
border-width: 1px; | |
border-style: solid; | |
border-radius: ${bulletRadiusPx}px; | |
background-color: ${(props: ITocListProps) => (props.active ? `rgba(34, 162, 201, 1);` : `#ffffff`)}; | |
width: ${bulletRadiusPx * 2}px; | |
height: ${bulletRadiusPx * 2}px; | |
content: ""; | |
position: absolute; | |
display: block; | |
z-index: 999; | |
top: calc(50% - ${bulletRadiusPx}px); | |
left: calc(${(props: ITocListProps) => `${(props.depth + 1 - 1) * listItemShiftWidthEm}em`} - ${bulletRadiusPx}px); | |
` | |
export const TocListItem = styled.li` | |
margin: 0; | |
list-style: none; | |
/* You need to turn on relative positioning so the line is placed relative to the item rather than absolutely on the page */ | |
position: relative; | |
/* Use padding to space things out rather than margins as the line would get broken up otherwise */ | |
padding-left: ${(props: ITocListProps) => `${(props.depth + 1) * listItemShiftWidthEm}em`}; | |
&::before { | |
background-color: ${(props) => props.theme.colors.palette.grey[20]}; | |
width: 1px; | |
content: ""; | |
position: absolute; | |
top: 0px; | |
bottom: 0px; | |
left: ${(props: ITocListProps) => `${(props.depth + 1 - 1) * listItemShiftWidthEm}em`}; | |
} | |
&:hover { | |
background-color: ${(props) => props.theme.colors.primaryShades[20]}; | |
} | |
` | |
export const TocIcon = styled(BookContentIcon)` | |
width: 1em; | |
margin-right: ${(props) => props.theme.space[1]}; | |
` | |
const openerToggleCss = (props: ITocToggleProps & IProps) => css` | |
position: fixed; | |
bottom: calc(1vh + 4em); | |
left: 0; | |
margin-left: ${props.theme.space[2]}; | |
transform: translate(${props.open ? `-100%` : 0}); | |
padding: ${props.theme.space[1]}; | |
border-radius: 0 50% 50% 0; | |
background: ${props.theme.colors.primary}; | |
color: ${props.theme.colors.background}; | |
` | |
const closerToggleCss = () => css` | |
margin-left: 1em; | |
padding: 2px; | |
border-radius: 0 50% 50% 0; | |
` | |
const toggleCss = () => css` | |
width: 1.6em; | |
height: auto; | |
z-index: 2; | |
transition: 0.3s; | |
justify-self: end; | |
&:hover { | |
transform: scale(1.1); | |
} | |
${mediaQuery.minMedium} { | |
display: none; | |
} | |
` | |
export const TocToggleOpener = styled(BookContentIcon)` | |
${toggleCss} | |
${openerToggleCss} | |
` | |
export const TocToggleCloser = styled(CrossIcon)` | |
${toggleCss} | |
${closerToggleCss} | |
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/utils/theme.ts | |
import merge from "deepmerge" | |
import { toTheme } from "@theme-ui/typography" | |
import { theme as typographyTheme } from "./typography" | |
const shadowDarkBase = `19,18,23` | |
const shadowDarkFlares = `0,0,0` | |
interface IColorShades { | |
[shade: number]: string | |
} | |
interface IPalette { | |
[color: string]: IColorShades | |
} | |
const palette: IPalette = { | |
purple: { | |
90: "#362066", | |
80: "#452475", | |
70: "#542c85", | |
60: "#663399", | |
50: "#8a4baf", | |
40: "#b17acc", | |
30: "#d9bae8", | |
20: "#f1defa", | |
10: "#f6edfa", | |
5: "#fcfaff", | |
}, | |
orange: { | |
90: "#db3a00", | |
80: "#e65800", | |
70: "#f67300", | |
60: "#fb8400", | |
50: "#ffb238", | |
40: "#ffd280", | |
30: "#ffe4a1", | |
20: "#ffedbf", | |
10: "#fff4db", | |
5: "#fffcf7", | |
}, | |
yellow: { | |
90: "#8a6534", | |
80: "#bf9141", | |
70: "#e3a617", | |
60: "#fec21e", | |
50: "#fed038", | |
40: "#ffdf37", | |
30: "#ffeb99", | |
20: "#fff2a8", | |
10: "#fff5bf", | |
5: "#fffdf7", | |
}, | |
red: { | |
90: "#b80000", | |
80: "#ce0009", | |
70: "#da0013", | |
60: "#ec1818", | |
50: "#fa2915", | |
40: "#ff5a54", | |
30: "#ff8885", | |
20: "#ffbab8", | |
10: "#fde7e7", | |
5: "#fffafa", | |
}, | |
magenta: { | |
90: "#690147", | |
80: "#7d0e59", | |
70: "#940159", | |
60: "#a6026a", | |
50: "#bc027f", | |
40: "#d459ab", | |
30: "#e899ce", | |
20: "#f2c4e3", | |
10: "#ffe6f6", | |
5: "#fffafd", | |
}, | |
blue: { | |
90: "#004ca3", | |
80: "#006ac1", | |
70: "#047bd3", | |
60: "#0e8de6", | |
50: "#0d96f2", | |
40: "#3fa9f5", | |
30: "#63b8f6", | |
20: "#90cdf9", | |
10: "#dbf0ff", | |
5: "#f5fcff", | |
}, | |
teal: { | |
90: "#008577", | |
80: "#10a39e", | |
70: "#00bdb6", | |
60: "#2de3da", | |
50: "#05f7f4", | |
40: "#73fff7", | |
30: "#a6fffa", | |
20: "#ccfffc", | |
10: "#dcfffd", | |
5: "#f7ffff", | |
}, | |
green: { | |
90: "#006500", | |
80: "#088413", | |
70: "#1d9520", | |
60: "#2ca72c", | |
50: "#37b635", | |
40: "#59c156", | |
30: "#79cd75", | |
20: "#a1da9e", | |
10: "#def5dc", | |
5: "#f7fdf7", | |
}, | |
grey: { | |
90: "#232129", | |
80: "#36313d", | |
70: "#48434f", | |
60: "#635e69", | |
50: "#78757a", | |
40: "#b7b5bd", | |
30: "#d9d7e0", | |
20: "#f0f0f2", | |
10: "#f5f5f5", | |
5: "#fbfbfb", | |
}, | |
white: "#ffffff", | |
black: "#000000", | |
} | |
const blackRGB = "35, 33, 41" // grey.90 | |
const whiteRGB = "255, 255, 255" | |
interface IStringCssProps { | |
[name: string]: string | |
} | |
interface ITransition { | |
default: string | |
curve: IStringCssProps | |
speed: IStringCssProps | |
} | |
const transition: ITransition = { | |
default: `250ms cubic-bezier(0.4, 0, 0.2, 1)`, | |
curve: { | |
default: `cubic-bezier(0.4, 0, 0.2, 1)`, | |
fastOutLinearIn: `cubic-bezier(0.4, 0, 1, 1)`, | |
}, | |
speed: { | |
faster: `50ms`, | |
fast: `100ms`, | |
default: `250ms`, | |
slow: `500ms`, | |
slower: `1000ms`, | |
}, | |
} | |
interface IColors { | |
newsletter: IStringCssProps | |
textMuted: string | |
warning: string | |
background: string | |
primary: string | |
primaryShades: { | |
[shade: number]: string | |
} | |
secondary: string | |
blackFade: IColorShades | |
whiteFade: IColorShades | |
palette: IPalette | |
input: IStringCssProps | |
text: { | |
primary: string | |
placeholder: string | |
} | |
ui: { | |
border: string | |
} | |
} | |
const hex2rgba = (hex: string, alpha: number) => { | |
const [r, g, b] = hex.match(/\w\w/g).map((x) => parseInt(x, 16)) | |
return `rgba(${r},${g},${b},${alpha})` | |
} | |
const primaryColor = `#007acc` | |
const colors: IColors = { | |
newsletter: { | |
background: `white`, | |
border: `#f5f5f5`, | |
heading: `#48434f`, | |
stripeColorA: `#ff5a54`, | |
stripeColorB: `#3fa9f5`, | |
}, | |
textMuted: `#78757a`, | |
warning: `#da0013`, | |
background: "#fff", | |
primary: primaryColor, | |
primaryShades: { | |
20: hex2rgba(primaryColor, 0.2), | |
}, | |
secondary: `#da0013`, | |
blackFade: { | |
90: "rgba(" + blackRGB + ", 0.9)", | |
80: "rgba(" + blackRGB + ", 0.8)", | |
70: "rgba(" + blackRGB + ", 0.7)", | |
60: "rgba(" + blackRGB + ", 0.6)", | |
50: "rgba(" + blackRGB + ", 0.5)", | |
40: "rgba(" + blackRGB + ", 0.4)", | |
30: "rgba(" + blackRGB + ", 0.3)", | |
20: "rgba(" + blackRGB + ", 0.2)", | |
10: "rgba(" + blackRGB + ", 0.1)", | |
5: "rgba(" + blackRGB + ", 0.05)", | |
}, | |
whiteFade: { | |
90: "rgba(" + whiteRGB + ", 0.9)", | |
80: "rgba(" + whiteRGB + ", 0.8)", | |
70: "rgba(" + whiteRGB + ", 0.7)", | |
60: "rgba(" + whiteRGB + ", 0.6)", | |
50: "rgba(" + whiteRGB + ", 0.5)", | |
40: "rgba(" + whiteRGB + ", 0.4)", | |
30: "rgba(" + whiteRGB + ", 0.3)", | |
20: "rgba(" + whiteRGB + ", 0.2)", | |
10: "rgba(" + whiteRGB + ", 0.1)", | |
5: "rgba(" + whiteRGB + ", 0.05)", | |
}, | |
palette, | |
input: { | |
border: palette.grey[30], | |
focusBorder: palette.orange[40], | |
focusBoxShadow: palette.orange[20], | |
}, | |
text: { | |
primary: "#000", | |
placeholder: palette.grey[40], | |
}, | |
ui: { | |
border: palette.grey[20], | |
}, | |
} | |
interface ILineHeights { | |
solid: number | |
dense: number | |
loose: number | |
} | |
const lineHeights: ILineHeights = { | |
solid: 1, | |
dense: 1.25, | |
loose: 1.75, | |
} | |
export interface ITheme { | |
colors: IColors | |
lineHeights: ILineHeights | |
shadows: IStringCssProps | |
radii: string[] | |
buttons: any | |
forms: any | |
styles: any | |
space: string[] | |
fonts: { | |
body?: string | |
heading?: string | |
} | |
fontSizes: string[] | |
fontWeights: string[] | |
} | |
const localTheme: ITheme = { | |
colors, | |
lineHeights, | |
shadows: { | |
dialog: `0px 4px 16px rgba(${shadowDarkBase}, 0.08), 0px 8px 24px rgba(${shadowDarkFlares}, 0.16)`, | |
floating: `0px 2px 4px rgba(${shadowDarkBase}, 0.08), 0px 4px 8px rgba(${shadowDarkFlares}, 0.16)`, | |
overlay: `0px 4px 8px rgba(${shadowDarkBase}, 0.08), 0px 8px 16px rgba(${shadowDarkFlares}, 0.16)`, | |
raised: `0px 1px 2px rgba(${shadowDarkBase}, 0.08), 0px 2px 4px rgba(${shadowDarkFlares}, 0.08)`, | |
}, | |
radii: [`0`, `2px`, `4px`, `8px`, `16px`], | |
buttons: { | |
primary: { | |
borderRadius: 2, | |
borderWidth: 1, | |
color: "background", | |
bg: "primary", | |
"&:hover": { | |
bg: palette.blue[90], | |
}, | |
cursor: `pointer`, | |
fontFamily: `heading`, | |
fontWeight: `bold`, | |
fontSize: 1, | |
lineHeight: `solid`, | |
textDecoration: `none`, | |
whiteSpace: `nowrap`, | |
px: 3, | |
height: `36px`, | |
}, | |
secondary: { | |
color: "background", | |
bg: "secondary", | |
}, | |
}, | |
forms: { | |
label: { | |
fontSize: 1, | |
fontWeight: "bold", | |
}, | |
input: { | |
backgroundColor: palette.white, | |
display: `block`, | |
fontSize: 1, | |
fontWeight: `body`, | |
lineHeight: `2.25rem`, | |
py: 0, | |
px: 2, | |
verticalAlign: `middle`, | |
width: `100%`, | |
border: `1px solid ${colors.input.border}`, | |
borderRadius: 2, | |
transition: `box-shadow ${transition.speed.default} ${transition.curve.default}`, | |
"&:focus": { | |
borderColor: "primary", | |
boxShadow: (t) => `0 0 0 2px ${t.colors.primary}`, | |
outline: "none", | |
}, | |
"::placeholder": { | |
color: colors.text.placeholder, | |
opacity: 1, | |
}, | |
"&:disabled": { | |
cursor: `not-allowed`, | |
opacity: `0.5`, | |
}, | |
}, | |
select: { | |
borderColor: "gray", | |
"&:focus": { | |
borderColor: "primary", | |
boxShadow: (t) => `0 0 0 2px ${t.colors.primary}`, | |
outline: "none", | |
}, | |
}, | |
textarea: { | |
borderColor: "gray", | |
"&:focus": { | |
borderColor: "primary", | |
boxShadow: (t) => `0 0 0 2px ${t.colors.primary}`, | |
outline: "none", | |
}, | |
}, | |
slider: { | |
bg: "muted", | |
}, | |
}, | |
styles: { | |
root: { | |
pre: { | |
borderRadius: 0, | |
}, | |
"a.gatsby-resp-image-link": { | |
boxShadow: `none`, | |
}, | |
a: { | |
boxShadow: "0 1px 0 0 currentColor", | |
color: "primary", | |
textDecoration: "none", | |
}, | |
"a:hover,a:active": { | |
textDecoration: "none", | |
boxShadow: "none", | |
}, | |
"a.anchor": { | |
textDecoration: "none", | |
boxShadow: "none", | |
}, | |
}, | |
}, | |
space: [], | |
fonts: {}, | |
fontSizes: [], | |
fontWeights: [], | |
} | |
const uiTypographyTheme = toTheme(typographyTheme) | |
const resultTheme: ITheme = merge(uiTypographyTheme, localTheme) | |
resultTheme.space = [`0rem`, `0.25rem`, `0.5rem`, `0.75rem`, `1rem`, `1.25rem`, `1.5rem`, `2rem`, `2.5rem`, `3rem`] | |
resultTheme.fontSizes = [ | |
`0.75rem`, | |
`0.875rem`, | |
`1rem`, | |
`1.125rem`, | |
`1.25rem`, | |
`1.5rem`, | |
`1.75rem`, | |
`2rem`, | |
`2.25rem`, | |
`2.625rem`, | |
] | |
console.info(`theme:`, resultTheme) | |
export default resultTheme |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/components/toc/types.ts | |
export interface ITocListProps { | |
active: boolean | |
depth: number | |
} | |
export interface ITocToggleProps { | |
open: boolean | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/utils/typography.js | |
import Typography from "typography" | |
import Theme from "typography-theme-sutro" | |
import CodePlugin from "typography-plugin-code" | |
// https://github.com/KyleAMathews/typography.js/blob/master/packages/typography-plugin-code/src/index.js | |
Theme.plugins = [new CodePlugin()] | |
// We fetch them with font-display: swap by web-font-loader, | |
// see https://github.com/KyleAMathews/typography.js/issues/211. | |
delete Theme.googleFonts | |
const typography = new Typography(Theme) | |
// Hot reload typography in development. | |
if (process.env.NODE_ENV !== `production`) { | |
typography.injectStyles() | |
} | |
export default typography | |
export const rhythm = typography.rhythm | |
export const scale = typography.scale | |
export const theme = Theme |
What does your utils/styled
file look like? I can't find any examples online for that and there isn't a file here
@blakermchale sorry for late response - I've added all missed utils/*
files
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Awesome!! you are a live saver.