diff --git a/assets/index.less b/assets/index.less index e3589cf..30890f8 100644 --- a/assets/index.less +++ b/assets/index.less @@ -117,3 +117,14 @@ direction: rtl; } } + +.rc-segmented-item { + &:focus { + outline: none; + } + + &-focused { + border-radius: 2px; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); + } +} diff --git a/docs/demo/basic.tsx b/docs/demo/basic.tsx index 9be5161..db4d63d 100644 --- a/docs/demo/basic.tsx +++ b/docs/demo/basic.tsx @@ -8,6 +8,8 @@ export default function App() {
console.log(value, typeof value)} />
@@ -15,6 +17,7 @@ export default function App() { console.log(value, typeof value)} /> diff --git a/package.json b/package.json index a5f5115..db3a299 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@rc-component/father-plugin": "^1.0.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", "@types/classnames": "^2.2.9", "@types/jest": "^29.2.4", "@types/react": "^18.3.11", diff --git a/src/index.tsx b/src/index.tsx index b251c27..0b6bb1b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -84,6 +84,11 @@ const InternalSegmentedOption: React.FC<{ e: React.ChangeEvent, value: SegmentedRawOption, ) => void; + onFocus: (e: React.FocusEvent) => void; + onBlur: (e?: React.FocusEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onKeyUp: (e: React.KeyboardEvent) => void; + onMouseDown: () => void; }> = ({ prefixCls, className, @@ -94,6 +99,11 @@ const InternalSegmentedOption: React.FC<{ value, name, onChange, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + onMouseDown, }) => { const handleChange = (event: React.ChangeEvent) => { if (disabled) { @@ -107,20 +117,23 @@ const InternalSegmentedOption: React.FC<{ className={classNames(className, { [`${prefixCls}-item-disabled`]: disabled, })} + onMouseDown={onMouseDown} >
{label} @@ -176,10 +189,63 @@ const Segmented = React.forwardRef( const divProps = omit(restProps, ['children']); + // ======================= Focus ======================== + const [isKeyboard, setIsKeyboard] = React.useState(false); + const [isFocused, setIsFocused] = React.useState(false); + + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + + const handleMouseDown = () => { + setIsKeyboard(false); + }; + + // capture keyboard tab interaction for correct focus style + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Tab') { + setIsKeyboard(true); + } + }; + + // ======================= Keyboard ======================== + const onOffset = (offset: number) => { + const currentIndex = segmentedOptions.findIndex( + (option) => option.value === rawValue, + ); + + const total = segmentedOptions.length; + const nextIndex = (currentIndex + offset + total) % total; + + const nextOption = segmentedOptions[nextIndex]; + if (nextOption) { + setRawValue(nextOption.value); + onChange?.(nextOption.value); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + onOffset(-1); + break; + case 'ArrowRight': + case 'ArrowDown': + onOffset(1); + break; + } + }; + return (
( { [`${prefixCls}-item-selected`]: segmentedOption.value === rawValue && !thumbShow, + [`${prefixCls}-item-focused`]: + isFocused && + isKeyboard && + segmentedOption.value === rawValue, }, )} checked={segmentedOption.value === rawValue} onChange={handleChange} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onMouseDown={handleMouseDown} disabled={!!disabled || !!segmentedOption.disabled} /> ))} diff --git a/tests/__snapshots__/index.test.tsx.snap b/tests/__snapshots__/index.test.tsx.snap index 87e05e3..cba0121 100644 --- a/tests/__snapshots__/index.test.tsx.snap +++ b/tests/__snapshots__/index.test.tsx.snap @@ -4,7 +4,8 @@ exports[`rc-segmented render empty segmented 1`] = `
iOS @@ -43,14 +43,12 @@ exports[`rc-segmented render label with ReactNode 1`] = ` class="rc-segmented-item" >

