diff --git a/components/article/ArticlePage.tsx b/components/article/ArticlePage.tsx index de1d2e5f87..f7f80ecf19 100644 --- a/components/article/ArticlePage.tsx +++ b/components/article/ArticlePage.tsx @@ -95,8 +95,8 @@ export const ArticlePage = () => { )} - {includesPlatformSpecificContent && } - {includesToolSpecificContent && } + {includesPlatformSpecificContent && } + {includesToolSpecificContent && } {product && ( 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 ( + + {options.map((option) => { + params.set(queryStringKey, option.value) + const linkProps = { + [`data-${queryStringKey}`]: option.value, + } + return ( + { + event.preventDefault() + onClickChoice(option.value) + }} + {...linkProps} + > + {option.label} + + ) + })} + + ) +} diff --git a/components/article/PlatformPicker.tsx b/components/article/PlatformPicker.tsx index 234b2e5af3..579542eb3c 100644 --- a/components/article/PlatformPicker.tsx +++ b/components/article/PlatformPicker.tsx @@ -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('.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: inline content const platformEls = Array.from( document.querySelectorAll( - 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 => { - 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 ( - - - {platformOptions.map((option) => { - return ( - { - onClickPlatform(option.id) - }} - > - {option.label} - - ) - })} - - - ) - } - - if (variant === 'underlinenav') { - const [, pathQuery = ''] = asPath.split('?') - const params = new URLSearchParams(pathQuery) - return ( - - {platformOptions.map((option) => { - params.set(platformQueryKey, option.id) - return ( - { - event.preventDefault() - onClickPlatform(option.id) - }} - > - {option.label} - - ) - })} - - ) - } + const options = platforms.filter((platform) => detectedPlatforms.includes(platform.value)) return ( - - {platformOptions.map((option) => { - return ( - { - onClickPlatform(option.id) - }} - > - {option.label} - - ) - })} - + ) } diff --git a/components/article/ToolPicker.tsx b/components/article/ToolPicker.tsx index 753a71a406..171865fb8e 100644 --- a/components/article/ToolPicker.tsx +++ b/components/article/ToolPicker.tsx @@ -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 { - 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 ( + { + 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 ( - - {detectedTools.map((tool) => { - params.set(toolQueryKey, tool) - return ( - { - event.preventDefault() - onClickTool(tool) - }} - > - {allTools[tool]} - - ) - })} - - ) - } - - return null }