diff --git a/.changeset/curly-rabbits-study.md b/.changeset/curly-rabbits-study.md new file mode 100644 index 0000000000..064435dc68 --- /dev/null +++ b/.changeset/curly-rabbits-study.md @@ -0,0 +1,5 @@ +--- + +--- + + diff --git a/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx b/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx index d6194f4ec1..58abff9734 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx +++ b/packages/agentic/components/src/components/checkbox/checkbox.stories.tsx @@ -166,13 +166,28 @@ export const ExternallyDrivenStatus: Story = { export const HiddenLabel: Story = { render: () => ( - + + ), + wdio: { + 'CBX-004 preserves native explicit and fallback names without visible labels': async ({ browser, expect }) => { + const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + const explicit = await browser.$('~checkbox-explicit-name'); + const fallback = await browser.$('~checkbox-fallback-name'); + expect(await explicit.getTagName()).toBe('checkbox'); + expect(await fallback.getTagName()).toBe('checkbox'); + expect(await explicit.getProperty('name')).toBe('Enable notifications'); + expect(await fallback.getProperty('name')).toBe('Save drafts'); + await expectNativeState(browser, 'checkbox-explicit-name', 'checked', false); + await expectNativeState(browser, 'checkbox-fallback-name', 'checked', false); + }, + }, parameters: { docs: { description: { - story: 'When the visible label is hidden, the component still requires an accessible name.', + story: + 'When the visible label is hidden, the component still requires an accessible name. Native name and checked state are checked separately; macOS exposes checkbox state through AXValue.', }, }, }, @@ -199,9 +214,27 @@ export const ConstrainedText: Story = { + + + ), @@ -212,6 +245,24 @@ export const ConstrainedText: Story = { }, }, }, + wdio: { + 'CBX-003 wraps the label into a taller constrained row': async ({ browser, expect }) => { + const narrow = await browser.$('~checkbox-narrow-label'); + const wide = await browser.$('~checkbox-wide-label'); + const narrowRect = await browser.getElementRect(await narrow.elementId); + const wideRect = await browser.getElementRect(await wide.elementId); + expect(narrowRect.width).toBeLessThan(wideRect.width); + expect(narrowRect.height).toBeGreaterThan(wideRect.height); + }, + 'CBX-003 wraps secondary text into a taller constrained row': async ({ browser, expect }) => { + const narrow = await browser.$('~checkbox-narrow-secondary'); + const wide = await browser.$('~checkbox-wide-secondary'); + const narrowRect = await browser.getElementRect(await narrow.elementId); + const wideRect = await browser.getElementRect(await wide.elementId); + expect(narrowRect.width).toBeLessThan(wideRect.width); + expect(narrowRect.height).toBeGreaterThan(wideRect.height); + }, + }, }; const styles = StyleSheet.create({ @@ -235,4 +286,7 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', gap: 16, }, + unconstrained: { + width: 480, + }, }); diff --git a/packages/agentic/components/src/components/checkbox/checkbox.test.tsx b/packages/agentic/components/src/components/checkbox/checkbox.test.tsx index d698f95f0d..66ae89dde3 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.test.tsx +++ b/packages/agentic/components/src/components/checkbox/checkbox.test.tsx @@ -9,6 +9,7 @@ import type { RenderResult } from '@testing-library/react-native'; import { defaultFlexTokens } from '@fluentui-react-native/design/testing'; import { Checkbox } from './checkbox'; +import type { CheckboxStatus } from './checkbox.types'; function renderCheckbox(props: React.ComponentProps): Promise { return render(); @@ -330,4 +331,214 @@ describe('Checkbox', () => { }); expect(StyleSheet.flatten(component.getByTestId('checkbox-label').props.style).color).toBe(tokens.foregroundNeutralDisabled); }); + + describe('SPEC regression coverage', () => { + afterEach(() => jest.restoreAllMocks()); + + it('CBX-001 resolves omitted defaults rather than caller-supplied values', async () => { + const component = await renderCheckbox({}); + expect(getRoot(component).props).toMatchObject({ + accessible: true, + accessibilityLabel: 'Label', + accessibilityState: { checked: false, disabled: false }, + focusable: true, + }); + expect(getRoot(component)).toBeEnabled(); + expect(component.getByText('Label')).toBeOnTheScreen(); + expect(component.queryByTestId('checkbox-secondary', { includeHiddenElements: true })).toBeNull(); + expect(component.queryByTestId('checkbox-check-icon')).toBeNull(); + expect(component.queryByTestId('checkbox-dash-icon')).toBeNull(); + expect(getIndicatorStyle(component)).toMatchObject({ + borderRadius: defaultFlexTokens.borderRadius.base100, + height: 16, + width: 16, + }); + }); + + it('CBX-001 forwards native root props and the original layout event', async () => { + const onLayout = jest.fn(); + const hitSlop = { bottom: 4, left: 8, right: 8, top: 4 }; + const component = await renderCheckbox({ focusable: false, hitSlop, nativeID: 'choice', onLayout, testID: 'consumer-checkbox' }); + const root = getRoot(component); + expect(root).toBe(component.getByTestId('consumer-checkbox')); + expect(root.props).toMatchObject({ focusable: false, hitSlop, nativeID: 'choice' }); + const event = { nativeEvent: { layout: { height: 24, width: 120, x: 0, y: 0 } } }; + await fireEvent(root, 'layout', event); + expect(onLayout).toHaveBeenCalledTimes(1); + expect(onLayout).toHaveBeenCalledWith(event); + }); + + describe.each(['unchecked', 'checked', 'indeterminate'] as const)('CBX-002 starting from %s', (status) => { + it.each([false, true])('preserves status ownership and callback order with controlled=%s', async (controlled) => { + const calls: string[] = []; + const onStatusChange = jest.fn((next: CheckboxStatus) => calls.push(next)); + const onPress = jest.fn(() => calls.push('press')); + const component = await renderCheckbox({ + defaultStatus: controlled ? (status === 'checked' ? 'unchecked' : 'checked') : status, + onPress, + onStatusChange, + ...(controlled ? { status } : {}), + }); + const initialChecked = status === 'indeterminate' ? 'mixed' : status === 'checked'; + expect(getRoot(component).props.accessibilityState.checked).toBe(initialChecked); + const first = status === 'checked' ? 'unchecked' : 'checked'; + const second = controlled ? first : first === 'checked' ? 'unchecked' : 'checked'; + const event = { nativeEvent: { pageX: 12, pageY: 12 } }; + + await fireEvent.press(getRoot(component), event); + expect(getRoot(component).props.accessibilityState.checked).toBe(controlled ? initialChecked : first === 'checked'); + await fireEvent.press(getRoot(component), event); + expect(getRoot(component).props.accessibilityState.checked).toBe(controlled ? initialChecked : second === 'checked'); + expect(onStatusChange.mock.calls).toEqual([[first], [second]]); + expect(onPress).toHaveBeenCalledTimes(2); + for (const call of [1, 2]) { + expect(onPress).toHaveBeenNthCalledWith( + call, + expect.objectContaining({ nativeEvent: expect.objectContaining(event.nativeEvent) }), + ); + } + expect(calls).toEqual([first, 'press', second, 'press']); + }); + + it('does not report or apply a disabled press', async () => { + const onPress = jest.fn(); + const onStatusChange = jest.fn(); + const component = await renderCheckbox({ defaultStatus: status, disabled: true, onPress, onStatusChange }); + await fireEvent.press(getRoot(component)); + expect(onPress).not.toHaveBeenCalled(); + expect(onStatusChange).not.toHaveBeenCalled(); + expect(getRoot(component).props.accessibilityState.checked).toBe(status === 'indeterminate' ? 'mixed' : status === 'checked'); + }); + }); + + it('CBX-002 keeps defaultStatus as an initializer and accepts later controlled values', async () => { + const component = await renderCheckbox({ defaultStatus: 'checked' }); + await component.rerender(); + expect(getRoot(component).props.accessibilityState.checked).toBe(true); + + const controlled = await renderCheckbox({ status: 'unchecked', defaultStatus: 'checked' }); + await controlled.rerender(); + expect(getRoot(controlled).props.accessibilityState.checked).toBe('mixed'); + }); + + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])('CBX-003 gates text and development warnings with showLabel=%s and showSecondaryText=%s', async (showLabel, showSecondaryText) => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + const component = await renderCheckbox({ showLabel, showSecondaryText, accessibilityHint: 'Caller hint' }); + expect(component.queryByTestId('checkbox-label') !== null).toBe(showLabel); + expect(component.queryByTestId('checkbox-secondary', { includeHiddenElements: true }) !== null).toBe(showLabel && showSecondaryText); + expect(getRoot(component).props.accessibilityHint).toBe(showLabel && showSecondaryText ? 'Caller hint. Description' : 'Caller hint'); + if (showSecondaryText && !showLabel) { + expect(warn.mock.calls).toEqual([['Checkbox: secondary text requires a visible label.']]); + } else { + expect(warn).not.toHaveBeenCalled(); + } + }); + + it('CBX-003 and CBX-004 render indicator, label, and secondary text in order without duplicate accessible nodes', async () => { + const component = await renderCheckbox({ showSecondaryText: true }); + const indicator = component.getByTestId('checkbox-indicator'); + const label = component.getByTestId('checkbox-label'); + const secondary = component.getByTestId('checkbox-secondary', { includeHiddenElements: true }); + expect(getRoot(component).children).toEqual([ + indicator, + expect.objectContaining({ + props: expect.objectContaining({ accessible: false }), + children: [label, secondary], + }), + ]); + expect(indicator.props.accessible).toBe(false); + expect(label.props.accessible).toBe(false); + expect(secondary.props).toMatchObject({ accessible: false, accessibilityElementsHidden: true }); + expect(secondary).toHaveTextContent('Description'); + }); + + it('CBX-004 owns role and checked/disabled state while preserving caller context', async () => { + const component = await renderCheckbox({ + accessibilityRole: 'button', + accessibilityState: { busy: true, checked: false, disabled: true }, + accessibilityLabel: 'Explicit option name', + accessibilityHint: 'Caller context', + secondaryText: 'Supporting context', + showSecondaryText: true, + status: 'indeterminate', + }); + expect(getRoot(component).props).toMatchObject({ + accessibilityRole: 'checkbox', + accessibilityState: { busy: true, checked: 'mixed', disabled: false }, + accessibilityLabel: 'Explicit option name', + accessibilityHint: 'Caller context. Supporting context', + }); + }); + + it.each(['unchecked', 'checked', 'indeterminate'] as const)( + 'CBX-005 resolves all %s colors and restores hover after release', + async (status) => { + const colors = defaultFlexTokens.color; + const component = await renderCheckbox({ status, showSecondaryText: true }); + for (const [event, palette] of [ + ['hoverIn', colors.hover], + ['pressIn', colors.pressed], + ['pressOut', colors.hover], + ['hoverOut', colors], + ] as const) { + await fireEvent(getRoot(component), event, {}); + expect(getIndicatorStyle(component)).toMatchObject({ + backgroundColor: status === 'unchecked' ? palette.backgroundNeutralTransparent : palette.backgroundBrandHeavy, + borderColor: status === 'unchecked' ? palette.strokeNeutralHeavy : palette.strokeNeutralTransparent, + }); + expect(StyleSheet.flatten(component.getByTestId('checkbox-label').props.style).color).toBe( + status === 'unchecked' ? palette.foregroundNeutralSecondary : palette.foregroundNeutralPrimary, + ); + expect(StyleSheet.flatten(component.getByTestId('checkbox-secondary', { includeHiddenElements: true }).props.style).color).toBe( + palette.foregroundNeutralSecondary, + ); + if (status !== 'unchecked') { + expect(StyleSheet.flatten(component.getByText(status === 'checked' ? '\u2713' : '\u2212').props.style).color).toBe( + palette.foregroundBrandOnloud, + ); + } + } + }, + ); + + it.each(['unchecked', 'checked', 'indeterminate'] as const)( + 'CBX-005 selects disabled %s colors during an active press', + async (status) => { + const component = await renderCheckbox({ status, showSecondaryText: true }); + await fireEvent(getRoot(component), 'hoverIn', {}); + await fireEvent(getRoot(component), 'pressIn', {}); + await component.rerender(); + const colors = defaultFlexTokens.color; + expect(getIndicatorStyle(component)).toMatchObject({ + backgroundColor: status === 'unchecked' ? colors.backgroundNeutralTransparent : colors.backgroundNeutralHeavyDisabled, + borderColor: status === 'unchecked' ? colors.strokeNeutralDisabled : colors.strokeNeutralTransparent, + }); + for (const id of ['checkbox-label', 'checkbox-secondary']) { + expect(StyleSheet.flatten(component.getByTestId(id, { includeHiddenElements: true }).props.style).color).toBe( + colors.foregroundNeutralDisabled, + ); + } + if (status !== 'unchecked') { + expect(StyleSheet.flatten(component.getByText(status === 'checked' ? '\u2713' : '\u2212').props.style).color).toBe( + colors.foregroundNeutralDisabled, + ); + } + }, + ); + + it('CBX-005 applies the last caller style after structural and token styles', async () => { + const component = await renderCheckbox({ + style: [ + { minHeight: 40, gap: 20 }, + { minHeight: 48, gap: 12, flexDirection: 'column' }, + ], + }); + expect(getRootStyle(component)).toMatchObject({ minHeight: 48, gap: 12, flexDirection: 'column' }); + }); + }); });