Web @@ -85,7 +81,8 @@ exports[`rc-segmented render segmented ok 1`] = `
iOS @@ -112,14 +107,12 @@ exports[`rc-segmented render segmented ok 1`] = ` class="rc-segmented-item" >
Android @@ -129,14 +122,12 @@ exports[`rc-segmented render segmented ok 1`] = ` class="rc-segmented-item" >
Web @@ -150,7 +141,8 @@ exports[`rc-segmented render segmented with CSSMotion basic 1`] = `
iOS @@ -177,14 +167,12 @@ exports[`rc-segmented render segmented with CSSMotion basic 1`] = ` class="rc-segmented-item" >
Android @@ -194,14 +182,12 @@ exports[`rc-segmented render segmented with CSSMotion basic 1`] = ` class="rc-segmented-item" >
Web3 @@ -215,7 +201,8 @@ exports[`rc-segmented render segmented with options 1`] = `
iOS @@ -242,14 +227,12 @@ exports[`rc-segmented render segmented with options 1`] = ` class="rc-segmented-item" >
Android @@ -259,14 +242,12 @@ exports[`rc-segmented render segmented with options 1`] = ` class="rc-segmented-item" >
Web @@ -280,7 +261,7 @@ exports[`rc-segmented render segmented with options null/undefined 1`] = `
@@ -340,7 +315,8 @@ exports[`rc-segmented render segmented with options: 1 1`] = `
1 @@ -367,14 +341,12 @@ exports[`rc-segmented render segmented with options: 1 1`] = ` class="rc-segmented-item" >
2 @@ -384,14 +356,12 @@ exports[`rc-segmented render segmented with options: 1 1`] = ` class="rc-segmented-item" >
3 @@ -401,14 +371,12 @@ exports[`rc-segmented render segmented with options: 1 1`] = ` class="rc-segmented-item" >
4 @@ -418,14 +386,12 @@ exports[`rc-segmented render segmented with options: 1 1`] = ` class="rc-segmented-item" >
5 @@ -439,7 +405,8 @@ exports[`rc-segmented render segmented with options: 2 1`] = `
iOS @@ -466,14 +431,12 @@ exports[`rc-segmented render segmented with options: 2 1`] = ` class="rc-segmented-item" >
Android @@ -483,14 +446,12 @@ exports[`rc-segmented render segmented with options: 2 1`] = ` class="rc-segmented-item" >
Web @@ -504,7 +465,8 @@ exports[`rc-segmented render segmented with options: disabled 1`] = `
iOS @@ -531,7 +491,6 @@ exports[`rc-segmented render segmented with options: disabled 1`] = ` class="rc-segmented-item rc-segmented-item-disabled" > Android @@ -549,14 +507,12 @@ exports[`rc-segmented render segmented with options: disabled 1`] = ` class="rc-segmented-item" >
Web @@ -570,7 +526,8 @@ exports[`rc-segmented render segmented with title 1`] = `
Web @@ -597,14 +552,12 @@ exports[`rc-segmented render segmented with title 1`] = ` class="rc-segmented-item" >
hello1 @@ -614,14 +567,12 @@ exports[`rc-segmented render segmented with title 1`] = ` class="rc-segmented-item" >
test1 @@ -632,14 +583,12 @@ exports[`rc-segmented render segmented with title 1`] = ` class="rc-segmented-item" >
hello1 @@ -649,14 +598,12 @@ exports[`rc-segmented render segmented with title 1`] = ` class="rc-segmented-item" >
foo1 @@ -670,7 +617,7 @@ exports[`rc-segmented render segmented: disabled 1`] = `
iOS @@ -698,7 +643,6 @@ exports[`rc-segmented render segmented: disabled 1`] = ` class="rc-segmented-item rc-segmented-item-disabled" > Android @@ -716,7 +659,6 @@ exports[`rc-segmented render segmented: disabled 1`] = ` class="rc-segmented-item rc-segmented-item-disabled" > Web @@ -738,7 +679,8 @@ exports[`rc-segmented should render vertical segmented 1`] = `
iOS @@ -765,14 +705,12 @@ exports[`rc-segmented should render vertical segmented 1`] = ` class="rc-segmented-item" >
Android @@ -782,14 +720,12 @@ exports[`rc-segmented should render vertical segmented 1`] = ` class="rc-segmented-item" >
Web @@ -803,7 +739,8 @@ exports[`rc-segmented should render vertical segmented and handle thumb animatio
iOS @@ -830,14 +765,12 @@ exports[`rc-segmented should render vertical segmented and handle thumb animatio class="rc-segmented-item" >
Android @@ -847,14 +780,12 @@ exports[`rc-segmented should render vertical segmented and handle thumb animatio class="rc-segmented-item" >
Web diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 8641abf..2a52c98 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import Segmented from '../src'; @@ -664,3 +665,104 @@ describe('rc-segmented', () => { }); }); }); + +describe('Segmented keyboard navigation', () => { + it('should be focusable through Tab key', async () => { + const user = userEvent.setup(); + const { getByRole, container } = render( + , + ); + + const segmentedContainer = getByRole('radiogroup'); + const inputs = container.querySelectorAll('.rc-segmented-item-input'); + const firstInput = inputs[0]; + + await user.tab(); + // segmented container should be focused + expect(segmentedContainer).toHaveFocus(); + await user.tab(); + // first segmented item should be focused + expect(firstInput).toHaveFocus(); + }); + + it('should handle circular navigation with arrow keys', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + , + ); + + // focus on segmented + await user.tab(); + // focus on first item + await user.tab(); + + // Test right navigation from first item and back to first item + await user.keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenCalledWith('Android'); + await user.keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenCalledWith('Web'); + await user.keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenCalledWith('iOS'); + + // Test left navigation from first item to last item + await user.keyboard('{ArrowLeft}'); + expect(onChange).toHaveBeenCalledWith('Web'); + }); + + it('should skip Tab navigation when disabled', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + const segmentedContainer = container.querySelector('.rc-segmented'); + + await user.tab(); + + // Disabled state should not get focus + expect(segmentedContainer).not.toHaveFocus(); + + // Verify container has no tabIndex attribute + expect(segmentedContainer?.getAttribute('tabIndex')).toBeNull(); + }); + + it('should handle keyboard navigation with disabled options', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + , + ); + + await user.tab(); + await user.tab(); + + await user.keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenCalledWith('Web'); + + onChange.mockClear(); + + await user.keyboard('{ArrowLeft}'); + expect(onChange).toHaveBeenCalledWith('iOS'); + }); + + it('should not have focus style when clicking', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await user.click(container.querySelector('.rc-segmented-item-input')!); + expect(container.querySelector('.rc-segmented-item-input')).not.toHaveClass( + 'rc-segmented-item-input-focused', + ); + }); +});