Skip to content
Closed
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
128 changes: 125 additions & 3 deletions src/components/Content/Switcher/client/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import SwitcherAstro from '@components/Content/Switcher/index.astro'
import type { ContentSwitcherElement } from '@components/Content/Switcher/client'
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
import { executeRender } from '@test/unit/helpers/litRuntime'
import { executeRender, type RenderResult } from '@test/unit/helpers/litRuntime'

type SwitcherModule = WebComponentModule<ContentSwitcherElement>

Expand All @@ -18,7 +18,10 @@

const runComponentRender = async (
args: Parameters<AstroContainer['renderToString']>[1],
assertion: (_context: { element: ContentSwitcherElement }) => Promise<void> | void
assertion: (_context: {
element: ContentSwitcherElement
renderResult: RenderResult
}) => Promise<void> | void
): Promise<void> => {
await executeRender<SwitcherModule>({
container,
Expand All @@ -30,7 +33,7 @@
},
assert: async ({ element, module, renderResult }) => {
expect(renderResult).toContain(`<${module.registeredName}`)
await assertion({ element })
await assertion({ element, renderResult })
},
})
}
Expand Down Expand Up @@ -118,4 +121,123 @@
}
)
})

test('disables the deep-dive label and toggle when the alternate variant is missing', async () => {
await runComponentRender(
{
props: {
currentVariant: 'overview',
slug: 'article-without-deep-dive',
hasAlternate: false,
},
},
async ({ element }) => {
const overviewLink = element.querySelector('#overview-label') as HTMLAnchorElement | null
const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null
const switchLink = element.querySelector(
'[data-switcher-toggle]'
) as HTMLAnchorElement | null

expect(overviewLink?.getAttribute('href')).toBe('/articles/article-without-deep-dive')
expect(overviewLink?.getAttribute('aria-disabled')).toBe('false')
expect(deepDiveLink?.getAttribute('href')).toBeNull()
expect(deepDiveLink?.getAttribute('aria-disabled')).toBe('true')
expect(switchLink?.getAttribute('href')).toBeNull()
expect(switchLink?.getAttribute('aria-disabled')).toBe('true')
}
)
})

test('disables the overview label when the alternate variant is missing on deep-dive pages', async () => {
await runComponentRender(
{
props: {
currentVariant: 'deep-dive',
slug: 'deep-dive-without-overview',
hasAlternate: false,
},
},
async ({ element }) => {
const overviewLink = element.querySelector('#overview-label') as HTMLAnchorElement | null
const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null
const switchLink = element.querySelector(
'[data-switcher-toggle]'
) as HTMLAnchorElement | null

expect(deepDiveLink?.getAttribute('href')).toBe('/deep-dive/deep-dive-without-overview')
expect(deepDiveLink?.getAttribute('aria-disabled')).toBe('false')
expect(overviewLink?.getAttribute('href')).toBeNull()
expect(overviewLink?.getAttribute('aria-disabled')).toBe('true')
expect(switchLink?.getAttribute('href')).toBeNull()
expect(switchLink?.getAttribute('aria-disabled')).toBe('true')
}
)
})

test('does not prefetch the alternate variant href when it is missing', async () => {
await runComponentRender(
{
props: {
currentVariant: 'overview',
slug: 'article-without-deep-dive',
hasAlternate: false,
},
},
async () => {
await new Promise(resolve => setTimeout(resolve, 200))

const prefetchLink = document.head.querySelector(
'link[rel="prefetch"][href="/deep-dive/article-without-deep-dive"]'
) as HTMLLinkElement | null

expect(prefetchLink).toBeNull()
}
)
})

test('renders has-alternate="false" for articles without a deep-dive entry', async () => {
await runComponentRender(
{
props: {
path: '/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation',
},
},
async ({ element, renderResult }) => {
expect(renderResult).toContain('has-alternate="false"')

const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null
const switchLink = element.querySelector(
'[data-switcher-toggle]'
) as HTMLAnchorElement | null

expect(deepDiveLink?.getAttribute('href')).toBeNull()
expect(switchLink?.getAttribute('href')).toBeNull()
}
)
})

