diff --git a/src/components/Checklist/Checklist.module.css b/src/components/Checklist/Checklist.module.css new file mode 100644 index 00000000..5d904af6 --- /dev/null +++ b/src/components/Checklist/Checklist.module.css @@ -0,0 +1,375 @@ +.checklist { + margin-bottom: var(--m-3); +} + +.container { + max-width: var(--lp-section-max-width); + margin: 0 auto; + padding: 0 1rem; +} + +/* Disable animations for users with preferred reduced motion. */ +@media (prefers-reduced-motion: reduce) { + .checklist, + .checklist * { + transition: none !important; + } +} + +.steps { + display: flex; + flex-direction: column; + gap: var(--m-2); + padding: 0; + list-style: none; +} + +.step { + margin-top: 0 !important; + scroll-margin-top: calc(var(--ifm-navbar-height) + var(--m-2)); + border: 1.5px solid var(--color-tonal-neutral-1); + border-radius: var(--r-md); + background-color: var(--color-white); + + &.animate { + transition: + border-color var(--t-fast), + background-color var(--t-interaction); + } + + &.open { + border-color: var(--color-dark-purple); + } + + &:not(.open) { + &:hover { + background-color: var(--color-tonal-primary-0); + } + } + + @media (--md-scr) { + border-radius: var(--r-lg); + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--m-1-5); + width: 100%; + padding: var(--m-1-5) var(--m-2); + background: none; + border: none; + font-family: inherit; + text-align: left; + cursor: pointer; + + &.isOpen { + padding-bottom: var(--m-2); + } + + @media (--md-scr) { + gap: var(--m-2); + } +} + +.title { + display: flex; + align-items: center; + gap: var(--m-1-5); + font-size: var(--fs-text-xl); + font-weight: var(--fw-bold); + line-height: 150%; + color: var(--color-black); + + &.isOpen { + color: var(--color-dark-purple); + } + + @media (--md-scr) { + font-size: var(--fs-header-4); + } +} + +.completedMarker { + display: flex; + padding: var(--m-0-5); + border-radius: var(--r-circle); + background-color: var(--color-tonal-neutral-2); + transition: background-color var(--t-fast); + &.completed { + background-color: var(--color-interactive-success-default); + } +} + +.chevron { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: var(--r-default); + padding: var(--m-1); + + svg { + height: 16px; + transform: rotate(180deg); + transition: transform var(--t-fast); + } + + svg path { + fill: var(--color-foreground-muted); + } + + .open & svg { + transform: none; + } + + @media (--md-scr) { + padding: 10px; + + svg { + height: 20px; + } + } +} + +/* Animating grid rows between 1fr and 0fr slides the body to its natural + height. Visibility keeps collapsed content out of the tab order and the + accessibility tree, and only flips to hidden once the collapse finishes. */ +.body { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + + &.animate { + transition: + grid-template-rows var(--t-interaction) ease, + opacity var(--t-interaction) ease, + visibility var(--t-interaction); + } + + &.collapsed { + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + } +} + +.bodyInner { + min-height: 0; + overflow: hidden; + container-type: inline-size; + container-name: checklist-body; +} + +.bodyContent { + display: flex; + flex-direction: column; + gap: var(--m-2); + padding: 0 var(--m-2) var(--m-1-5) var(--m-2); + + @container checklist-body (width > 800px) { + display: block; + } +} + +.headerText { + display: flex; + flex-direction: column; + gap: var(--m-0-5); +} + +.video { + aspect-ratio: 16 / 9; + border-radius: var(--m-1); + overflow: hidden; + + @media (--md-scr) { + border-radius: var(--m-1-5); + } + @container checklist-body (width > 800px) { + float: right; + aspect-ratio: 16/9; + min-width: 50%; + margin: 0 0 var(--m-2) var(--m-2); + } +} + +.progress { + display: flex; + align-items: center; + gap: var(--m-2); + + @media (--md-scr) { + gap: var(--m-4); + } +} + +.progressTrack { + flex: 1; + height: var(--m-1-5); + + @media (--md-scr) { + height: var(--m-2); + } + border: 1px solid var(--color-tonal-primary-2); + border-radius: var(--r-circle); + background-color: var(--color-tonal-primary-0); + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 31px; + background-color: var(--color-dark-purple); + transition: width var(--t-interaction); +} + +.progressText { + flex-shrink: 0; + font-size: var(--fs-text-sm); + color: var(--color-foreground-muted); +} + +.items { + display: flex; + flex-direction: column; + gap: var(--m-0-5); + margin: 0; + padding: 0; + list-style: none; + + @container checklist-body (width > 800px) { + display: block; + } +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--m-2); + margin-top: 0 !important; + min-height: var(--m-4); + font-size: var(--fs-text-md); + line-height: 160%; + color: var(--color-black); + + .label { + a { + text-decoration: underline; + } + } + + @media (--md-scr) { + gap: var(--m-3); + font-size: var(--fs-text-lg); + } + + @container checklist-body (width > 800px) { + margin-top: var(--m-0-5) !important; + &:first-child { + margin-top: var(--m-2) !important; + } + } +} + +.label { + display: flex; + align-items: flex-start; + gap: var(--m-1-5); + cursor: pointer; + transition: color var(--t-fast); + + @media (--md-scr) { + gap: var(--m-2); + } +} + +/* Custom-drawn so the tick can be sized */ +.checkbox { + appearance: none; + flex-shrink: 0; + width: 18px; + height: 18px; + margin: var(--m-0-5) 0 0; + border: 1px solid var(--color-foreground-muted); + border-radius: var(--r-default); + /* Aspect ratio for the tick is 23:16 in the design */ + background: var(--color-white) no-repeat center / 8px calc(8px * 16 / 23); + cursor: pointer; + transition: + background-color var(--t-fast), + border-color var(--t-fast); + + &:checked { + border-color: var(--color-dark-purple); + background-color: var(--color-dark-purple); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7.906 5.5' fill='none'%3E%3Cpath d='M6.51 0.21967C6.8294 -0.0732233 7.34713 -0.0732233 7.66653 0.21967C7.98594 0.512563 7.98594 0.987324 7.66653 1.28022L3.30449 5.28022C2.98508 5.57311 2.46735 5.57311 2.14795 5.28022L0.239553 3.53022C-0.0798509 3.23732 -0.0798509 2.76256 0.239553 2.46967C0.558956 2.17678 1.07669 2.17678 1.39609 2.46967L2.72622 3.6894L6.51 0.21967Z' fill='white'/%3E%3C/svg%3E"); + } + + &:focus-visible { + outline: 2px solid var(--color-dark-purple); + outline-offset: 2px; + } +} + +.playButton { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + align-self: flex-start; + padding: 10px; + border: unset; + border-radius: var(--r-circle); + background-color: var(--color-background-depth-4); + cursor: pointer; + transition: background-color var(--t-fast); + + svg { + --size: 12px; + width: 12px; + + path { + fill: var(--color-foreground-slightly-muted); + } + } + + &:hover { + background-color: var(--color-lighter-gray); + } + + &:hover .tooltip, + &:focus-visible .tooltip { + opacity: 1; + } +} + +.tooltip { + position: absolute; + top: 50%; + right: calc(100% + var(--m-1)); + transform: translateY(-50%); + padding: var(--m-0-5) var(--m-1); + border-radius: var(--r-default); + background-color: var(--color-foreground-tooltip); + color: var(--color-white); + font-size: var(--fs-text-sm); + line-height: 150%; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity var(--t-fast); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/src/components/Checklist/Checklist.stories.tsx b/src/components/Checklist/Checklist.stories.tsx new file mode 100644 index 00000000..d2085471 --- /dev/null +++ b/src/components/Checklist/Checklist.stories.tsx @@ -0,0 +1,166 @@ +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import { userEvent, within } from "storybook/test"; +import { expect } from "storybook/test"; +import Checklist, { ChecklistItem, ChecklistStep } from "./Checklist"; +import { collectEvents } from "@site/src/utils/analytics"; + +const STORAGE_KEY = "checklist:storybook"; + +const ChecklistExample = () => ( + + + + Update required security contacts + + + Establish a break-glass access plan + + + + + Connect an identity provider + + Map SSO groups to Teleport roles + + + Set up the Teleport Terraform provider + Manage roles with Terraform + + +); + +const meta: Meta = { + title: "components/Checklist", + component: Checklist, + beforeEach: () => { + localStorage.removeItem(STORAGE_KEY); + }, +}; + +export default meta; +type Story = StoryObj; + +export const InitialState: Story = { + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Only the first step is opened", async () => { + expect(canvas.getByRole("button", { name: /1\. Setup/ })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect( + canvas.getByRole("button", { name: /2\. Single Sign-On/ }), + ).toHaveAttribute("aria-expanded", "false"); + expect( + canvas.getByRole("button", { name: /3\. Infrastructure as Code/ }), + ).toHaveAttribute("aria-expanded", "false"); + }); + }, +}; + +export const CheckingItemsCompletesStep: Story = { + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const [first, second] = canvas.getAllByRole("checkbox"); + + await step("Check the first item", async () => { + await userEvent.click(first); + expect(first).toBeChecked(); + expect(canvas.getByText("1/2 completed")).toBeInTheDocument(); + expect( + canvas.getByRole("button", { name: /1\. Setup/ }), + ).not.toHaveAccessibleName(/Completed/); + }); + + await step("Check the second item to complete the step", async () => { + await userEvent.click(second); + expect(canvas.getByText("2/2 completed")).toBeInTheDocument(); + expect( + canvas.getByRole("button", { name: /1\. Setup/ }), + ).toHaveAccessibleName(/Completed/); + expect( + JSON.parse( + localStorage.getItem(STORAGE_KEY) || "{checked: [], open: 0}", + ), + ).toEqual({ + checked: ["checklist-setup-0", "checklist-setup-1"], + open: 0, + }); + }); + + await step("Uncheck an item", async () => { + await userEvent.click(second); + expect(second).not.toBeChecked(); + expect(canvas.getByText("1/2 completed")).toBeInTheDocument(); + }); + }, +}; + +export const RestoresProgressFromStorage: Story = { + render: () => , + beforeEach: () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ checked: ["0-0", "0-1"], open: 1 }), + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("The previously open step is opened", async () => { + const sso = canvas.getByRole("button", { name: /2\. Single Sign-On/ }); + expect(sso).toHaveAttribute("aria-expanded", "true"); + }); + }, +}; + +export const ToggleSteps: Story = { + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const setup = canvas.getByRole("button", { name: /1\. Setup/ }); + const sso = canvas.getByRole("button", { name: /2\. Single Sign-On/ }); + + await step("Open the second step", async () => { + await userEvent.click(sso); + expect(sso).toHaveAttribute("aria-expanded", "true"); + expect(setup).toHaveAttribute("aria-expanded", "false"); + }); + + await step("Collapse the second step", async () => { + await userEvent.click(sso); + expect(sso).toHaveAttribute("aria-expanded", "false"); + }); + }, +}; + +export const PlayFromTimestamp: Story = { + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + let player: HTMLIFrameElement | null = null; + + await step("Load the player at the item's timestamp", async () => { + await userEvent.click( + canvas.getByRole("button", { name: "Play video from 0:42" }), + ); + player = canvasElement.querySelector("iframe"); + expect(player?.getAttribute("src")).toContain("start=42"); + expect(player?.getAttribute("src")).toContain("enablejsapi=1"); + }); + + await step("Seek to another timestamp without reloading", async () => { + await userEvent.click( + canvas.getByRole("button", { name: "Play video from 1:35" }), + ); + // The seek is sent to the existing player, so the iframe and its src + // stay the same. + expect(canvasElement.querySelector("iframe")).toBe(player); + expect(player?.getAttribute("src")).toContain("start=42"); + }); + }, +}; diff --git a/src/components/Checklist/Checklist.tsx b/src/components/Checklist/Checklist.tsx new file mode 100644 index 00000000..e924a513 --- /dev/null +++ b/src/components/Checklist/Checklist.tsx @@ -0,0 +1,305 @@ +import cn from "clsx"; +import { + Children, + isValidElement, + useEffect, + useId, + useLayoutEffect, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import Icon from "../Icon"; +import YouTubeEmbed from "../YouTubeEmbed"; +import styles from "./Checklist.module.css"; + +type ChecklistItemProps = { + // Video timestamp in seconds that the item's play button jumps to. + timestamp?: number; + children: ReactNode; +}; + +type ChecklistStepProps = { + title: string; + videoId?: string; + children: ReactNode; +}; + +type ChecklistProps = { + // Unique per checklist, used as the localStorage key for saved progress. + id: string; + children: ReactNode; +}; + +type Step = Omit & { + items: ChecklistItemProps[]; +}; + +// ChecklistStep and ChecklistItem only carry props. Checklist reads them and +// renders the whole list, so all state lives in one place. +export const ChecklistStep: React.FC = () => null; +export const ChecklistItem: React.FC = () => null; + +const getChildProps = (children: ReactNode): T[] => + Children.toArray(children) + .filter((child): child is ReactElement => isValidElement(child)) + .map((child) => child.props); + +const itemKey = (stepTitle: string, itemIndex: number) => + `${getStepId(stepTitle)}-${itemIndex}`; + +const isStepComplete = (step: Step, checked: string[]) => + step.items.every((_, itemIndex) => + checked.includes(itemKey(step.title, itemIndex)), + ); + +// Make sure the opened step is a valid index. +const getOpenStep = (steps: Step[], openIndex: number) => { + if (typeof openIndex === "number" && steps[openIndex]) { + return openIndex; + } + return null; +}; + +const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = String(seconds % 60).padStart(2, "0"); + return `${mins}:${secs}`; +}; + +// Anchor id of a step, e.g. "checklist-single-sign-on". +const getStepId = (title: string) => + `checklist-${title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "")}`; + +const Checklist: React.FC = ({ id, children }) => { + const storageKey = `checklist:${id}`; + const baseId = useId(); + const steps: Step[] = getChildProps(children).map( + ({ children, ...step }) => ({ + ...step, + items: getChildProps(children), + }), + ); + + const [checked, setChecked] = useState([]); + const [initialized, setInitialized] = useState(false); + const [animate, setAnimate] = useState(false); + const [openStep, setOpenStep] = useState(0); + // Steps whose video stays mounted, so it doesn't vanish mid-collapse and + // thumbnails only load for steps the user has opened. + const [openedSteps, setOpenedSteps] = useState([0]); + // Latest play button click in the open step. A new object on every click, + // so the same timestamp can be replayed. + const [seek, setSeek] = useState<{ time: number }>(); + + const getBodyId = (stepIndex: number) => `${baseId}-step-${stepIndex}`; + + const toggleStep = (step: number | null, checkedItems?: string[]) => { + setOpenStep(step); + setSeek(undefined); + if (step !== null && !openedSteps.includes(step)) { + setOpenedSteps([...openedSteps, step]); + } + if (!checkedItems) { + // Persist the open step in localStorage so it can be restored on page reload. + try { + localStorage.setItem( + storageKey, + JSON.stringify({ checked: checkedItems || checked, open: step }), + ); + } catch {} + } + }; + + useLayoutEffect(() => { + try { + let saved: { checked: string[]; open: number } = JSON.parse( + localStorage.getItem(storageKey) || "{checked:[], open:0}", + ); + // If the saved data is invalid, reset it to an empty checklist + // instead of throwing an error and breaking the page. + if (!saved.checked || !Array.isArray(saved.checked)) { + saved = { checked: [], open: 0 }; + } + setChecked(saved.checked); + toggleStep(getOpenStep(steps, saved.open), saved.checked); + } catch {} + + setInitialized(true); + }, [storageKey]); + + // delay the animation until after the first render, so the open + // step doesn't animate in on a page load + useEffect(() => { + if (initialized) setAnimate(true); + }, [initialized]); + + const toggleItem = (key: string) => { + const next = checked.includes(key) + ? checked.filter((k) => k !== key) + : [...checked, key]; + setChecked(next); + try { + localStorage.setItem( + storageKey, + JSON.stringify({ checked: next, open: openStep }), + ); + } catch {} + }; + + return ( + + + {steps.map((step, stepIndex) => { + const isOpen = openStep === stepIndex; + const isComplete = isStepComplete(step, checked); + const checkedCount = step.items.filter((_, itemIndex) => + checked.includes(itemKey(step.title, itemIndex)), + ).length; + const progress = step.items.length + ? (checkedCount / step.items.length) * 100 + : 0; + const bodyId = getBodyId(stepIndex); + + return ( + + toggleStep(isOpen ? null : stepIndex)} + > + + + {stepIndex + 1}. {step.title} + + + {isComplete && ( + Completed + )} + + + + + + + + + + + + {step.videoId && openedSteps.includes(stepIndex) && ( + + {/* Remounting on close resets the video to its thumbnail, + which stops playback. */} + + + )} + + + + + + + {checkedCount}/{step.items.length} completed + + + + + {step.items.map((item, itemIndex) => { + const key = itemKey(step.title, itemIndex); + const isChecked = checked.includes(key); + const playLabel = + item.timestamp !== undefined + ? `Play video from ${formatTime(item.timestamp)}` + : undefined; + + return ( + + + toggleItem(key)} + /> + {item.children} + + {playLabel && step.videoId && ( + + setSeek({ time: item.timestamp as number }) + } + > + + {/* Visual only; aria-label already gives screen readers the same text. */} + + {playLabel} + + + )} + + ); + })} + + + + + + ); + })} + + + ); +}; + +export default Checklist; diff --git a/src/components/Checklist/index.ts b/src/components/Checklist/index.ts new file mode 100644 index 00000000..f48e668d --- /dev/null +++ b/src/components/Checklist/index.ts @@ -0,0 +1 @@ +export { default, ChecklistStep, ChecklistItem } from "./Checklist"; diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index 78379360..ce2c6871 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -29,6 +29,7 @@ export { default as card } from "./svg/card.svg"; export { default as checkCircle } from "./svg/check-circle.svg"; export { default as checkRound } from "./svg/check-round.svg"; export { default as check } from "./svg/check.svg"; +export { default as checkWhite } from "./svg/check-white.svg"; export { default as chip } from "./svg/chip.svg"; export { default as circleci } from "./svg/circleci.svg"; export { default as clickhouse } from "./svg/clickhouse.svg"; diff --git a/src/components/Icon/svg/check-white.svg b/src/components/Icon/svg/check-white.svg new file mode 100644 index 00000000..0a798148 --- /dev/null +++ b/src/components/Icon/svg/check-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/YouTubeEmbed/YouTubeEmbed.tsx b/src/components/YouTubeEmbed/YouTubeEmbed.tsx index d88ef2b4..4e9335aa 100644 --- a/src/components/YouTubeEmbed/YouTubeEmbed.tsx +++ b/src/components/YouTubeEmbed/YouTubeEmbed.tsx @@ -1,6 +1,6 @@ -import { getEmbedYouTubeUrl } from "@site/src/utils"; +import { getEmbedYouTubeUrl, YOUTUBE_EMBED_ORIGIN } from "@site/src/utils"; import cn from "clsx"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import styles from "./YouTubeEmbed.module.css"; import Icon from "../Icon"; @@ -11,20 +11,53 @@ type YouTubeEmbedProps = { title?: string; fetchPriority?: "high" | "low"; className?: string; + // Jumps the video to `time` seconds whenever a new object is passed. + seek?: { time: number }; }; +// Sends a command over the same postMessage channel the YouTube IFrame API +// uses. Requires enablejsapi=1 in the embed URL. +const sendPlayerCommand = ( + iframe: HTMLIFrameElement | null, + func: string, + args: unknown[] = [], +) => + iframe?.contentWindow?.postMessage( + JSON.stringify({ event: "command", func, args }), + YOUTUBE_EMBED_ORIGIN, + ); + const YouTubeEmbed: React.FC = ({ videoId, title, fetchPriority, className, + seek, }) => { const [videoActive, setVideoActive] = useState(false); + // Set only when the player first loads, so later seeks don't change the + // iframe src and reload it. + const [start, setStart] = useState(); + const iframeRef = useRef(null); + + useEffect(() => { + if (!seek) return; + if (!videoActive) { + setStart(seek.time); + setVideoActive(true); + return; + } + sendPlayerCommand(iframeRef.current, "seekTo", [seek.time, true]); + sendPlayerCommand(iframeRef.current, "playVideo"); + // Only react to new seeks, not to the player becoming active. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seek]); if (videoActive) return ( , // needed to circumvent props mismatch in types ThumbsFeedback, Checkpoint, + Checklist, + ChecklistStep, + ChecklistItem, EnterpriseFeatureBadge, EnterpriseFeatureCallout, EnterpriseFeatureWidget, diff --git a/src/utils/index.ts b/src/utils/index.ts index f7b93080..ac51bd16 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,6 @@ -export const getEmbedYouTubeUrl = (videoId: string) => { - return `https://www.youtube.com/embed/${videoId}?autoplay=1`; +export const YOUTUBE_EMBED_ORIGIN = "https://www.youtube-nocookie.com"; + +export const getEmbedYouTubeUrl = (videoId: string, start?: number) => { + const startParam = start !== undefined ? `&start=${start}` : ""; + return `${YOUTUBE_EMBED_ORIGIN}/embed/${videoId}?autoplay=1&enablejsapi=1${startParam}`; };