Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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,66 @@
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 } 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('selects the option reached by native focus', () => {
const onChange = vi.fn()
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',
)
expect(radios().map((option) => option.props.focusable)).toEqual([true, true])
void act(() => radios()[0]!.props.onFocus())
void act(() => radios()[1]!.props.onFocus())
expect(onChange).toHaveBeenCalledExactlyOnceWith('Streak')
void act(() => radios()[0]!.props.onFocus())
expect(onChange).toHaveBeenLastCalledWith('Standard')
})

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,124 @@
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 } 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('selects the language reached by native focus', () => {
const props = baseProps()
let tree: any
void act(() => {
tree = create(<PreferencePickerSheet {...props} />)
})
const radios = tree.root.findAll(
(node: any) => typeof node.type === 'string' && node.props.accessibilityRole === 'radio',
)
expect(radios.map((option: any) => option.props.focusable)).toEqual([true, true])
void act(() => radios[0]!.props.onFocus())
void act(() => radios[1]!.props.onFocus())
expect(props.onLanguageChange).toHaveBeenCalledExactlyOnceWith('pt-BR')
})

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 adjacentOption = restoredRadios[retainedIndex + 1]!
const adjacentLabel = adjacentOption.props.accessibilityLabel
const retainedOption = restoredRadios[retainedIndex]!

expect(retainedOption.props.nextFocusDown).toBe(adjacentOption.props.__nativeTag)
void act(() => retainedOption.props.onFocus())
void act(() => adjacentOption.props.onFocus())

expect(props.onTimeZoneChange).toHaveBeenCalledExactlyOnceWith(adjacentLabel)
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React from 'react'
import { TextInput } from 'react-native'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__emitKeyboardEvent,
__resetTestHostConfig,
__setHostRefsNull,
__setMeasureInWindowImpl,
} from '../../../test-mocks/react-native'
import {
KeyboardAwareFlatList,
Expand All @@ -30,11 +32,13 @@ interface MeasureResult {
}

function createFocusableInput({ y, height }: MeasureResult) {
return Object.assign(new React.Component({}), {
measureInWindow: (
callback: (x: number, y: number, width: number, height: number) => void,
) => callback(0, y, 0, height),
__setMeasureInWindowImpl((callback) => callback(0, y, 0, height))
const inputRef = React.createRef<TextInput>()
TestRenderer.act(() => {
TestRenderer.create(<TextInput ref={inputRef} />)
})
if (!inputRef.current) throw new Error('TextInput host ref was not assigned')
return inputRef.current
}

function renderScrollView(onScroll?: (event: unknown) => void) {
Expand Down Expand Up @@ -86,8 +90,9 @@ describe('KeyboardAwareScrollView (mobile)', () => {
it('ignores revealInput while the keyboard is hidden', () => {
vi.useFakeTimers()
renderScrollView()
const input = createFocusableInput({ y: 500, height: 40 })
TestRenderer.act(() => {
capturedContext?.revealInput(createFocusableInput({ y: 500, height: 40 }))
capturedContext?.revealInput(input)
vi.advanceTimersByTime(60)
})
expect(capturedContext).not.toBeNull()
Expand Down Expand Up @@ -152,9 +157,9 @@ describe('KeyboardAwareScrollView (mobile)', () => {

it('returns early when the scrollable ref is null', () => {
vi.useFakeTimers()
const input = createFocusableInput({ y: 700, height: 40 })
__setHostRefsNull(true)
renderScrollView()
const input = createFocusableInput({ y: 700, height: 40 })
TestRenderer.act(() => {
capturedContext?.revealInput(input)
__emitKeyboardEvent('keyboardDidShow', { endCoordinates: { screenY: 400 } })
Expand Down
8 changes: 8 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,14 @@ type NavigationHost = {
accessibilityRole?: string
accessibilityLabel?: string
accessibilityState?: { selected?: boolean; checked?: boolean; disabled?: boolean }
__nativeTag?: number
focusable?: boolean
nextFocusDown?: number
nextFocusForward?: number
nextFocusLeft?: number
nextFocusRight?: number
nextFocusUp?: number
onFocus?: () => void
testID?: string
style?: unknown
}
Expand Down
42 changes: 41 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,36 @@
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 } from '../../../test-mocks/react-native'
const options = [{ value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'completed', label: 'Completed' }] as const

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 +61,17 @@ describe('SegmentedControl', () => {
expect(onChange).not.toHaveBeenCalled()
tree.unmount()
})

it('keeps disabled views out of native focus and selects an enabled focused view', () => {
const onChange = vi.fn()
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.focusable)).toEqual([true, false, true])
void act(() => radios()[0]!.props.onFocus?.())
void act(() => radios()[2]!.props.onFocus?.())
expect(onChange).toHaveBeenCalledExactlyOnceWith('completed')
tree.unmount()
})
})
Loading
Loading