@@ -36,9 +36,9 @@ export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;
3636/**
3737 * SegmentedControl component props.
3838 */
39- export interface SegmentedControlProps
39+ export interface SegmentedControlProps < T extends string = string >
4040 extends Props ,
41- ControlledValueProps < string > ,
41+ ControlledValueProps < T > ,
4242 React . RefAttributes < HTMLDivElement > {
4343 /**
4444 * Whether the control should take up the full width of its container.
@@ -68,7 +68,7 @@ export interface SegmentedControlProps
6868 /**
6969 * List of available options.
7070 */
71- options : Array < OptionProps < string > > ;
71+ options : Array < OptionProps < T > > ;
7272
7373 /**
7474 * Aria role for the overall component. Child buttons get appropriate roles.
@@ -95,137 +95,165 @@ export interface SegmentedControlProps
9595 small ?: boolean ;
9696}
9797
98+ /**
99+ * Generic component type. This is essentially a type hack required to make forwardRef work with generic
100+ * components. Note that this slows down TypeScript compilation, but is better than the alternative of globally
101+ * augmenting "@types/react".
102+ *
103+ * @see https://stackoverflow.com/a/73795494/7406866
104+ */
105+ export interface SegmentedControlComponent extends React . FC < SegmentedControlProps > {
106+ /**
107+ * ReturnType here preserves type compatability with React 16 while we migrate to React 18.
108+ * see: https://github.com/palantir/blueprint/pull/7142/files#r1915691062
109+ */
110+ // TODO(React 18): Replace return type with `React.ReactNode` once we drop support for React 16.
111+ < T extends string > ( props : SegmentedControlProps < T > ) : ReturnType < React . FC < SegmentedControlProps < T > > > | null ;
112+ }
113+
98114/**
99115 * Segmented control component.
100116 *
101117 * @see https://blueprintjs.com/docs/#core/components/segmented-control
102118 */
103- export const SegmentedControl : React . FC < SegmentedControlProps > = React . forwardRef ( ( props , ref ) => {
104- const {
105- className,
106- defaultValue,
107- fill,
108- inline,
109- intent = Intent . NONE ,
110- // eslint-disable-next-line @typescript-eslint/no-deprecated
111- large,
112- onValueChange,
113- options,
114- role = "radiogroup" ,
115- size = "medium" ,
116- // eslint-disable-next-line @typescript-eslint/no-deprecated
117- small,
118- value : controlledValue ,
119- ...htmlProps
120- } = props ;
121-
122- const [ localValue , setLocalValue ] = React . useState < string | undefined > ( defaultValue ) ;
123- const selectedValue = controlledValue ?? localValue ;
124-
125- const outerRef = React . useRef < HTMLDivElement > ( null ) ;
126-
127- useValidateProps ( ( ) => {
128- logDeprecatedSizeWarning ( "SegmentedControl" , { large, small } ) ;
129- } , [ large , small ] ) ;
130-
131- const handleOptionClick = React . useCallback (
132- ( newSelectedValue : string , targetElement : HTMLElement ) => {
133- setLocalValue ( newSelectedValue ) ;
134- onValueChange ?.( newSelectedValue , targetElement ) ;
135- } ,
136- [ onValueChange ] ,
137- ) ;
119+ export const SegmentedControl : SegmentedControlComponent = React . forwardRef (
120+ < T extends string > ( props : SegmentedControlProps < T > , ref : React . ForwardedRef < HTMLDivElement > ) => {
121+ const {
122+ className,
123+ defaultValue,
124+ fill,
125+ inline,
126+ intent = Intent . NONE ,
127+ // eslint-disable-next-line @typescript-eslint/no-deprecated
128+ large,
129+ onValueChange,
130+ options,
131+ role = "radiogroup" ,
132+ size = "medium" ,
133+ // eslint-disable-next-line @typescript-eslint/no-deprecated
134+ small,
135+ value : controlledValue ,
136+ ...htmlProps
137+ } = props ;
138138
139- const handleKeyDown = React . useCallback (
140- ( e : React . KeyboardEvent < HTMLDivElement > ) => {
141- if ( role === "radiogroup" ) {
142- // in a `radiogroup`, arrow keys select next item, not tab key.
143- const direction = Utils . getArrowKeyDirection ( e , [ "ArrowLeft" , "ArrowUp" ] , [ "ArrowRight" , "ArrowDown" ] ) ;
144- const outerElement = outerRef . current ;
145- if ( direction === undefined || ! outerElement ) return ;
146-
147- const focusedElement = Utils . getActiveElement ( outerElement ) ?. closest < HTMLButtonElement > ( "button" ) ;
148- if ( ! focusedElement ) return ;
149-
150- // must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
151- const enabledOptionElements = Array . from (
152- outerElement . querySelectorAll < HTMLButtonElement > ( "button:not(:disabled)" ) ,
153- ) ;
154- const focusedIndex = enabledOptionElements . indexOf ( focusedElement ) ;
155- if ( focusedIndex < 0 ) return ;
156-
157- e . preventDefault ( ) ;
158- // auto-wrapping at 0 and `length`
159- const newIndex =
160- ( focusedIndex + direction + enabledOptionElements . length ) % enabledOptionElements . length ;
161- const newOption = enabledOptionElements [ newIndex ] ;
162- newOption . click ( ) ;
163- newOption . focus ( ) ;
164- }
165- } ,
166- [ outerRef , role ] ,
167- ) ;
139+ const [ localValue , setLocalValue ] = React . useState < T | undefined > ( defaultValue ) ;
140+ const selectedValue = controlledValue ?? localValue ;
168141
169- const classes = classNames ( Classes . SEGMENTED_CONTROL , className , {
170- [ Classes . FILL ] : fill ,
171- [ Classes . INLINE ] : inline ,
172- } ) ;
142+ const outerRef = React . useRef < HTMLDivElement > ( null ) ;
173143
174- const isAnySelected = options . some ( option => selectedValue === option . value ) ;
144+ useValidateProps ( ( ) => {
145+ logDeprecatedSizeWarning ( "SegmentedControl" , { large, small } ) ;
146+ } , [ large , small ] ) ;
175147
176- return (
177- < div
178- { ...removeNonHTMLProps ( htmlProps ) }
179- role = { role }
180- onKeyDown = { handleKeyDown }
181- className = { classes }
182- ref = { mergeRefs ( ref , outerRef ) }
183- >
184- { options . map ( ( option , index ) => {
185- const isSelected = selectedValue === option . value ;
186- return (
187- < SegmentedControlOption
188- { ...option }
189- intent = { intent }
190- isSelected = { isSelected }
191- key = { option . value }
192- // eslint-disable-next-line @typescript-eslint/no-deprecated
193- large = { large }
194- onClick = { handleOptionClick }
195- size = { size }
196- // eslint-disable-next-line @typescript-eslint/no-deprecated
197- small = { small }
198- { ...( role === "radiogroup"
199- ? {
200- "aria-checked" : isSelected ,
201- role : "radio" ,
202- // "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
203- // `!isAnySelected` accounts for case where no value is currently selected
204- // (passed value/defaultValue is not one of the values of the passed options.)
205- // In this case, set first item to be tabbable even though it's unselected.
206- tabIndex : isSelected || ( index === 0 && ! isAnySelected ) ? 0 : - 1 ,
207- }
208- : {
209- "aria-pressed" : isSelected ,
210- } ) }
211- />
212- ) ;
213- } ) }
214- </ div >
215- ) ;
216- } ) ;
148+ const handleOptionClick = React . useCallback (
149+ ( newSelectedValue : T , targetElement : HTMLElement ) => {
150+ setLocalValue ( newSelectedValue ) ;
151+ onValueChange ?.( newSelectedValue , targetElement ) ;
152+ } ,
153+ [ onValueChange ] ,
154+ ) ;
155+
156+ const handleKeyDown = React . useCallback (
157+ ( e : React . KeyboardEvent < HTMLDivElement > ) => {
158+ if ( role === "radiogroup" ) {
159+ // in a `radiogroup`, arrow keys select next item, not tab key.
160+ const direction = Utils . getArrowKeyDirection (
161+ e ,
162+ [ "ArrowLeft" , "ArrowUp" ] ,
163+ [ "ArrowRight" , "ArrowDown" ] ,
164+ ) ;
165+ const outerElement = outerRef . current ;
166+ if ( direction === undefined || ! outerElement ) return ;
167+
168+ const focusedElement = Utils . getActiveElement ( outerElement ) ?. closest < HTMLButtonElement > ( "button" ) ;
169+ if ( ! focusedElement ) return ;
170+
171+ // must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
172+ const enabledOptionElements = Array . from (
173+ outerElement . querySelectorAll < HTMLButtonElement > ( "button:not(:disabled)" ) ,
174+ ) ;
175+ const focusedIndex = enabledOptionElements . indexOf ( focusedElement ) ;
176+ if ( focusedIndex < 0 ) return ;
177+
178+ e . preventDefault ( ) ;
179+ // auto-wrapping at 0 and `length`
180+ const newIndex =
181+ ( focusedIndex + direction + enabledOptionElements . length ) % enabledOptionElements . length ;
182+ const newOption = enabledOptionElements [ newIndex ] ;
183+ newOption . click ( ) ;
184+ newOption . focus ( ) ;
185+ }
186+ } ,
187+ [ outerRef , role ] ,
188+ ) ;
189+
190+ const classes = classNames ( Classes . SEGMENTED_CONTROL , className , {
191+ [ Classes . FILL ] : fill ,
192+ [ Classes . INLINE ] : inline ,
193+ } ) ;
194+
195+ const isAnySelected = options . some ( option => selectedValue === option . value ) ;
196+
197+ return (
198+ < div
199+ { ...removeNonHTMLProps ( htmlProps ) }
200+ role = { role }
201+ onKeyDown = { handleKeyDown }
202+ className = { classes }
203+ ref = { mergeRefs ( ref , outerRef ) }
204+ >
205+ { options . map ( ( option , index ) => {
206+ const isSelected = selectedValue === option . value ;
207+ return (
208+ < SegmentedControlOption < T >
209+ { ...option }
210+ intent = { intent }
211+ isSelected = { isSelected }
212+ key = { option . value }
213+ // eslint-disable-next-line @typescript-eslint/no-deprecated
214+ large = { large }
215+ onClick = { handleOptionClick }
216+ size = { size }
217+ // eslint-disable-next-line @typescript-eslint/no-deprecated
218+ small = { small }
219+ { ...( role === "radiogroup"
220+ ? {
221+ "aria-checked" : isSelected ,
222+ role : "radio" ,
223+ // "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
224+ // `!isAnySelected` accounts for case where no value is currently selected
225+ // (passed value/defaultValue is not one of the values of the passed options.)
226+ // In this case, set first item to be tabbable even though it's unselected.
227+ tabIndex : isSelected || ( index === 0 && ! isAnySelected ) ? 0 : - 1 ,
228+ }
229+ : {
230+ "aria-pressed" : isSelected ,
231+ } ) }
232+ />
233+ ) ;
234+ } ) }
235+ </ div >
236+ ) ;
237+ } ,
238+ ) ;
217239SegmentedControl . displayName = `${ DISPLAYNAME_PREFIX } .SegmentedControl` ;
218240
219- interface SegmentedControlOptionProps
220- extends OptionProps < string > ,
241+ interface SegmentedControlOptionProps < T extends string = string >
242+ extends OptionProps < T > ,
221243 Pick < SegmentedControlProps , "intent" | "small" | "large" | "size" > ,
222244 Pick < ButtonProps , "role" | "tabIndex" > ,
223245 React . AriaAttributes {
224246 isSelected : boolean ;
225- onClick : ( value : string , targetElement : HTMLElement ) => void ;
247+ onClick : ( value : T , targetElement : HTMLElement ) => void ;
226248}
227249
228- function SegmentedControlOption ( { isSelected, label, onClick, value, ...buttonProps } : SegmentedControlOptionProps ) {
250+ function SegmentedControlOption < T extends string = string > ( {
251+ isSelected,
252+ label,
253+ onClick,
254+ value,
255+ ...buttonProps
256+ } : SegmentedControlOptionProps < T > ) {
229257 const handleClick = React . useCallback (
230258 ( event : React . MouseEvent < HTMLElement > ) => onClick ?.( value , event . currentTarget ) ,
231259 [ onClick , value ] ,
0 commit comments