Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
42a9731
fix: add radio group keyboard navigation
thomasluizon Sep 16, 2026
8cdef1b
fix(mobile): enable radio keyboard focus
thomasluizon Sep 16, 2026
109d93c
fix(mobile): preserve React Native release flags
thomasluizon Sep 16, 2026
144254c
Merge redesign/main into fix/ticket-543-radio-keys
thomasluizon Sep 16, 2026
3f274fe
fix(mobile): preserve rendered radio order
thomasluizon Sep 16, 2026
a559477
Merge redesign/main into fix/ticket-543-radio-keys
thomasluizon Sep 16, 2026
c990617
fix(mobile): follow native radio focus
thomasluizon Sep 16, 2026
386fd3c
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 16, 2026
522fb74
fix(mobile): type Android focus props
thomasluizon Sep 16, 2026
0cbcd18
fix(mobile): remove invisible radio focus target
thomasluizon Sep 16, 2026
e8eceaa
fix(mobile): preserve radio selection on focus entry
thomasluizon Sep 17, 2026
bb3ae15
test(mobile): distinguish radio entry from movement
thomasluizon Sep 17, 2026
69e4352
fix(web): avoid duplicate radio selection
thomasluizon Sep 17, 2026
f426d3f
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 17, 2026
fb520fb
fix(mobile): unify radio group navigation
thomasluizon Sep 17, 2026
9e8299b
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 17, 2026
457e380
fix(mobile): keep native radio entry focus
thomasluizon Sep 17, 2026
e818a47
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 17, 2026
8eefbea
fix: align radio entry focus and move-parent parity
thomasluizon Sep 18, 2026
d0631c3
fix: preserve selection during radio entry redirect
thomasluizon Sep 18, 2026
44f9648
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 18, 2026
b027b22
fix: classify radio focus by provenance
thomasluizon Sep 18, 2026
60b550e
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 18, 2026
78c3862
test: assert time keyboard selection
thomasluizon Sep 18, 2026
8ddcca0
Separate radio selection from the commit that closes a picker
thomasluizon Sep 18, 2026
fdb8833
Commit a preference only on a press, and leave D-pad traversal to And…
thomasluizon Sep 18, 2026
42bda16
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 18, 2026
f104e11
Select an unchecked root row in the move-parent overlay test
thomasluizon Sep 18, 2026
398103a
Cover the release template the config plugin actually patches
thomasluizon Sep 18, 2026
29998d8
Give the relocated eyebrow the type role and gap a group label needs
thomasluizon Sep 18, 2026
7992853
Merge remote-tracking branch 'origin/redesign/main' into fix/ticket-5…
thomasluizon Sep 18, 2026
24b2f7f
Retire the selection-field surface and make the drift gate notice a c…
thomasluizon Sep 18, 2026
a6b71b4
Give the time picker one widget contract and split the focus plugin
thomasluizon Sep 18, 2026
c77458a
Fall back to the standalone rule when a radio group has no focus prov…
thomasluizon Sep 18, 2026
74b891a
Give every converted control a visible focus ring and retire the focu…
thomasluizon Sep 18, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GoalType } from '@orbit/shared/types/goal'
import { GoalTypeSelector } from '@/components/habits/create-goal-from-habit/goal-type-selector'
import { createStyles } from '@/components/habits/create-goal-from-habit/styles'
import { createTokensV2 } from '@/lib/theme'
import { __resetTestHostConfig, __setFocusImpl } from '../../../test-mocks/react-native'

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))

const tokens = createTokensV2('purple', 'dark')
const styles = createStyles(tokens, 0)

function Selector({ onChange }: Readonly<{ onChange: (value: GoalType) => void }>) {
const [value, setValue] = useState<GoalType>('Standard')
return (
<GoalTypeSelector
tokens={tokens}
styles={styles}
goalType={value}
onTypeChange={(nextValue) => {
setValue(nextValue)
onChange(nextValue)
}}
/>
)
}

