- )
-}
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 ({