test('renders the has-alternate attribute for articles with a deep-dive entry', async () => {
await runComponentRender(
{
props: {
path: '/articles/reverse-engineering-documentation-legacy-systems',
},
},
async ({ element, renderResult }) => {
expect(renderResult).toContain('has-alternate="true"')

Check failure on line 227 in src/components/Content/Switcher/client/__tests__/index.spec.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

src/components/Content/Switcher/client/__tests__/index.spec.ts > ContentSwitcherElement > renders the has-alternate attribute for articles with a deep-dive entry

AssertionError: expected '<content-switcher class="print:hidden…' to contain 'has-alternate="true"' Expected: "has-alternate="true"" Received: "<content-switcher class="print:hidden!" current-variant="overview" slug="reverse-engineering-documentation-legacy-systems" has-alternate="false"></content-switcher><script type="module" src="/home/runner/work/astro.webstackbuilders.com/astro.webstackbuilders.com/src/components/Content/Switcher/index.astro?astro&type=script&index=0&lang.ts"></script>" ❯ src/components/Content/Switcher/client/__tests__/index.spec.ts:227:30 ❯ assert src/components/Content/Switcher/client/__tests__/index.spec.ts:36:15 ❯ assert test/unit/helpers/litRuntime.ts:310:10 ❯ test/unit/helpers/litRuntime.ts:248:9 ❯ withJsdomEnvironment test/unit/helpers/litRuntime.ts:147:10 ❯ renderInJsdom test/unit/helpers/litRuntime.ts:218:2 ❯ executeRender test/unit/helpers/litRuntime.ts:322:2 ❯ runComponentRender src/components/Content/Switcher/client/__tests__/index.spec.ts:26:5 ❯ src/components/Content/Switcher/client/__tests__/index.spec.ts:220:5

const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null
const switchLink = element.querySelector(
'[data-switcher-toggle]'
) as HTMLAnchorElement | null

expect(deepDiveLink?.getAttribute('href')).toBe(
'/deep-dive/reverse-engineering-documentation-legacy-systems'
)
expect(switchLink?.getAttribute('href')).toBe(
'/deep-dive/reverse-engineering-documentation-legacy-systems'
)
}
)
})
})
85 changes: 66 additions & 19 deletions src/components/Content/Switcher/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LitElement, html } from 'lit'
import { ifDefined } from 'lit/directives/if-defined.js'
import { defineCustomElement } from '@components/scripts/utils'
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
import { queryPrefetchLink } from './selectors'
Expand All @@ -7,6 +8,8 @@ export type ContentVariant = 'overview' | 'deep-dive'

const prefetchedHrefs = new Set<string>()

const disabledLinkClasses = 'pointer-events-none cursor-not-allowed opacity-50'

const normalizeSlug = (slug: string): string => slug.replace(/^\/+|\/+$/g, '')

