@@ -95,8 +95,8 @@ export const ArticlePage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{includesPlatformSpecificContent && <PlatformPicker variant="underlinenav" />}
|
||||
{includesToolSpecificContent && <ToolPicker variant="underlinenav" />}
|
||||
{includesPlatformSpecificContent && <PlatformPicker />}
|
||||
{includesToolSpecificContent && <ToolPicker />}
|
||||
|
||||
{product && (
|
||||
<Callout
|
||||
|
||||
126
components/article/InArticlePicker.tsx
Normal file
126
components/article/InArticlePicker.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { UnderlineNav } from '@primer/react'
|
||||
import { sendEvent, EventType } from 'components/lib/events'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
type Option = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
type Props = {
|
||||
// Use this if not specified on the query string
|
||||
defaultValue?: string
|
||||
// Use this if not specified on the query string or no cookie
|
||||
fallbackValue: string
|
||||
cookieKey: string
|
||||
queryStringKey: string
|
||||
onValue: (value: string) => void
|
||||
preferenceName: string
|
||||
options: Option[]
|
||||
ariaLabel: string
|
||||
}
|
||||
export const InArticlePicker = ({
|
||||
defaultValue,
|
||||
fallbackValue,
|
||||
cookieKey,
|
||||
queryStringKey,
|
||||
onValue,
|
||||
preferenceName,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: Props) => {
|
||||
const router = useRouter()
|
||||
const { query, locale } = router
|
||||
const [currentValue, setCurrentValue] = useState('')
|
||||
|
||||
// Run on mount for client-side only features
|
||||
useEffect(() => {
|
||||
const raw = query[queryStringKey]
|
||||
let value = ''
|
||||
if (raw) {
|
||||
if (Array.isArray(raw)) value = raw[0]
|
||||
else value = raw
|
||||
}
|
||||
// Only pick it up from the possible query string if its value
|
||||
// is a valid option.
|
||||
const possibleValues = options.map((option) => option.value)
|
||||
if (!value || !possibleValues.includes(value)) {
|
||||
const cookieValue = Cookies.get(cookieKey)
|
||||
if (defaultValue) {
|
||||
value = defaultValue
|
||||
} else if (cookieValue && possibleValues.includes(cookieValue)) {
|
||||
value = cookieValue
|
||||
} else {
|
||||
value = fallbackValue
|
||||
}
|
||||
}
|
||||
setCurrentValue(value)
|
||||
}, [query, fallbackValue, defaultValue, options])
|
||||
|
||||
useEffect(() => {
|
||||
// This will make the hook run this callback on mount and on change.
|
||||
// That's important because even though the user hasn't interacted
|
||||
// and made an overriding choice, we still want to run this callback
|
||||
// because the page might need to be corrected based on *a* choice
|
||||
// independent of whether it's a change.
|
||||
if (currentValue) {
|
||||
onValue(currentValue)
|
||||
}
|
||||
}, [currentValue])
|
||||
|
||||
const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?')
|
||||
|
||||
function onClickChoice(value: string) {
|
||||
const params = new URLSearchParams(asPathQuery)
|
||||
params.set(queryStringKey, value)
|
||||
const newPath = `/${locale}${asPathRoot}?${params}`
|
||||
router.push(newPath, undefined, { shallow: true, locale })
|
||||
|
||||
sendEvent({
|
||||
type: EventType.preference,
|
||||
preference_name: preferenceName,
|
||||
preference_value: value,
|
||||
})
|
||||
|
||||
Cookies.set(cookieKey, value, {
|
||||
sameSite: 'strict',
|
||||
secure: document.location.protocol !== 'http:',
|
||||
expires: 365,
|
||||
})
|
||||
}
|
||||
|
||||
const sharedContainerProps = {
|
||||
'data-testid': `${queryStringKey}-picker`,
|
||||
'aria-label': ariaLabel,
|
||||
[`data-default-${queryStringKey}`]: defaultValue || '',
|
||||
className: 'mb-4',
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(asPathQuery)
|
||||
|
||||
return (
|
||||
<UnderlineNav {...sharedContainerProps}>
|
||||
{options.map((option) => {
|
||||
params.set(queryStringKey, option.value)
|
||||
const linkProps = {
|
||||
[`data-${queryStringKey}`]: option.value,
|
||||
}
|
||||
return (
|
||||
<UnderlineNav.Link
|
||||
href={`?${params}`}
|
||||
key={option.value}
|
||||
selected={option.value === currentValue}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClickChoice(option.value)
|
||||
}}
|
||||
{...linkProps}
|
||||
>
|
||||
{option.label}
|
||||
</UnderlineNav.Link>
|
||||
)
|
||||
})}
|
||||
</UnderlineNav>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { SubNav, TabNav, UnderlineNav } from '@primer/react'
|
||||
import { sendEvent, EventType } from 'components/lib/events'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useArticleContext } from 'components/context/ArticleContext'
|
||||
import { parseUserAgent } from 'components/lib/user-agent'
|
||||
import { InArticlePicker } from './InArticlePicker'
|
||||
|
||||
const platformQueryKey = 'platform'
|
||||
const platforms = [
|
||||
{ id: 'mac', label: 'Mac' },
|
||||
{ id: 'windows', label: 'Windows' },
|
||||
{ id: 'linux', label: 'Linux' },
|
||||
{ value: 'mac', label: 'Mac' },
|
||||
{ value: 'windows', label: 'Windows' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
]
|
||||
|
||||
// Nota bene: platform === os
|
||||
@@ -22,7 +19,7 @@ const platforms = [
|
||||
function showPlatformSpecificContent(platform: string) {
|
||||
const markdowns = Array.from(document.querySelectorAll<HTMLElement>('.extended-markdown'))
|
||||
markdowns
|
||||
.filter((el) => platforms.some((platform) => el.classList.contains(platform.id)))
|
||||
.filter((el) => platforms.some((platform) => el.classList.contains(platform.value)))
|
||||
.forEach((el) => {
|
||||
el.style.display = el.classList.contains(platform) ? '' : 'none'
|
||||
})
|
||||
@@ -31,7 +28,7 @@ function showPlatformSpecificContent(platform: string) {
|
||||
// example: <span class="platform-mac">inline content</span>
|
||||
const platformEls = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
platforms.map((platform) => `.platform-${platform.id}`).join(', ')
|
||||
platforms.map((platform) => `.platform-${platform.value}`).join(', ')
|
||||
)
|
||||
)
|
||||
platformEls.forEach((el) => {
|
||||
@@ -39,153 +36,39 @@ function showPlatformSpecificContent(platform: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// uses the order of the supportedPlatforms array to
|
||||
// determine the default platform
|
||||
const getFallbackPlatform = (detectedPlatforms: Array<string>): string => {
|
||||
const foundPlatform = platforms.find((platform) => detectedPlatforms.includes(platform.id))
|
||||
return foundPlatform?.id || 'linux'
|
||||
}
|
||||
|
||||
type Props = {
|
||||
variant?: 'subnav' | 'tabnav' | 'underlinenav'
|
||||
}
|
||||
export const PlatformPicker = ({ variant = 'subnav' }: Props) => {
|
||||
const router = useRouter()
|
||||
const { query, asPath, locale } = router
|
||||
export const PlatformPicker = () => {
|
||||
const { defaultPlatform, detectedPlatforms } = useArticleContext()
|
||||
const [currentPlatform, setCurrentPlatform] = useState(defaultPlatform || '')
|
||||
|
||||
// Run on mount for client-side only features
|
||||
const [defaultUA, setDefaultUA] = useState('')
|
||||
useEffect(() => {
|
||||
let userAgent = parseUserAgent().os
|
||||
if (userAgent === 'ios') {
|
||||
userAgent = 'mac'
|
||||
}
|
||||
setDefaultUA(userAgent)
|
||||
}, [])
|
||||
|
||||
// If it's a valid platform option, set platform from query param
|
||||
let platform =
|
||||
query[platformQueryKey] && Array.isArray(query[platformQueryKey])
|
||||
? query[platformQueryKey][0]
|
||||
: query[platformQueryKey] || ''
|
||||
if (!platform || !platforms.some((platform) => platform.id === query.platform)) {
|
||||
platform = defaultPlatform || Cookies.get('osPreferred') || userAgent || 'linux'
|
||||
}
|
||||
// Defensively, just in case some article happens to have an array
|
||||
// but for some reasons, it might be empty, let's not have a picker
|
||||
// at all.
|
||||
if (!detectedPlatforms.length) return null
|
||||
|
||||
setCurrentPlatform(platform)
|
||||
|
||||
// always trigger this on initial render. if the default doesn't change the other useEffect won't fire
|
||||
showPlatformSpecificContent(platform)
|
||||
}, [asPath])
|
||||
|
||||
// Make sure we've always selected a platform that exists in the article
|
||||
useEffect(() => {
|
||||
// Only check *after* current platform has been determined
|
||||
if (currentPlatform && !detectedPlatforms.includes(currentPlatform)) {
|
||||
setCurrentPlatform(getFallbackPlatform(detectedPlatforms))
|
||||
}
|
||||
}, [currentPlatform, detectedPlatforms.join(',')])
|
||||
|
||||
const onClickPlatform = useCallback(
|
||||
(platform: string) => {
|
||||
// Set platform in query param without altering other query params
|
||||
const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?')
|
||||
const params = new URLSearchParams(asPathQuery)
|
||||
params.set(platformQueryKey, platform)
|
||||
const newPath = `/${locale}${asPathRoot}?${params}`
|
||||
router.push(newPath, undefined, { shallow: true, locale })
|
||||
|
||||
sendEvent({
|
||||
type: EventType.preference,
|
||||
preference_name: 'os',
|
||||
preference_value: platform,
|
||||
})
|
||||
|
||||
Cookies.set('osPreferred', platform, {
|
||||
sameSite: 'strict',
|
||||
secure: document.location.protocol !== 'http:',
|
||||
expires: 365,
|
||||
})
|
||||
},
|
||||
[asPath, locale]
|
||||
)
|
||||
|
||||
// only show platforms that are in the current article
|
||||
const platformOptions = platforms.filter((platform) => detectedPlatforms.includes(platform.id))
|
||||
|
||||
const sharedContainerProps = {
|
||||
'data-testid': 'platform-picker',
|
||||
'aria-label': 'Platform picker',
|
||||
'data-default-platform': defaultPlatform,
|
||||
className: 'mb-4',
|
||||
}
|
||||
|
||||
if (variant === 'subnav') {
|
||||
return (
|
||||
<SubNav {...sharedContainerProps}>
|
||||
<SubNav.Links>
|
||||
{platformOptions.map((option) => {
|
||||
return (
|
||||
<SubNav.Link
|
||||
key={option.id}
|
||||
data-platform={option.id}
|
||||
as="button"
|
||||
selected={option.id === currentPlatform}
|
||||
onClick={() => {
|
||||
onClickPlatform(option.id)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</SubNav.Link>
|
||||
)
|
||||
})}
|
||||
</SubNav.Links>
|
||||
</SubNav>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'underlinenav') {
|
||||
const [, pathQuery = ''] = asPath.split('?')
|
||||
const params = new URLSearchParams(pathQuery)
|
||||
return (
|
||||
<UnderlineNav {...sharedContainerProps}>
|
||||
{platformOptions.map((option) => {
|
||||
params.set(platformQueryKey, option.id)
|
||||
return (
|
||||
<UnderlineNav.Link
|
||||
href={`?${params.toString()}`}
|
||||
key={option.id}
|
||||
data-platform={option.id}
|
||||
selected={option.id === currentPlatform}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClickPlatform(option.id)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</UnderlineNav.Link>
|
||||
)
|
||||
})}
|
||||
</UnderlineNav>
|
||||
)
|
||||
}
|
||||
const options = platforms.filter((platform) => detectedPlatforms.includes(platform.value))
|
||||
|
||||
return (
|
||||
<TabNav {...sharedContainerProps}>
|
||||
{platformOptions.map((option) => {
|
||||
return (
|
||||
<TabNav.Link
|
||||
key={option.id}
|
||||
data-platform={option.id}
|
||||
as="button"
|
||||
selected={option.id === currentPlatform}
|
||||
onClick={() => {
|
||||
onClickPlatform(option.id)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</TabNav.Link>
|
||||
)
|
||||
})}
|
||||
</TabNav>
|
||||
<InArticlePicker
|
||||
defaultValue={defaultPlatform}
|
||||
fallbackValue={
|
||||
detectedPlatforms.includes(defaultUA)
|
||||
? defaultUA
|
||||
: detectedPlatforms[detectedPlatforms.length - 1]
|
||||
}
|
||||
cookieKey="osPreferred"
|
||||
queryStringKey={platformQueryKey}
|
||||
onValue={showPlatformSpecificContent}
|
||||
preferenceName="os"
|
||||
ariaLabel="Platform"
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import Cookies from 'js-cookie'
|
||||
import { UnderlineNav } from '@primer/react'
|
||||
import { sendEvent, EventType } from 'components/lib/events'
|
||||
import { preserveAnchorNodePosition } from 'scroll-anchoring'
|
||||
|
||||
import { useArticleContext } from 'components/context/ArticleContext'
|
||||
import { InArticlePicker } from './InArticlePicker'
|
||||
|
||||
// example: http://localhost:4000/en/codespaces/developing-in-codespaces/creating-a-codespace
|
||||
|
||||
@@ -48,97 +44,29 @@ function getDefaultTool(defaultTool: string | undefined, detectedTools: Array<st
|
||||
}
|
||||
|
||||
const toolQueryKey = 'tool'
|
||||
type Props = {
|
||||
variant?: 'subnav' | 'tabnav' | 'underlinenav'
|
||||
}
|
||||
export const ToolPicker = ({ variant = 'subnav' }: Props) => {
|
||||
const router = useRouter()
|
||||
const { asPath, query, locale } = router
|
||||
export const ToolPicker = () => {
|
||||
// allTools comes from the ArticleContext which contains the list of tools available
|
||||
const { defaultTool, detectedTools, allTools } = useArticleContext()
|
||||
const [currentTool, setCurrentTool] = useState(getDefaultTool(defaultTool, detectedTools))
|
||||
|
||||
const sharedContainerProps = {
|
||||
'data-testid': 'tool-picker',
|
||||
'aria-label': 'Tool picker',
|
||||
'data-default-tool': defaultTool,
|
||||
className: 'mb-4',
|
||||
}
|
||||
if (!detectedTools.length) return null
|
||||
|
||||
// Run on mount for client-side only features
|
||||
useEffect(() => {
|
||||
// If the user selected a tool preference and the tool is present on this page
|
||||
// Has to be client-side only for cookie reading
|
||||
const cookieValue = Cookies.get('toolPreferred')
|
||||
if (cookieValue && detectedTools.includes(cookieValue)) {
|
||||
setCurrentTool(cookieValue)
|
||||
}
|
||||
}, [])
|
||||
const options = detectedTools.map((value) => {
|
||||
return { value, label: allTools[value] }
|
||||
})
|
||||
|
||||
// Whenever the currentTool is changed, update the article content or selected tool from query param
|
||||
useEffect(() => {
|
||||
preserveAnchorNodePosition(document, () => {
|
||||
showToolSpecificContent(currentTool, Object.keys(allTools))
|
||||
})
|
||||
|
||||
// If tool from query is a valid option, use it
|
||||
const tool =
|
||||
query[toolQueryKey] && Array.isArray(query[toolQueryKey])
|
||||
? query[toolQueryKey][0]
|
||||
: query[toolQueryKey] || ''
|
||||
if (tool && detectedTools.includes(tool)) {
|
||||
setCurrentTool(tool)
|
||||
}
|
||||
}, [currentTool, asPath])
|
||||
|
||||
const onClickTool = useCallback(
|
||||
(tool: string) => {
|
||||
// Set tool in query param without altering other query params
|
||||
const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?')
|
||||
const params = new URLSearchParams(asPathQuery)
|
||||
params.set(toolQueryKey, tool)
|
||||
const newPath = `/${locale}${asPathRoot}?${params}`
|
||||
router.push(newPath, undefined, { shallow: true, locale })
|
||||
|
||||
sendEvent({
|
||||
type: EventType.preference,
|
||||
preference_name: 'application',
|
||||
preference_value: tool,
|
||||
})
|
||||
Cookies.set('toolPreferred', tool, {
|
||||
sameSite: 'strict',
|
||||
secure: document.location.protocol !== 'http:',
|
||||
expires: 365,
|
||||
})
|
||||
},
|
||||
[asPath, locale]
|
||||
return (
|
||||
<InArticlePicker
|
||||
fallbackValue={getDefaultTool(defaultTool, detectedTools)}
|
||||
cookieKey="toolPreferred"
|
||||
queryStringKey={toolQueryKey}
|
||||
onValue={(value: string) => {
|
||||
preserveAnchorNodePosition(document, () => {
|
||||
showToolSpecificContent(value, Object.keys(allTools))
|
||||
})
|
||||
}}
|
||||
preferenceName="application"
|
||||
ariaLabel="Tool"
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
|
||||
if (variant === 'underlinenav') {
|
||||
const [, pathQuery = ''] = asPath.split('?')
|
||||
const params = new URLSearchParams(pathQuery)
|
||||
return (
|
||||
<UnderlineNav {...sharedContainerProps}>
|
||||
{detectedTools.map((tool) => {
|
||||
params.set(toolQueryKey, tool)
|
||||
return (
|
||||
<UnderlineNav.Link
|
||||
href={`?${params.toString()}`}
|
||||
key={tool}
|
||||
data-tool={tool}
|
||||
selected={tool === currentTool}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClickTool(tool)
|
||||
}}
|
||||
>
|
||||
{allTools[tool]}
|
||||
</UnderlineNav.Link>
|
||||
)
|
||||
})}
|
||||
</UnderlineNav>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user