@@ -14,8 +14,8 @@ import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
1414import { useTranslation } from '@/languages/components/useTranslation'
1515
1616import styles from './VersionPicker.module.scss'
17- // The header variant's trigger, menu surface and rows are shared with the language
18- // picker so the two dropdowns cannot drift apart .
17+ // The header variant shares HeaderPicker.module.scss with the language picker,
18+ // so the two dropdowns stay in sync .
1919import headerStyles from '@/frame/components/page-header/HeaderPicker.module.scss'
2020
2121type Props = {
@@ -27,8 +27,8 @@ type VersionPickerLink = {
2727 text : string
2828 selected : boolean
2929 href : string
30- // Brand's ActionMenu identifies the chosen row by string value, so every row needs
31- // one. Versions use their own version name; the two extra rows use sentinels.
30+ // Brand's ActionMenu identifies rows by string value. Versions use their version
31+ // name; extra rows use sentinels.
3232 value : string
3333 extra : {
3434 arrow : boolean
@@ -41,82 +41,69 @@ type VersionPickerLink = {
4141const ALL_RELEASES_VALUE = 'all-enterprise-releases'
4242const ABOUT_VERSIONS_VALUE = 'about-versions'
4343
44- // Brand clones ActionMenu.Button with its own ref, so the trigger cannot be reached
45- // through a React ref. A stable test id keeps both the Escape handler and the tests
46- // off Brand's hashed CSS class names.
44+ // Brand clones ActionMenu.Button with its own ref, so React refs cannot reach the
45+ // trigger. A stable test id keeps the Escape handler and tests off hashed CSS classes.
4746const HEADER_TRIGGER_TESTID = 'version-picker-button'
4847
4948type PlanMenuItemProps = {
5049 item : VersionPickerLink
51- // Injected by ActionMenu.Overlay, which clones each of its direct children with the
52- // select handler and the selection type derived from `selectionVariant`.
50+ // ActionMenu.Overlay injects handler and type into each direct child.
5351 handler ?: ( value : string ) => void
5452 type ?: 'none' | 'single' | 'link'
5553}
5654
55+ // Extra rows opt out of Brand selection semantics because axe rejects aria-checked
56+ // on menuitem, and Brand derives both role and aria-checked from type.
5757const PlanMenuItem = ( { item, handler, type } : PlanMenuItemProps ) => {
5858 const isExtra = Boolean ( item . extra . arrow || item . extra . info )
5959
6060 return (
6161 < BrandActionMenu . Item
6262 handler = { handler }
63- // Brand derives both `role` and `aria-checked` from `type`. The two extra rows
64- // navigate elsewhere instead of choosing a version, so under the injected
65- // 'single' they would render role="menuitem" *plus* aria-checked — which axe
66- // rejects, since aria-checked is not an allowed attribute on menuitem. Overlay
67- // injects `type` into its direct children only, so this wrapper is the seam
68- // where a single row can opt out of selection semantics.
6963 type = { isExtra ? 'none' : type }
7064 value = { item . value }
7165 selected = { item . selected }
7266 className = { cx (
7367 headerStyles . headerMenuItem ,
7468 item . selected && headerStyles . headerMenuItemSelected ,
7569 ) }
76- // Only spread `role` for the extras: passing `role={undefined}` would override
77- // the role Brand computes and leave the version rows with no role at all.
70+ // Only spread role for extras; role undefined overrides Brand's computed role.
7871 { ...( isExtra ? { role : 'menuitem' } : { } ) }
7972 >
8073 < span data-testid = "version-picker-item" className = { headerStyles . headerMenuItemLabel } >
8174 { item . text }
8275 { item . extra . arrow && < ArrowRightIcon verticalAlign = "middle" size = { 15 } className = "ml-1" /> }
8376 { item . extra . info && < InfoIcon verticalAlign = "middle" size = { 15 } className = "ml-1" /> }
8477 </ span >
85- { /* The design marks the current plan with a trailing green dot instead of
86- Brand's leading check icon, which the stylesheet hides. */ }
78+ { /* HeaderPicker.module.scss hides Brand's leading check icon; design uses a trailing green dot. */ }
8779 { item . selected && < DotFillIcon size = { 16 } className = { headerStyles . headerMenuItemDot } /> }
8880 </ BrandActionMenu . Item >
8981 )
9082}
9183
92- // The rule between the version rows and the two navigation rows. Brand has no divider
93- // child, and ActionMenu.Overlay clones every direct child with `handler` and `type`,
94- // so this wrapper takes no props at all: the injected ones are swallowed here instead
95- // of landing on the DOM node. The <li> carries no tabIndex and no `data-value`, so
96- // Brand's focus zone and its Enter handler both skip it — and it is never the menu's
97- // first or last <li>, which are the two rows Brand wires its arrow-key wrap-around to.
84+ // Brand lacks a divider child, and ActionMenu.Overlay injects handler and type into
85+ // every direct child. This wrapper swallows those props so they do not reach the li.
86+ // Without tabIndex or data-value, Brand's focus zone and Enter handler skip the
87+ // separator. The caller keeps it away from the first and last li, which Brand uses
88+ // for arrow-key wrap-around.
9889const PlanMenuSeparator = ( ) => < li role = "separator" className = { headerStyles . headerMenuSeparator } />
9990
91+ // VersionPicker uses startsWith to identify Enterprise Server because VersionItem
92+ // omits hasNumberedReleases. The label says "version" for Enterprise Server because
93+ // versionTitle includes the numbered release; a "plan" label would make screen
94+ // readers announce "Select your plan: Enterprise Server 3.19".
10095export const VersionPicker = ( { variant = 'default' , onNavigate } : Props ) => {
10196 const router = useRouter ( )
10297 const { currentVersion } = useVersion ( )
10398 const mainContext = useMainContext ( )
10499 const [ open , setOpen ] = useState ( false )
105100 const pickerId = useId ( )
106101 const isHeader = variant === 'header'
107- // Use TypeScript's "not null assertion" because mainContext.page should
108- // be present in mainContext if it's gotten to the stage of React
109- // rendering.
102+ // React rendering only starts after MainContext adds page.
110103 const page = mainContext . page !
111104 const { allVersions, enterpriseServerVersions } = mainContext
112105 const { t } = useTranslation ( [ 'pages' , 'picker' ] )
113106
114- // The same control chooses a plan on dotcom and Enterprise Cloud but a numbered
115- // release on Enterprise Server, where `versionTitle` is `${planTitle} ${release}`.
116- // A single "Select your plan:" would announce "Select your plan: Enterprise
117- // Server 3.19" to screen readers. Uses the same `startsWith` predicate as
118- // `hasEnterpriseVersions` below: `hasNumberedReleases` is set on the runtime
119- // version object but is not declared on the `VersionItem` type.
120107 const pickerLabel = currentVersion . startsWith ( 'enterprise-server' )
121108 ? t ( 'version_picker_label' )
122109 : t ( 'plan_picker_label' )
@@ -195,7 +182,7 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => {
195182 const selectedOption = allLinks . find ( ( item ) => item . selected )
196183
197184 const handleVersionSelect = ( item : VersionPickerLink ) => {
198- // Save the user's version preference when they actively select one
185+ // Navigation rows leave the existing version preference alone.
199186 if ( item . extra ?. version ) {
200187 try {
201188 Cookies . set ( USER_VERSION_COOKIE_NAME , item . extra . version )
@@ -204,26 +191,20 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => {
204191 }
205192 }
206193 setOpen ( false )
207- // Navigate after setting cookie
194+ // Set the cookie before navigation so the next page can read the preference.
208195 if ( item . href ) {
209196 onNavigate ?.( )
210197 router . push ( item . href )
211198 }
212199 }
213200
214201 if ( isHeader ) {
215- // The Figma dropdown node draws no divider, but the rule that separated the
216- // versions from the two navigation rows is kept from the @primer /react menu this
217- // replaced. The filter keeps it from ever becoming the menu's first or last row:
218- // Brand focuses the first <li> and binds its arrow-key wrap-around to the first
219- // and the last, and neither should land on a separator.
202+ // Keep the separator from the default picker, but not where Brand focuses or wraps rows.
220203 const headerLinks = allLinks . filter (
221204 ( item , index ) => ! item . divider || ( index > 0 && index < allLinks . length - 1 ) ,
222205 )
223206
224- // Brand reports the chosen row by value. Routing every row — the two extras
225- // included — back through handleVersionSelect keeps navigation client-side
226- // instead of letting the extras become anchors that reload the page.
207+ // Route extra rows through handleVersionSelect so they stay client-side.
227208 const handleHeaderSelect = ( value : string ) => {
228209 const item = headerLinks . find ( ( link ) => link . value === value )
229210 if ( item ) {
@@ -239,15 +220,10 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => {
239220 )
240221 if ( trigger ?. getAttribute ( 'aria-expanded' ) !== 'true' ) return
241222
242- // Brand's ActionMenu and SubdomainNavBar both listen for Escape on `document`
243- // and neither honours defaultPrevented, so a single Escape would close this
244- // picker *and* the surrounding narrow menu. Stopping the event here — while it
245- // is still in its capture phase, before it reaches either listener — leaves the
246- // outer menu open. Brand has no controlled `open` prop, so the picker is closed
247- // through its own trigger: focus it first so focus stays put, then click it to
248- // let ActionMenu toggle itself shut.
223+ // Stop Escape in capture so SubdomainNavBar's document listener leaves the narrow menu open.
249224 event . preventDefault ( )
250225 event . stopPropagation ( )
226+ // Brand has no controlled open prop, so click its focused trigger to close it.
251227 trigger . focus ( )
252228 trigger . click ( )
253229 }
0 commit comments