diff --git a/.eslintrc b/.eslintrc index e8f158be..3e483b49 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,6 +6,12 @@ "mskelton/no-unescaped-quotes": "error" }, "overrides": [ + { + "files": "config/**", + "rules": { + "sort/object-properties": "off" + } + }, { "files": "e2e/**", "extends": "@mskelton/eslint-config/playwright" diff --git a/app/(main)/blog/components/CodeBlock.tsx b/app/(main)/blog/components/CodeBlock.tsx new file mode 100644 index 00000000..5a247cff --- /dev/null +++ b/app/(main)/blog/components/CodeBlock.tsx @@ -0,0 +1,8 @@ +export interface CodeBlockProps { + children: React.ReactElement + filename: string +} + +export default function CodeBlock({ children }: CodeBlockProps) { + return children +} diff --git a/app/(main)/blog/components/Demo/CopyCodeButton.tsx b/app/(main)/blog/components/Demo/CopyCodeButton.tsx new file mode 100644 index 00000000..25fdcbca --- /dev/null +++ b/app/(main)/blog/components/Demo/CopyCodeButton.tsx @@ -0,0 +1,32 @@ +"use client" + +import { + ClipboardDocumentCheckIcon, + ClipboardDocumentListIcon, +} from "@heroicons/react/24/outline" +import React, { useState } from "react" +import DemoToolbarButton from "./DemoToolbarButton" + +export interface CopyCodeButtonProps { + raw: string +} + +export default function CopyCodeButton({ raw }: CopyCodeButtonProps) { + const [copied, setCopied] = useState(false) + const Icon = copied ? ClipboardDocumentCheckIcon : ClipboardDocumentListIcon + + function handleCopy() { + setCopied(true) + navigator.clipboard.writeText(raw) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + ) +} diff --git a/app/(main)/blog/components/Demo/Demo.tsx b/app/(main)/blog/components/Demo/Demo.tsx new file mode 100644 index 00000000..293bc056 --- /dev/null +++ b/app/(main)/blog/components/Demo/Demo.tsx @@ -0,0 +1,65 @@ +"use client" + +import clsx from "clsx" +import { useRef, useState } from "react" +import DemoToolbar from "./DemoToolbar" + +export interface DemoProps { + center?: boolean + children: string + component: React.ReactNode + name: string + raw: string +} + +export default function Demo({ + center = false, + children: source, + component, + name, + raw, +}: DemoProps) { + const focusRef = useRef(null) + const [reset, setReset] = useState(0) + const [isExpanded, setIsExpanded] = useState(false) + + return ( +
+
+ ) +} diff --git a/app/(main)/blog/components/Demo/DemoToolbar.tsx b/app/(main)/blog/components/Demo/DemoToolbar.tsx new file mode 100644 index 00000000..520d6caa --- /dev/null +++ b/app/(main)/blog/components/Demo/DemoToolbar.tsx @@ -0,0 +1,56 @@ +import { ViewfinderCircleIcon } from "@heroicons/react/24/outline" +import { GitHubIcon } from "components/SocialIcons" +import { TooltipGroup } from "components/Tooltip" +import CopyCodeButton from "./CopyCodeButton" +import DemoToolbarButton from "./DemoToolbarButton" +import ExpandCodeButton from "./ExpandCodeButton" +import ResetDemoButton from "./ResetDemoButton" + +const gh = (path: string) => + `https://github.com/mskelton/mskelton.dev/blob/main/app/(main)/blog/posts/${path}` + +export interface DemoToolbarProps { + isExpanded: boolean + onFocusReset: () => void + onReset: () => void + onToggleExpanded: () => void + path: string + raw: string +} + +export default function DemoToolbar({ + isExpanded, + onFocusReset, + onReset, + onToggleExpanded, + path, + raw, +}: DemoToolbarProps) { + return ( +
+
+ + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/app/(main)/blog/components/Demo/DemoToolbarButton.tsx b/app/(main)/blog/components/Demo/DemoToolbarButton.tsx new file mode 100644 index 00000000..e467db8e --- /dev/null +++ b/app/(main)/blog/components/Demo/DemoToolbarButton.tsx @@ -0,0 +1,39 @@ +import clsx from "clsx" +import { cloneElement } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip" + +export interface DemoToolbarButtonProps { + children: React.ReactElement + href?: string + onClick?: () => void + title: string +} + +export default function DemoToolbarButton({ + children, + href, + onClick, + title, +}: DemoToolbarButtonProps) { + const Component = href ? "a" : "button" + + return ( + + + + {cloneElement(children, { + className: clsx("w-5 h-5", children.props.className), + })} + + + {title} + + ) +} diff --git a/app/(main)/blog/components/Demo/ExpandCodeButton.tsx b/app/(main)/blog/components/Demo/ExpandCodeButton.tsx new file mode 100644 index 00000000..b2082ce4 --- /dev/null +++ b/app/(main)/blog/components/Demo/ExpandCodeButton.tsx @@ -0,0 +1,52 @@ +import React from "react" +import DemoToolbarButton from "./DemoToolbarButton" + +const props = { + className: "transition-[d]", + strokeLinecap: "round", + strokeLinejoin: "round", + strokeWidth: "1.5", +} as const + +export interface ExpandCodeButtonProps { + isExpanded: boolean + onToggleExpanded: () => void +} + +export default function ExpandCodeButton({ + isExpanded, + onToggleExpanded, +}: ExpandCodeButtonProps) { + return ( + + + + ) +} diff --git a/app/(main)/blog/components/Demo/ResetDemoButton.tsx b/app/(main)/blog/components/Demo/ResetDemoButton.tsx new file mode 100644 index 00000000..57339c78 --- /dev/null +++ b/app/(main)/blog/components/Demo/ResetDemoButton.tsx @@ -0,0 +1,25 @@ +import { ArrowPathIcon } from "@heroicons/react/24/outline" +import React, { useState } from "react" +import DemoToolbarButton from "./DemoToolbarButton" + +export interface ResetDemoButtonProps { + onReset: () => void +} + +export default function ResetDemoButton({ onReset }: ResetDemoButtonProps) { + const [key, setKey] = useState() + + function handleReset() { + setKey((key ?? 0) ^ 1) + onReset() + } + + return ( + + + + ) +} diff --git a/app/(main)/blog/components/Demo/index.ts b/app/(main)/blog/components/Demo/index.ts new file mode 100644 index 00000000..77373d08 --- /dev/null +++ b/app/(main)/blog/components/Demo/index.ts @@ -0,0 +1 @@ +export { default } from "./Demo" diff --git a/app/(main)/blog/posts/css-scroll-animations.draft/BasicScroll.css b/app/(main)/blog/posts/css-scroll-animations.draft/BasicScroll.css new file mode 100644 index 00000000..488bd81d --- /dev/null +++ b/app/(main)/blog/posts/css-scroll-animations.draft/BasicScroll.css @@ -0,0 +1,36 @@ +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.scroll-parent { + height: 320px; + overflow-y: scroll; + position: relative; +} + +@media (pointer: fine) { + .scroll-parent { + overscroll-behavior: contain; + } +} + +.scroll-area { + height: 1200px; +} + +.box { + --size: 120px; + animation: rotate linear; + animation-duration: 1ms; /* Firefox requires this to apply the animation */ + animation-timeline: scroll(); + aspect-ratio: 1; + left: calc(50% - var(--size) / 2); + position: sticky; + top: calc(50% - var(--size) / 2); + width: var(--size); +} diff --git a/app/(main)/blog/posts/css-scroll-animations.draft/BasicScroll.tsx b/app/(main)/blog/posts/css-scroll-animations.draft/BasicScroll.tsx new file mode 100644 index 00000000..c8c50994 --- /dev/null +++ b/app/(main)/blog/posts/css-scroll-animations.draft/BasicScroll.tsx @@ -0,0 +1,11 @@ +import "./BasicScroll.css" + +export default function Component() { + return ( +
+
+
+
+
+ ) +} diff --git a/app/(main)/blog/posts/css-scroll-animations.draft/content.mdx b/app/(main)/blog/posts/css-scroll-animations.draft/content.mdx new file mode 100644 index 00000000..fe3edbb0 --- /dev/null +++ b/app/(main)/blog/posts/css-scroll-animations.draft/content.mdx @@ -0,0 +1,52 @@ +--- +title: CSS Scroll Animations +description: + Scroll animations is an extremely exciting feature that is coming to CSS. Join + me as we explore how to use scroll animations to create performant and well + polished user experiences. +date: "2023-09-29" +--- + +import CodeBlock from "../../components/CodeBlock" +import Demo from "../../components/Demo" +import BasicScroll from "./BasicScroll" + +In a recent project at work, I started exploring CSS scroll animations to +transition elements in a sticky header between a larger and smaller size. After +just a couple hours of exploration I had a working prototype using scroll +animations to replace the much less reliable and smooth JavaScript based +animation. + +Having just scratched the surface of what is possible with scroll animations, I +knew a blog post was in order to show off some of the incredible features of +scroll animations. + +> [!WARN Experimental features ahead] +> CSS scroll animations are still experimental. At the time of writing, support +> for scroll animations is primarily in Chromium with Firefox supporting them +> behind a flag. Safari unfortunately does not support them at all. For more +> details, checkout the +> [MDN compatibility table](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline#browser_compatibility). + +Let's start with a rather simple example showing a blue box that rotates as it's +container scrolls. Try scrolling the container below to see it rotate! + + + + + +To accomplish this lovely animation, we only need to add 3 lines of CSS. + + + +We specify the `animation` just like we would with any other CSS animation by +referencing a set of keyframes (e.g. `rotate`), and an easing function (e.g. +`linear`). What is missing from our `animation` property is a duration which is +required when using standard CSS animations. This is omitted because a duration +does not make sense when the animation is linked to scroll progress. + +> [!ERROR Firefox bug] +> To make this work properly in Firefox, we need to add an `animation-duration` +> which we can set to `1ms`. The value doesn't actually matter, but Firefox +> needs the property for the animation to work. Hopefully this will be fixed as +> Firefox support for scroll animations continues to improve. diff --git a/app/(main)/bytes/api.ts b/app/(main)/bytes/api.ts index 75b7e2a0..780d7867 100644 --- a/app/(main)/bytes/api.ts +++ b/app/(main)/bytes/api.ts @@ -1,4 +1,3 @@ -import rehypeShiki from "@stefanprobst/rehype-shiki" import { notFound } from "next/navigation" import { compileMDX } from "next-mdx-remote/rsc" import { cache } from "react" @@ -16,7 +15,7 @@ import rehypeCodeMeta from "../../../config/rehype-code-meta.mjs" import rehypeCodeTitles from "../../../config/rehype-code-titles.mjs" import rehypeHeaderId from "../../../config/rehype-header-id.mjs" import rehypeHeadings from "../../../config/rehype-headings.mjs" -import remarkCodeMeta from "../../../config/remark-code-meta.mjs" +import rehypeShiki from "../../../config/rehype-shiki.mjs" import { ByteMeta } from "./types" // Revalidate the data at most every hour @@ -37,7 +36,7 @@ export const getByte = cache(async (slug: string) => { components: { a: MarkdownLink, img: MarkdownImage, - pre: MarkdownPre, + pre: MarkdownPre as any, }, options: { mdxOptions: { @@ -47,11 +46,11 @@ export const getByte = cache(async (slug: string) => { rehypeHeaderId, rehypeCodeTitles, [rehypeShiki, { highlighter: await getHighlighter() }], - rehypeCodeMeta, rehypeCodeA11y, + rehypeCodeMeta, rehypeCallout, ], - remarkPlugins: [remarkGfm, remarkSmartypants as any, remarkCodeMeta], + remarkPlugins: [remarkGfm, remarkSmartypants as any], }, }, source: byte.content, diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx new file mode 100644 index 00000000..42d2e4ab --- /dev/null +++ b/app/components/Tooltip.tsx @@ -0,0 +1,186 @@ +"use client" + +import { + autoUpdate, + flip, + FloatingDelayGroup, + FloatingPortal, + offset, + shift, + useDelayGroup, + useDelayGroupContext, + useDismiss, + useFloating, + useFocus, + useHover, + useId, + useInteractions, + useMergeRefs, + useRole, + useTransitionStyles, +} from "@floating-ui/react" +import type { Placement } from "@floating-ui/react" +import React, { + cloneElement, + createContext, + useContext, + useMemo, + useState, +} from "react" + +export interface TooltipGroupProps { + children: React.ReactNode +} + +export function TooltipGroup({ children }: TooltipGroupProps) { + return ( + + {children} + + ) +} + +interface UseTooltipProps { + initialOpen?: boolean + onOpenChange?: (open: boolean) => void + open?: boolean + placement?: Placement +} + +export function useTooltip({ + initialOpen = false, + onOpenChange: setControlledOpen, + open: controlledOpen, + placement = "top", +}: UseTooltipProps = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen) + + const open = controlledOpen ?? uncontrolledOpen + const setOpen = setControlledOpen ?? setUncontrolledOpen + + const { delay } = useDelayGroupContext() + + const data = useFloating({ + middleware: [offset(5), flip(), shift()], + onOpenChange: setOpen, + open, + placement, + whileElementsMounted: autoUpdate, + }) + + const context = data.context + + const hover = useHover(context, { + delay, + enabled: controlledOpen == null, + move: false, + }) + const focus = useFocus(context, { enabled: controlledOpen == null }) + const dismiss = useDismiss(context) + const role = useRole(context, { role: "tooltip" }) + const interactions = useInteractions([hover, focus, dismiss, role]) + + return useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data], + ) +} + +type TooltipContextValue = ReturnType | null + +const TooltipContext = createContext(null) + +export const useTooltipState = () => { + const context = useContext(TooltipContext) + + if (context == null) { + throw new Error("Tooltip components must be wrapped in ") + } + + return context +} + +export interface TooltipProps extends UseTooltipProps { + children: React.ReactNode +} + +export function Tooltip({ children, ...options }: TooltipProps) { + const tooltip = useTooltip(options) + + return ( + + {children} + + ) +} + +export interface TooltipTriggerProps { + children: React.ReactElement +} + +export function TooltipTrigger({ children }: TooltipTriggerProps) { + const state = useTooltipState() + + const childrenRef = (children as any).ref + const ref = useMergeRefs([state.refs.setReference, childrenRef]) + + return cloneElement( + children, + state.getReferenceProps({ ref, ...children.props }), + ) +} + +export interface TooltipContentProps + extends React.HTMLAttributes {} + +export function TooltipContent({ + children, + style, + ...props +}: TooltipContentProps) { + const state = useTooltipState() + const id = useId() + const { currentId, isInstantPhase } = useDelayGroupContext() + + useDelayGroup(state.context, { id }) + + const instantDuration = 0 + const duration = 250 + + const { isMounted, styles } = useTransitionStyles(state.context, { + duration: isInstantPhase + ? { + // `id` is this component's `id` + // `currentId` is the current group's `id` + close: currentId === id ? duration : instantDuration, + open: instantDuration, + } + : duration, + initial: { + opacity: 0, + transform: "scale(0.8)", + }, + }) + + return isMounted ? ( + +
+
+ {children} +
+
+
+ ) : null +} diff --git a/app/lib/posts.ts b/app/lib/posts.ts index 67bfe704..dd2fa290 100644 --- a/app/lib/posts.ts +++ b/app/lib/posts.ts @@ -24,7 +24,9 @@ export async function getAllPostSlugs() { const cwd = path.join(process.cwd(), "app/(main)/blog/posts") const filenames = await glob("*/content.mdx", { cwd }) - return filenames.map((file) => path.basename(path.dirname(file))) + return filenames + .map((file) => path.basename(path.dirname(file))) + .filter((slug) => !slug.endsWith(".draft")) } export async function getAllPosts() { diff --git a/app/lib/themeEffect.ts b/app/lib/themeEffect.ts index dec365ca..d8d79c00 100644 --- a/app/lib/themeEffect.ts +++ b/app/lib/themeEffect.ts @@ -7,6 +7,7 @@ export const themeEffect = function () { pref === "dark" || (!pref && window.matchMedia("(prefers-color-scheme: dark)").matches) ) { + // TODO: Add [color-scheme:dark] d.classList.add("dark") result = "dark" } else { diff --git a/app/styles/tailwind.css b/app/styles/tailwind.css index c438147c..34b455b7 100644 --- a/app/styles/tailwind.css +++ b/app/styles/tailwind.css @@ -13,14 +13,17 @@ } [data-rmiz-ghost] { - position: absolute; pointer-events: none; + position: absolute; } [data-rmiz-btn-zoom] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; background-color: rgba(0, 0, 0, 0.7); - border-radius: 50%; border: none; + border-radius: 50%; box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); color: #fff; height: 40px; @@ -29,9 +32,6 @@ padding: 9px; touch-action: manipulation; width: 40px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; } [data-rmiz-btn-zoom]:not(:focus):not(:active) { @@ -46,9 +46,9 @@ } [data-rmiz-btn-zoom] { - position: absolute; - inset: 10px 10px auto auto; cursor: zoom-in; + inset: 10px 10px auto auto; + position: absolute; } [data-rmiz-content="found"] img, @@ -67,23 +67,23 @@ } [data-rmiz-modal][open] { - position: fixed; - width: 100vw; - width: 100dvw; + background: transparent; + border: 0; height: 100vh; height: 100dvh; - max-width: none; - max-height: none; margin: 0; - padding: 0; - border: 0; - background: transparent; + max-height: none; + max-width: none; overflow: hidden; + padding: 0; + position: fixed; + width: 100vw; + width: 100dvw; } [data-rmiz-modal-overlay] { - position: absolute; inset: 0; + position: absolute; transition: background-color 0.3s; } @@ -104,18 +104,18 @@ } [data-rmiz-modal-content] { + height: 100%; position: relative; width: 100%; - height: 100%; } [data-rmiz-modal-img] { - position: absolute; + border-radius: 0.75rem; cursor: zoom-out; image-rendering: high-quality; + position: absolute; transform-origin: top left; transition: transform 0.3s; - border-radius: 0.75rem; } @media (prefers-reduced-motion: reduce) { diff --git a/components/markdown/MarkdownPre.tsx b/components/markdown/MarkdownPre.tsx index f7f26b72..2508ae40 100644 --- a/components/markdown/MarkdownPre.tsx +++ b/components/markdown/MarkdownPre.tsx @@ -5,12 +5,21 @@ import { ClipboardDocumentListIcon, } from "@heroicons/react/24/outline" import { clsx } from "clsx" -import React, { useRef, useState } from "react" +import React, { cloneElement, useRef, useState } from "react" -export default function MarkdownPre( - props: React.HTMLAttributes, -) { +export interface MarkdownPreProps extends React.HTMLAttributes { + children: React.ReactElement + hasFocus?: boolean +} + +export default function MarkdownPre({ + children, + className, + hasFocus, + ...props +}: MarkdownPreProps) { const preRef = useRef(null!) + const [isExpanded, setIsExpanded] = useState(false) const [copied, setCopied] = useState(false) const Icon = copied ? ClipboardDocumentCheckIcon : ClipboardDocumentListIcon @@ -28,12 +37,38 @@ export default function MarkdownPre( return ( <> -
+      {hasFocus ? (
+        
+      ) : null}
+
+      
+        {cloneElement(children, {
+          className: clsx(
+            children.props.className,
+            "grid [font-size:inherit] p-8 overflow-x-auto [font-weight:inherit] bg-transparent",
+            "focus:outline-none focus-visible:ring-inset focus-visible:ring focus-visible:ring-indigo-500",
+          ),
+        })}
+