diff --git a/src/assets/icons/markdown.svg b/src/assets/icons/markdown.svg new file mode 100644 index 0000000000..6b568c368b --- /dev/null +++ b/src/assets/icons/markdown.svg @@ -0,0 +1,7 @@ + + diff --git a/src/components/CopyAsMarkdown.astro b/src/components/CopyAsMarkdown.astro index 6ed8d0f253..ee1c0ee83c 100644 --- a/src/components/CopyAsMarkdown.astro +++ b/src/components/CopyAsMarkdown.astro @@ -1,7 +1,7 @@ --- import { Lang, Translations } from '@util/Languages'; import { getPageMarkdownPath } from '@util/mdxContent'; -import Button from './Button.astro'; +import SplitButton from './SplitButton.astro'; type Props = { lang: string; @@ -15,31 +15,104 @@ const pageMdUrl = getPageMarkdownPath(Astro.url.pathname); { pageMdUrl && ( - + ) } + + diff --git a/src/components/MarkdownLinks.astro b/src/components/MarkdownLinks.astro deleted file mode 100644 index 190653b662..0000000000 --- a/src/components/MarkdownLinks.astro +++ /dev/null @@ -1,57 +0,0 @@ ---- -import { Lang, Translations } from '@util/Languages'; -import { SITE } from '@config'; -import { getPageMarkdownPath } from '@util/mdxContent'; - -type Props = { - lang: string; -}; -const { lang } = Astro.props satisfies Props; - -const _ = Lang(lang); - -const pageMdUrl = getPageMarkdownPath(Astro.url.pathname); -const allDocsUrl = SITE.subfolder.replace(/\/$/, '') + '/llms-full.txt'; ---- - -{ - pageMdUrl && ( - - ) -} diff --git a/src/components/SplitButton.astro b/src/components/SplitButton.astro index 6210b6b5df..d62c5ebcfa 100644 --- a/src/components/SplitButton.astro +++ b/src/components/SplitButton.astro @@ -12,7 +12,8 @@ export type SplitButtonItem = { type Props = HTMLAttributes<'div'> & { label: string; - href: string | URL; + // Omitted when the primary acts on the page rather than navigating away + href?: string | URL; icon?: string; size?: 'xSmall' | 'small' | 'medium'; importance?: 'default' | 'loud'; @@ -40,6 +41,12 @@ if (items.length === 0) { throw new Error('A SplitButton with no menu items should just be a Button'); } +// Button types the two halves of its own union separately - an anchor needs an +// `href`, a plain button forbids one - so the link attributes are handed over as +// a set rather than as three possibly-undefined props. `target` and `rel` say +// where a link opens, and mean nothing on a button. +const primaryLink = href ? { href, target, rel } : {}; + const menuItems = items.map((item) => ({ ...item, kind: 'link' as const, @@ -51,14 +58,14 @@ const menuItems = items.map((item) => ({
-
diff --git a/src/scripts/copy-as-markdown.ts b/src/scripts/copy-as-markdown.ts index 411e52cd97..d89a2d5612 100644 --- a/src/scripts/copy-as-markdown.ts +++ b/src/scripts/copy-as-markdown.ts @@ -1,31 +1,28 @@ import { copyOnClick, revertAfter } from './modules/clipboard.js'; -// Which label shows, and which glyph the icon masks, is CSS's decision - all -// three labels are in the DOM, and this only says which state the button is in. -function showLabelResult(button: HTMLElement, ok: boolean): string { - const state = ok ? 'copied' : 'failed'; +// Which label shows, and which glyph the icon masks, is CSS's decision - both +// labels are in the DOM, and this only says whether the copy landed. +// +// A copy that fails says nothing. Writing text to the clipboard is not something +// a reader can act on the failure of, and the chain in clipboard.js already logs +// whichever rung refused. +function showCopied(button: HTMLElement, ok: boolean): string { + if (!ok) return ''; - // Both are cleared every time, so a failure followed by a success inside the - // revert window does not leave the button wearing two states at once. - const rest = () => { - delete button.dataset.copied; - delete button.dataset.failed; - }; + button.dataset.copied = ''; + revertAfter(button, () => delete button.dataset.copied); - rest(); - button.dataset[state] = ''; - revertAfter(button, rest); - - const label = button.querySelector( - `.octo-copy-md__label--${state}` - ); + const label = button.querySelector('.octo-copy-md__copied'); return label?.textContent?.trim() ?? ''; } // Handed over unresolved: the clipboard write starts while the page is still // downloading, which is what keeps the copy working in Safari. function pageMarkdown(button: HTMLElement): Promise | null { - const url = button.dataset.copyMdUrl; + // The URL sits on the split button wrapping the copy half, so that a click on + // the caret beside it resolves to the menu rather than to a copy. + const url = + button.closest('[data-copy-md-url]')?.dataset.copyMdUrl; if (!url) return null; return fetch(url).then((response) => { @@ -34,4 +31,8 @@ function pageMarkdown(button: HTMLElement): Promise | null { }); } -copyOnClick('[data-copy-md-url]', pageMarkdown, { show: showLabelResult }); +// Matched on the primary half rather than on the control, so that the caret does +// not copy the page on its way to opening the menu. +copyOnClick('[data-copy-md-url] .split-btn__primary', pageMarkdown, { + show: showCopied, +}); diff --git a/src/scripts/main.js b/src/scripts/main.js index 3fa6d85c28..24eed5f812 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -4,7 +4,6 @@ import { addListImageIntersectionObserver, } from './modules/animation.js'; import { addResizedEvent } from './modules/resizing.js'; -import { markdownLinkMenus } from './modules/markdown-links.js'; import { setClickableBlocks } from './modules/click-blocks.js'; import { setExternalLinkAttributes } from './modules/external-links.js'; import { monitorInputType } from './modules/input-type.js'; diff --git a/src/scripts/modules/markdown-links.js b/src/scripts/modules/markdown-links.js deleted file mode 100644 index 65513fb386..0000000000 --- a/src/scripts/modules/markdown-links.js +++ /dev/null @@ -1,41 +0,0 @@ -// @ts-check -import { qs, qsa } from './query.js'; - -class MarkdownLinks { - constructor(menu) { - this.menu = menu; - this.trigger = qs('[data-md-links-trigger]', menu); - - this.addListeners(); - } - - handleKeyboardNavigation(e) { - if (!this.menu.open) return; - - if (e.key === 'Escape') { - e.preventDefault(); - this.menu.open = false; - this.trigger.focus(); - } - } - - handleOutsideClick(e) { - if (!this.menu.open) return; - if (e.target instanceof Node && this.menu.contains(e.target)) return; - this.menu.open = false; - } - - addListeners() { - this.menu.addEventListener('keydown', (e) => - this.handleKeyboardNavigation(e) - ); - - document.addEventListener('click', (e) => this.handleOutsideClick(e)); - } -} - -const markdownLinkMenus = Array.from(qsa('[data-md-links-menu]')).map( - (menu) => new MarkdownLinks(menu) -); - -export { markdownLinkMenus }; diff --git a/src/styles/main.css b/src/styles/main.css index 73e904b4eb..63d8cf83b9 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1546,195 +1546,6 @@ html[data-theme='dark'] img.card__icon { mask: url('../assets/icons/github.svg') center / contain no-repeat; } -/* "Copy as markdown" page action */ -.octo-copy-md__icon { - background-color: var(--colorIconPrimary); - mask: url('../assets/icons/copy.svg') center / contain no-repeat; -} - -.octo-copy-md[data-copied] .octo-copy-md__icon { - mask: url('../assets/icons/check.svg') center / contain no-repeat; -} - -/* Slotted, so button.css's scoped .btn__label padding does not reach it. */ -.octo-copy-md__labels { - display: grid; - padding-inline: var(--space4); - text-align: start; -} - -.octo-copy-md__label { - grid-area: 1 / 1; - visibility: hidden; - white-space: nowrap; -} - -.octo-copy-md__label--rest { - visibility: visible; -} - -.octo-copy-md:is([data-copied], [data-failed]) .octo-copy-md__label--rest { - visibility: hidden; -} - -.octo-copy-md[data-copied] .octo-copy-md__label--copied, -.octo-copy-md[data-failed] .octo-copy-md__label--failed { - visibility: visible; -} - -/* "Use Octopus docs with AI" dropdown */ -.octo-md-links { - margin-block-start: var(--block-gap); -} - -.octo-md-links__menu { - display: inline-block; - position: relative; -} - -.octo-md-links__trigger { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.875rem; - border: 1px solid var(--border-color-menu-open); - border-radius: 999px; - background-color: transparent; - color: var(--color-menu-link); - font: inherit; - cursor: pointer; - list-style: none; - text-decoration: none; - user-select: none; - transition: - border-color 200ms cubic-bezier(0.4, 0, 0.2, 1), - background-color 200ms cubic-bezier(0.4, 0, 0.2, 1), - color 200ms cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-md-links__trigger > * { - text-decoration: none; - color: inherit; -} - -.octo-md-links__trigger::-webkit-details-marker, -.octo-md-links__trigger::marker { - content: ''; - display: none; -} - -.octo-md-links__trigger-icon, -.octo-md-links__trigger-caret { - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.octo-md-links__trigger-icon::before { - content: '\f0eb'; /* fa-lightbulb */ - font-family: fa-solid; - font-size: 0.95em; - line-height: 1; - color: var(--color-menu-link-alt); - transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-md-links__trigger-caret::before { - content: '\f078'; /* fa-chevron-down */ - font-family: fa-solid; - font-size: 0.7em; - line-height: 1; - color: currentColor; - transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-md-links__menu[open] - > .octo-md-links__trigger - .octo-md-links__trigger-caret::before { - transform: rotate(180deg); -} - -.octo-md-links__trigger:hover { - color: var(--color-menu-link-active); - border-color: var(--color-menu-link-alt); -} - -.octo-md-links__menu[open] > .octo-md-links__trigger { - color: var(--color-menu-link-active); - border-color: var(--color-menu-link-alt); - background-color: var(--bg-color-menu-open); - box-shadow: 0 0.0625rem 0.25rem rgba(13, 128, 216, 0.08); -} - -.octo-md-links__trigger:focus-visible { - outline: 2px solid var(--color-menu-link-alt); - outline-offset: 2px; -} - -.octo-md-links .octo-md-links__options { - position: absolute; - z-index: 10; - inset-block-start: calc(100% + 0.5rem); - inset-inline-start: 0; - margin: 0; - padding: 0.5rem; - list-style: none; - background-color: var(--bg-color-menu); - border: 1px solid var(--border-color-menu-open); - border-radius: 0.625rem; - min-width: 20rem; - box-sizing: border-box; - overflow: hidden; - box-shadow: - 0 0.625rem 1.875rem rgba(15, 37, 53, 0.12), - 0 0.125rem 0.375rem rgba(15, 37, 53, 0.06); -} - -.octo-md-links .octo-md-links__options .octo-md-links__option { - display: inline-flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 0.75rem; - border: none; - border-radius: 0.375rem; - background: transparent; - color: var(--color-menu-link); - font: inherit; - text-align: start; - text-decoration: none; - cursor: pointer; - transition: - background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), - color 150ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-md-links .octo-md-links__options .octo-md-links__option:hover, -.octo-md-links .octo-md-links__options .octo-md-links__option:focus-visible { - background-color: var(--bg-color-menu-open); - color: var(--color-menu-link-active); - outline: none; -} - -.octo-md-links__option::before { - font-family: fa-solid; - font-size: 0.95em; - width: 1.1em; - text-align: center; - color: var(--color-menu-link-alt); - flex-shrink: 0; - transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-md-links__option--view::before { - content: '\f15c'; /* fa-file-lines */ -} - -.octo-md-links__option--all::before { - content: '\f02d'; /* fa-book */ -} - /* Live regions for announcing the result of an action, such as copying a URL or a code block. Read by screen readers, never shown. */ .copy-status { diff --git a/tests/copy-button.spec.ts b/tests/copy-button.spec.ts index f9c508fd1c..e3de3395ce 100644 --- a/tests/copy-button.spec.ts +++ b/tests/copy-button.spec.ts @@ -64,7 +64,8 @@ test('the tooltip arrow overlaps the bubble it points from', async ({ const overlap = await button.evaluate((el) => { const bubble = el.querySelector('.tooltip'); - if (!bubble) throw new Error('expected the button to have a .tooltip bubble'); + if (!bubble) + throw new Error('expected the button to have a .tooltip bubble'); const caret = getComputedStyle(bubble, '::after'); const bubbleHeight = bubble.getBoundingClientRect().height; @@ -147,7 +148,7 @@ test('the copy action puts the page markdown on the clipboard', async ({ await page.goto(MD_PAGE); - const button = page.locator('.octo-copy-md'); + const button = page.locator('.octo-copy-md .split-btn__primary'); await button.click(); await expect(button).toHaveAttribute('data-copied', ''); @@ -164,7 +165,9 @@ test('the copy action puts the page markdown on the clipboard', async ({ ); }); -test('the copy action reports a failure when the markdown cannot be fetched', async ({ +// A copy that fails says nothing, so the button has to stay as it was rather +// than claim it copied. +test('the copy action stays at rest when the markdown cannot be fetched', async ({ page, }) => { await page.goto(MD_PAGE); @@ -172,9 +175,59 @@ test('the copy action reports a failure when the markdown cannot be fetched', as // Routed after the page has loaded, so only the copy's own fetch is refused. await page.route('**/*.md', (route) => route.abort()); - const button = page.locator('.octo-copy-md'); + const button = page.locator('.octo-copy-md .split-btn__primary'); + const label = button.locator('.btn__label'); await button.click(); - await expect(button).toHaveAttribute('data-failed', ''); await expect(button).not.toHaveAttribute('data-copied', ''); + await expect(label).toBeVisible(); + await expect(page.locator('.copy-status')).toHaveText(''); +}); + +// The label and the "Copied" it turns into are both in the DOM, stacked, so +// reporting a result must not reflow the page actions row. +test('the copy action keeps its width while it reports a result', async ({ + page, +}) => { + await page.goto(MD_PAGE); + + const button = page.locator('.octo-copy-md .split-btn__primary'); + const before = await button.boundingBox(); + + await button.click(); + await expect(button).toHaveAttribute('data-copied', ''); + + const after = await button.boundingBox(); + expect(after!.width).toBe(before!.width); +}); + +// "Open this page as markdown" used to sit in a menu at the bottom of the +// article. It is the copy action's one menu item now. +test('the copy action menu holds exactly one item, linking to the page markdown', async ({ + page, +}) => { + await page.goto(MD_PAGE); + + const menu = page.locator('.octo-copy-md [data-menu]'); + await menu.locator('summary').click(); + + const items = menu.locator('.menu__action'); + await expect(items).toHaveCount(1); + await expect(items).toHaveAttribute('href', MD_PAGE + '.md'); + await expect(items).toHaveAttribute('target', '_blank'); +}); + +// The URL sits on the control while the copy listener matches the primary half. +// Matching the control instead would copy the page every time the menu opened. +test('opening the menu does not copy the page', async ({ page }) => { + await page.goto(MD_PAGE); + + const control = page.locator('.octo-copy-md'); + await control.locator('summary').click(); + await expect(control.locator('.menu__list')).toBeVisible(); + + await expect(control.locator('.split-btn__primary')).not.toHaveAttribute( + 'data-copied', + '' + ); }); diff --git a/tests/llm-endpoints.spec.ts b/tests/llm-endpoints.spec.ts index 2a07adb932..dd61f0387d 100644 --- a/tests/llm-endpoints.spec.ts +++ b/tests/llm-endpoints.spec.ts @@ -96,25 +96,6 @@ test('Copy as markdown action advertises a working .md URL on the eligible page' expect(target.status()).toBe(200); }); -// All three labels are always in the DOM, stacked, so reporting a result must -// not reflow the page actions row. -test('the copy action keeps its width while it reports a result', async ({ - page, - context, -}) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await page.goto(STABLE_PLAIN_MD_PATH); - - const button = page.locator('.octo-copy-md'); - const before = await button.boundingBox(); - - await button.click(); - await expect(button).toHaveAttribute('data-copied', ''); - - const after = await button.boundingBox(); - expect(after!.width).toBe(before!.width); -}); - test('markdown page actions are hidden on the ineligible MDX page', async ({ page, }) => { @@ -123,10 +104,6 @@ test('markdown page actions are hidden on the ineligible MDX page', async ({ await page.locator('.octo-copy-md').count(), 'expected no copy action on ineligible page' ).toBe(0); - expect( - await page.locator('[data-md-links-menu]').count(), - 'expected no markdown links dropdown on ineligible page' - ).toBe(0); }); test('HtmlHead omits `` on the ineligible MDX page', async ({