describe('GoalTypeSelector', () => {
beforeEach(() => {
__resetTestHostConfig()
})

it('keeps one tab stop and moves selection and focus with radio keys', () => {
const onChange = vi.fn()
const focusedLabels: string[] = []
__setFocusImpl((props) => focusedLabels.push(String(props.accessibilityLabel)))
let tree: any
void act(() => {
tree = create(<Selector onChange={onChange} />)
})
const radios = (): any[] => tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)
const preventDefault = vi.fn()

expect(radios().map((option) => option.props.tabIndex)).toEqual([0, -1])
void act(() => radios()[0]!.props.onKeyDown({ nativeEvent: { key: 'ArrowRight' }, preventDefault }))
expect(preventDefault).toHaveBeenCalledOnce()
expect(onChange).toHaveBeenCalledExactlyOnceWith('Streak')
expect(focusedLabels.at(-1)).toBe('goals.form.typeStreak')
void act(() => radios()[1]!.props.onKeyDown({ nativeEvent: { key: 'ArrowLeft' }, preventDefault }))
expect(onChange).toHaveBeenLastCalledWith('Standard')
expect(focusedLabels.at(-1)).toBe('goals.form.typeStandard')
})

it('keeps touch selection unchanged', () => {
const onChange = vi.fn()
let tree: any
void act(() => {
tree = create(<Selector onChange={onChange} />)
})
const streak = tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)[1]!
void act(() => streak.props.onPress())
expect(onChange).toHaveBeenCalledExactlyOnceWith('Streak')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { createRef } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PreferencePickerSheet } from '@/components/profile/preferences-sections'
import type { SheetHandle } from '@/components/ui/sheet'
import { createTokensV2 } from '@/lib/theme'
import { __resetTestHostConfig, __setFocusImpl } from '../../../test-mocks/react-native'

vi.mock('@orbit/shared/utils', async (importOriginal) => ({
...await importOriginal<typeof import('@orbit/shared/utils')>(),
getTimezoneList: () => [
'Alpha/One',
'Bravo/Keep',
'Charlie/Three',
'Delta/Keep',
],
}))
vi.mock('@/components/marketing-consent/marketing-consent-section', () => ({
MarketingConsentSection: () => null,
}))
vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}))

const pickerTitles = {
language: 'Language',
theme: 'Theme',
timeZone: 'Timezone',
weekStart: 'Week start',
}

function baseProps() {
return {
tokens: createTokensV2('purple', 'dark'),
activePicker: 'language' as const,
pickerTitles,
pickerDescriptions: {},
timeZoneSearchLabel: 'Search timezones',
timeZoneNoResultsLabel: 'No timezones found',
timeZoneShowMoreLabel: 'Show more timezones',
selectedLanguage: 'en' as const,
currentTheme: 'dark' as const,
timeZone: 'America/Sao_Paulo',
weekStartDay: 1,
themeModeOptions: [
{ value: 'light' as const, label: 'Light' },
{ value: 'dark' as const, label: 'Dark' },
],
weekStartOptions: [
{ value: 1 as const, label: 'Monday' },
{ value: 0 as const, label: 'Sunday' },
],
sheetRef: createRef<SheetHandle>(),
closePicker: (exitAction?: () => void) => exitAction?.(),
onHidden: vi.fn(),
onLanguageChange: vi.fn(),
onThemeModeChange: vi.fn(),
onTimeZoneChange: vi.fn(),
onWeekStartChange: vi.fn(),
}
}

