Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/curly-rabbits-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

---


Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,28 @@ export const ExternallyDrivenStatus: Story = {
export const HiddenLabel: Story = {
render: () => (
<StoryGroup label="Label fallback">
<Checkbox accessibilityLabel="Enable notifications" label="Hidden label" showLabel={false} />
<Checkbox accessibilityLabel="Enable notifications" label="Hidden label" showLabel={false} testID="checkbox-explicit-name" />
<Checkbox label="Save drafts" showLabel={false} testID="checkbox-fallback-name" />
</StoryGroup>
),
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.',
},
},
},
Expand All @@ -199,9 +214,27 @@ export const ConstrainedText: Story = {
<StoryGroup label="Constrained label">
<Checkbox
label="Long checkbox label that wraps inside constrained layouts without truncating"
secondaryText="Secondary text can wrap too."
style={styles.constrained}
testID="checkbox-narrow-label"
/>
<Checkbox
label="Long checkbox label that wraps inside constrained layouts without truncating"
style={styles.unconstrained}
testID="checkbox-wide-label"
/>
<Checkbox
label="Option"
secondaryText="Long supporting text that wraps inside constrained layouts without truncating"
showSecondaryText
style={styles.constrained}
testID="checkbox-narrow-secondary"
/>
<Checkbox
label="Option"
secondaryText="Long supporting text that wraps inside constrained layouts without truncating"
showSecondaryText
style={styles.unconstrained}
testID="checkbox-wide-secondary"
/>
</StoryGroup>
),
Expand All @@ -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({
Expand All @@ -235,4 +286,7 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
gap: 16,
},
unconstrained: {
width: 480,
},
});
211 changes: 211 additions & 0 deletions packages/agentic/components/src/components/checkbox/checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Checkbox>): Promise<RenderResult> {
return render(<Checkbox {...props} />);
Expand Down Expand Up @@ -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(<Checkbox defaultStatus="unchecked" />);
expect(getRoot(component).props.accessibilityState.checked).toBe(true);

const controlled = await renderCheckbox({ status: 'unchecked', defaultStatus: 'checked' });
await controlled.rerender(<Checkbox status="indeterminate" defaultStatus="checked" />);
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(<Checkbox status={status} showSecondaryText disabled />);
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' });
});
});
});
Loading