Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@jirfag
Last active April 10, 2022 18:33
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jirfag/290a5a44f90ff67884843af2b70cb60f to your computer and use it in GitHub Desktop.
Save jirfag/290a5a44f90ff67884843af2b70cb60f to your computer and use it in GitHub Desktop.
React/Gatsby Table of Contents
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>
</>
)
}
}
// src/utils/emotion.d.ts
import "@emotion/react"
import { ITheme } from "./theme"
declare module "@emotion/react" {
export interface Theme extends ITheme {}
}
// 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
}
}
// 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)
}
}
// src/components/toc/index.ts
export { default } from "./component"
// 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
// src/utils/styled.tsx
import styled from "@emotion/styled"
import { ITheme } from "./theme"
export default styled
export interface IProps {
theme: ITheme
}
// 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}
`
// 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
// src/components/toc/types.ts
export interface ITocListProps {
active: boolean
depth: number
}
export interface ITocToggleProps {
open: boolean
}
// 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
@Cyrus-Kiprop
Copy link

Awesome!! you are a live saver.

@blakermchale
Copy link

What does your utils/styled file look like? I can't find any examples online for that and there isn't a file here

@jirfag
Copy link
Author

jirfag commented Apr 10, 2022

@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