Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions components/ArticleNavigation/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import CrossLink from '@node-core/ui-components/Common/BaseCrossLink';

import { sidebar } from '../../site.json' with { type: 'json' };

import styles from './index.module.css';

const flattenItems = items =>
items.flatMap(item =>
item.items?.length ? flattenItems(item.items) : [item]
);

const articles = sidebar.flatMap(({ items }) => flattenItems(items));

/**
* Previous/next article navigation following the same order as the sidebar.
*
* @param {{ pathname: string }} props
*/
const ArticleNavigation = ({ pathname }) => {
const currentIndex = articles.findIndex(({ link }) => link === pathname);

if (currentIndex === -1) {
return null;
}

const previous = articles[currentIndex - 1];
const next = articles[currentIndex + 1];

if (!previous && !next) {
return null;
}

return (
<nav className={styles.crossLinks} aria-label="Article navigation">
{(previous && (
<CrossLink
type="previous"
label="Previous"
text={previous.label}
link={previous.link}
/>
)) || <div />}

{next && (
<CrossLink
type="next"
label="Next"
text={next.label}
link={next.link}
/>
)}
</nav>
);
};

export default ArticleNavigation;
17 changes: 17 additions & 0 deletions components/ArticleNavigation/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.crossLinks {
margin-top: 1rem;
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}

@media (max-width: 767px) {
.crossLinks {
grid-template-columns: minmax(0, 1fr);
}

.crossLinks > :empty {
display: none;
}
}
50 changes: 28 additions & 22 deletions components/Layout/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import NavBar from '../Navigation';
import MetaBar from '../Metabar';
import SideBar from '../Sidebar';
import Footer from '../Footer';
import ArticleNavigation from '../ArticleNavigation';

/**
* @typedef {Object} Props
Expand All @@ -20,27 +21,32 @@ import Footer from '../Footer';
/**
* @param {Props} props
*/
export default ({ metadata, headings, readingTime, children }) => (
<>
<Banner />
<Analytics basePath="/learn/_vercel" />
<SpeedInsights basePath="/learn/_vercel" />
<NavBar metadata={metadata} />
<Article>
<SideBar pathname={`/learn${metadata.path.replace('/index', '')}`} />
<div>
export default ({ metadata, headings, readingTime, children }) => {
const pathname = `/learn${metadata.path.replace('/index', '')}`;

return (
<>
<Banner />
<Analytics basePath="/learn/_vercel" />
<SpeedInsights basePath="/learn/_vercel" />
<NavBar metadata={metadata} />
<Article>
<SideBar pathname={pathname} />
<div>
<TableOfContents headings={headings} summaryTitle="On this page" />
<br />
<main>{children}</main>
<div>
<TableOfContents headings={headings} summaryTitle="On this page" />
<br />
<main>{children}</main>
<ArticleNavigation pathname={pathname} />
</div>
<MetaBar
metadata={metadata}
headings={headings}
readingTime={readingTime}
/>
</div>
<MetaBar
metadata={metadata}
headings={headings}
readingTime={readingTime}
/>
</div>
</Article>
<Footer metadata={metadata} />
</>
);
</Article>
<Footer metadata={metadata} />
</>
);
};
99 changes: 99 additions & 0 deletions tests/e2e/interactions.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { test, expect } from '@playwright/test';
import { waitForIsland } from './helpers.mjs';

const ARTICLE = '/learn/getting-started/introduction-to-nodejs';
const EVENT_LOOP_ARTICLE =
'/learn/asynchronous-work/event-loop-timers-and-nexttick';

/**
* The theme is applied in an effect after the menu item is clicked, so use a
Expand Down Expand Up @@ -54,6 +56,63 @@ test.describe('Navigation', () => {
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('renders previous and next article links in sidebar order', async ({
page,
}) => {
await page.goto(EVENT_LOOP_ARTICLE);

const navigation = page.getByRole('navigation', {
name: 'Article navigation',
});
const previous = navigation.getByRole('link', { name: /Previous/ });
const next = navigation.getByRole('link', { name: /Next/ });

await expect(previous).toContainText(
'Overview of Blocking vs Non-Blocking'
);
await expect(previous).toHaveAttribute(
'href',
'/learn/asynchronous-work/overview-of-blocking-vs-non-blocking'
);
await expect(next).toContainText('The Node.js Event Emitter');
await expect(next).toHaveAttribute(
'href',
'/learn/asynchronous-work/the-nodejs-event-emitter'
);
});

test('continues article navigation across sidebar groups', async ({
page,
}) => {
await page.goto('/learn/asynchronous-work/dont-block-the-event-loop');

const navigation = page.getByRole('navigation', {
name: 'Article navigation',
});
const next = navigation.getByRole('link', { name: /Next/ });

await expect(next).toContainText('Comparing Node.js concurrency models');
await expect(next).toHaveAttribute(
'href',
'/learn/concurrency/comparing-nodejs-concurrency-models'
);
});

test('uses leaf article order for nested sidebar sections', async ({
page,
}) => {
await page.goto('/learn/diagnostics/memory');

const navigation = page.getByRole('navigation', {
name: 'Article navigation',
});
const previous = navigation.getByRole('link', { name: /Previous/ });
const next = navigation.getByRole('link', { name: /Next/ });

await expect(previous).toContainText('User Journey');
await expect(next).toContainText('Understanding and Tuning Memory');
});

test('unknown pages respond with 404', async ({ page }) => {
// Only the status is checked: which 404 page renders depends on the host
// (nodejs.org proxies /learn, previews and `serve` use their own).
Expand Down Expand Up @@ -124,6 +183,46 @@ test.describe('Small screens', () => {
await expect(page).toHaveURL(new RegExp(`${ARTICLE}$`));
});

test('the first article shows next without an empty mobile slot', async ({
page,
}) => {
await page.goto(ARTICLE);

const navigation = page.getByRole('navigation', {
name: 'Article navigation',
});
await navigation.scrollIntoViewIfNeeded();

await expect(
navigation.getByRole('link', { name: /Previous/ })
).toHaveCount(0);
await expect(navigation.getByRole('link', { name: /Next/ })).toContainText(
'How much JavaScript do you need to know to use Node.js?'
);
await expect(navigation.locator(':scope > div:empty')).toBeHidden();
});

test('article navigation moves to the next article from the bottom', async ({
page,
}) => {
await page.goto(EVENT_LOOP_ARTICLE);

const navigation = page.getByRole('navigation', {
name: 'Article navigation',
});
await navigation.scrollIntoViewIfNeeded();

const next = navigation.getByRole('link', { name: /Next/ });
await expect(next).toBeVisible();
await expect(next).toContainText('The Node.js Event Emitter');

await next.click();

await expect(page).toHaveURL(
/\/learn\/asynchronous-work\/the-nodejs-event-emitter$/
);
});

test('the menu button reveals the site links', async ({ page }) => {
await page.goto(ARTICLE);

Expand Down
Loading