describe('PreferencePickerSheet', () => {
beforeEach(() => {
__resetTestHostConfig()
})

it('enters once and moves focus and selection with ArrowDown', () => {
const props = baseProps()
const focusedLabels: string[] = []
__setFocusImpl((hostProps) => focusedLabels.push(String(hostProps.accessibilityLabel)))
let tree: any
void act(() => {
tree = create(<PreferencePickerSheet {...props} />)
})
const radios = tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)
const preventDefault = vi.fn()

expect(radios.map((option: any) => option.props.tabIndex)).toEqual([0, -1])
void act(() => radios[0]!.props.onKeyDown({ nativeEvent: { key: 'ArrowDown' }, preventDefault }))
expect(preventDefault).toHaveBeenCalledOnce()
expect(props.onLanguageChange).toHaveBeenCalledExactlyOnceWith('pt-BR')
expect(focusedLabels.at(-1)).toBe('Português')
})

it('uses rendered timezone order after filtered options remount', () => {
const props = {
...baseProps(),
activePicker: 'timeZone' as const,
timeZone: null,
}
let tree: any
void act(() => {
tree = create(<PreferencePickerSheet {...props} />)
})
const search = tree.root.find(
(node: any) => typeof node.type === 'string'
&& node.props.accessibilityLabel === props.timeZoneSearchLabel
&& typeof node.props.onChangeText === 'function',
)

void act(() => search.props.onChangeText('Keep'))
const filteredRadios = tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)
const retainedLabel = filteredRadios[0]!.props.accessibilityLabel

void act(() => search.props.onChangeText(''))
const restoredRadios = tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)
const retainedIndex = restoredRadios.findIndex(
(option: any) => option.props.accessibilityLabel === retainedLabel,
)
const adjacentLabel = restoredRadios[retainedIndex + 1]!.props.accessibilityLabel
const retainedOption = restoredRadios[retainedIndex]!

void act(() => retainedOption.props.onKeyDown({
nativeEvent: { key: 'ArrowDown' },
preventDefault: vi.fn(),
}))

expect(props.onTimeZoneChange).toHaveBeenCalledExactlyOnceWith(adjacentLabel)
})
})
2 changes: 2 additions & 0 deletions apps/mobile/__tests__/components/ui/navigation-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type NavigationHost = {
accessibilityRole?: string
accessibilityLabel?: string
accessibilityState?: { selected?: boolean; checked?: boolean; disabled?: boolean }
onKeyDown?: (event: { nativeEvent: { key: string }; preventDefault: () => void }) => void
tabIndex?: 0 | -1
testID?: string
style?: unknown
}
Expand Down
53 changes: 52 additions & 1 deletion apps/mobile/__tests__/components/ui/segmented-control.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import { act } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { press, renderNavigation } from './navigation-render'
import { __resetTestHostConfig, __setFocusImpl } from '../../../test-mocks/react-native'
const options = [{ value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'completed', label: 'Completed' }] as const

function keyDown(node: ReturnType<typeof renderNavigation>['hosts'] extends () => (infer THost)[] ? THost : never, key: string) {
const preventDefault = vi.fn()
void act(() => node.props.onKeyDown?.({ nativeEvent: { key }, preventDefault }))
return preventDefault
}

function StatefulSegmentedControl({
choices,
onChange,
}: Readonly<{
choices: typeof options
onChange: (value: (typeof options)[number]['value']) => void
}>) {
const [value, setValue] = useState<(typeof options)[number]['value']>('all')
return (
<SegmentedControl
options={choices}
value={value}
onChange={(nextValue) => {
setValue(nextValue)
onChange(nextValue)
}}
label="Views"
/>
)
}

describe('SegmentedControl', () => {
beforeEach(() => {
__resetTestHostConfig()
})
it('selects only another enabled view while retaining caller selection', () => {
const onChange = vi.fn()
const tree = renderNavigation(<SegmentedControl options={options} value="active" onChange={onChange} label="Views" />)
Expand Down Expand Up @@ -34,4 +67,22 @@ describe('SegmentedControl', () => {
expect(onChange).not.toHaveBeenCalled()
tree.unmount()
})

it('keeps one tab stop and moves across enabled views with every radio key', () => {
const onChange = vi.fn()
const focusedTestIds: string[] = []
__setFocusImpl((props) => focusedTestIds.push(String(props.testID)))
const choices = [options[0], { ...options[1], disabled: true }, options[2]] as const
const tree = renderNavigation(<StatefulSegmentedControl choices={choices} onChange={onChange} />)
const radios = () => tree.hosts().filter((node) => node.props.accessibilityRole === 'radio')

expect(radios().map((node) => node.props.tabIndex)).toEqual([0, -1, -1])
expect(keyDown(radios()[0]!, 'ArrowDown')).toHaveBeenCalledOnce()
expect(onChange).toHaveBeenLastCalledWith('completed')
expect(focusedTestIds.at(-1)).toBe('segment-completed-selected-enabled')
expect(keyDown(radios()[2]!, 'ArrowDown')).toHaveBeenCalledOnce()
expect(onChange).toHaveBeenLastCalledWith('all')
expect(focusedTestIds.at(-1)).toBe('segment-all-selected-enabled')
tree.unmount()
})
})
75 changes: 75 additions & 0 deletions apps/mobile/__tests__/components/ui/select-check.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useState } from 'react'
import { Pressable } from 'react-native'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RadioGroup } from '@/components/ui/radio-group'
import { RadioRow } from '@/components/ui/select-check'
import { __resetTestHostConfig, __setFocusImpl } from '../../../test-mocks/react-native'