const queueIdlePrefetch = (work: () => void): void => {
Expand Down Expand Up @@ -82,15 +85,29 @@ export class ContentSwitcherElement extends LitElement {
static override properties = {
currentVariant: { type: String, attribute: 'current-variant' },
slug: { type: String },
hasAlternate: {
type: Boolean,
attribute: 'has-alternate',
/**
* Astro renders boolean attributes as has-alternate="true" or
* has-alternate="false", so the default Lit converter (attribute
* presence) is not sufficient on its own.
*/
converter: {
fromAttribute: (value: string | null) => value !== null && value !== 'false',
},
},
}

declare currentVariant: ContentVariant
declare slug: string
declare hasAlternate: boolean

constructor() {
super()
this.currentVariant = 'overview'
this.slug = ''
this.hasAlternate = false
}

protected override createRenderRoot() {
Expand All @@ -109,6 +126,10 @@ export class ContentSwitcherElement extends LitElement {
}

private prefetchAlternateVariant(): void {
if (!this.hasAlternate) {
return
}

const alternateVariant = this.currentVariant === 'deep-dive' ? 'overview' : 'deep-dive'
const href = buildVariantHref(alternateVariant, this.slug)

Expand All @@ -123,42 +144,68 @@ export class ContentSwitcherElement extends LitElement {
const deepDiveHref = buildVariantHref('deep-dive', this.slug)
const switchHref = isDeepDive ? overviewHref : deepDiveHref

/**
* When the alternate variant route does not exist, its label link and the
* toggle are rendered inert so visitors cannot navigate to a 404 page.
*/
const overviewLinkDisabled = !this.hasAlternate && isDeepDive
const deepDiveLinkDisabled = !this.hasAlternate && !isDeepDive

const commonLabelClasses = 'text-xs no-underline hover:no-underline focus-visible:no-underline'

const overviewLabelClass = isDeepDive
? 'content-switcher-label content-switcher-label--overview text-content-offset hover:text-content' +
' ' +
commonLabelClasses
: 'content-switcher-label content-switcher-label--overview text-page-inverse' +
' ' +
commonLabelClasses

const deepDiveLabelClass = isDeepDive
? 'content-switcher-label content-switcher-label--deep-dive text-page-inverse' +
' ' +
commonLabelClasses
: 'content-switcher-label content-switcher-label--deep-dive text-content-offset hover:text-content' +
' ' +
commonLabelClasses
const overviewLabelClass =
(isDeepDive
? 'content-switcher-label content-switcher-label--overview text-content-offset hover:text-content' +
' ' +
commonLabelClasses
: 'content-switcher-label content-switcher-label--overview text-page-inverse' +
' ' +
commonLabelClasses) + (overviewLinkDisabled ? ' ' + disabledLinkClasses : '')

const deepDiveLabelClass =
(isDeepDive
? 'content-switcher-label content-switcher-label--deep-dive text-page-inverse' +
' ' +
commonLabelClasses
: 'content-switcher-label content-switcher-label--deep-dive text-content-offset hover:text-content' +
' ' +
commonLabelClasses) + (deepDiveLinkDisabled ? ' ' + disabledLinkClasses : '')

const switchTrackClass =
'content-switcher-track group relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-page-inverse transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary' +
(this.hasAlternate ? '' : ' ' + disabledLinkClasses)

return html`
<div class="flex items-center gap-3" role="radiogroup" aria-label="Content Variant">
<a class="${overviewLabelClass}" id="overview-label" href="${overviewHref}">Overview</a>
<a
class="${overviewLabelClass}"
id="overview-label"
href="${ifDefined(overviewLinkDisabled ? undefined : overviewHref)}"
aria-disabled="${overviewLinkDisabled ? 'true' : 'false'}"
>Overview</a
>

<a
href="${switchHref}"
href="${ifDefined(this.hasAlternate ? switchHref : undefined)}"
role="switch"
aria-checked="${isDeepDive ? 'true' : 'false'}"
aria-disabled="${this.hasAlternate ? 'false' : 'true'}"
aria-label="Toggle between Overview and Deep Dive"
class="content-switcher-track group relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-page-inverse transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary"
class="${switchTrackClass}"
data-switcher-toggle
>
<span
class="content-switcher-thumb pointer-events-none inline-block size-3 -translate-x-1.25 rounded-full bg-page-base shadow-sm ring-0 transition-transform"
></span>
</a>

<a class="${deepDiveLabelClass}" id="deep-dive-label" href="${deepDiveHref}">Deep Dive</a>
<a
class="${deepDiveLabelClass}"
id="deep-dive-label"
href="${ifDefined(deepDiveLinkDisabled ? undefined : deepDiveHref)}"
aria-disabled="${deepDiveLinkDisabled ? 'true' : 'false'}"
>Deep Dive</a
>
</div>
`
}
Expand Down
20 changes: 17 additions & 3 deletions src/components/Content/Switcher/index.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { parseContentPath } from './server'
import { contentPathHasAlternateVariant, parseContentPath } from './server'
import { BuildError } from '@lib/errors/BuildError'
import './index.css'

Expand All @@ -10,20 +10,30 @@ export type Props = {
currentVariant?: 'overview' | 'deep-dive'
/** Legacy slug for client rendering */
slug?: string
/** Legacy override for whether the alternate variant route exists */
hasAlternate?: boolean
}

const { path, currentVariant: legacyCurrentVariant, slug: legacySlug = '' } = Astro.props
const {
path,
currentVariant: legacyCurrentVariant,
slug: legacySlug = '',
hasAlternate: legacyHasAlternate,
} = Astro.props

let currentVariant: 'overview' | 'deep-dive'
let slug: string
let hasAlternateVariant: boolean

if (path) {
const parsed = parseContentPath(path)
currentVariant = parsed.currentVariant === 'articles' ? 'overview' : 'deep-dive'
slug = parsed.slug
hasAlternateVariant = await contentPathHasAlternateVariant(path)
} else if (legacyCurrentVariant) {
currentVariant = legacyCurrentVariant
slug = legacySlug
hasAlternateVariant = legacyHasAlternate ?? true
} else {
throw new BuildError('Content/Switcher: expected either `path` or `currentVariant` prop.', {
phase: 'compilation',
Expand All @@ -33,7 +43,11 @@ if (path) {
}
---

<content-switcher class="print:hidden!" current-variant={currentVariant} slug={slug}
<content-switcher
class="print:hidden!"
current-variant={currentVariant}
slug={slug}
has-alternate={hasAlternateVariant}
></content-switcher>

<script>
Expand Down
Loading
Loading