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