function RadioRows({ onChange }: Readonly<{ onChange: (value: string) => void }>) {
const [value, setValue] = useState('first')
const select = (nextValue: string) => {
setValue(nextValue)
onChange(nextValue)
}

return (
<RadioGroup accessibilityLabel="Cadence">
<RadioRow index={0} label="First" selected={value === 'first'} onPress={() => select('first')} />
<RadioRow index={1} label="Disabled" selected={false} disabled onPress={() => select('disabled')} />
<RadioRow index={2} label="Third" selected={value === 'third'} onPress={() => select('third')} />
<RadioRow index={3} label="Last" selected={value === 'last'} onPress={() => select('last')} />
</RadioGroup>
)
}

function keyDown(node: any, key: string) {
const preventDefault = vi.fn()
void act(() => node.props.onKeyDown({ nativeEvent: { key }, preventDefault }))
expect(preventDefault).toHaveBeenCalledOnce()
}

describe('select-check RadioRow group', () => {
beforeEach(() => {
__resetTestHostConfig()
})

it('keeps one tab stop, skips disabled rows, wraps, and follows selection with focus', () => {
const onChange = vi.fn()
const focusedLabels: string[] = []
__setFocusImpl((props) => focusedLabels.push(String(props.accessibilityLabel)))
let tree: any
void act(() => {
tree = create(<RadioRows onChange={onChange} />)
})
const radios = () => tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)

expect(radios().map((option: any) => option.props.tabIndex)).toEqual([0, -1, -1, -1])
keyDown(radios()[0], 'ArrowDown')
expect(onChange).toHaveBeenLastCalledWith('third')
expect(focusedLabels.at(-1)).toBe('Third')
keyDown(radios()[2], 'ArrowDown')
expect(onChange).toHaveBeenLastCalledWith('last')
expect(focusedLabels.at(-1)).toBe('Last')
keyDown(radios()[3], 'ArrowRight')
expect(onChange).toHaveBeenLastCalledWith('first')
expect(focusedLabels.at(-1)).toBe('First')
keyDown(radios()[0], 'ArrowUp')
expect(onChange).toHaveBeenLastCalledWith('last')
expect(focusedLabels.at(-1)).toBe('Last')
})

it('keeps touch selection unchanged and blocks disabled rows', () => {
const onChange = vi.fn()
let tree: any
void act(() => {
tree = create(<RadioRows onChange={onChange} />)
})
const controls = tree.root.findAllByType(Pressable)
void act(() => controls[2].props.onPress())
expect(onChange).toHaveBeenCalledExactlyOnceWith('third')
expect(controls[1].props.onPress).toBeUndefined()
})
})
24 changes: 24 additions & 0 deletions apps/mobile/__tests__/components/ui/selection-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SelectionField } from '@/components/ui/selection-field'
import { sheetTestControls } from '@/__tests__/support/sheet-double'
import { __resetTestHostConfig } from '../../../test-mocks/react-native'

const TestRenderer = require('react-test-renderer')

Expand Down Expand Up @@ -64,6 +65,7 @@ function sheetCount(tree: any) {
describe('SelectionField', () => {
beforeEach(() => {
sheetTestControls.defer(false)
__resetTestHostConfig()
})

afterEach(() => {
Expand Down Expand Up @@ -161,4 +163,26 @@ describe('SelectionField', () => {
expect(onChange).toHaveBeenCalledWith('daily')
expect(sheetCount(tree)).toBe(0)
})

it('enters once and skips a disabled option with ArrowDown', () => {
const { tree, onChange } = render({
value: 'daily',
options: [
OPTIONS[0]!,
{ value: 'blocked', label: 'Blocked', disabled: true },
OPTIONS[1]!,
],
})

openSheet(tree)
const options = findByRole(tree, 'radio')
expect(options.map((option: any) => option.props.tabIndex)).toEqual([0, -1, -1])
const preventDefault = vi.fn()
TestRenderer.act(() => {
options[0].props.onKeyDown({ nativeEvent: { key: 'ArrowDown' }, preventDefault })
})

expect(preventDefault).toHaveBeenCalledOnce()
expect(onChange).toHaveBeenCalledExactlyOnceWith('weekly')
})
})
Loading
Loading