diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 97b05e8982..273abe0186 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.2.0 ((5/28/2026, 10:22 AM PST)) + +This is an artificial version bump with no new change. + ## 9.1.3 ((5/28/2026, 09:35 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index d9efc24fda..068573e0f3 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.1.3", + "version": "9.2.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 48915ba554..a73329b049 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.2.0 ((5/28/2026, 10:22 AM PST)) + +This is an artificial version bump with no new change. + ## 9.1.3 ((5/28/2026, 09:35 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index ca92e7adc8..ea64348c5f 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "9.1.3", + "version": "9.2.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 231a4c30e2..4d284e214f 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to this project will be documented in this file. +## 9.2.0 (5/28/2026 PST) + +#### 🚀 Updates + +- Feat: improve Select theming support. [[#733](https://github.com/coinbase/cds/pull/733)] +- Feat: add readOnly support to Select. [[#733](https://github.com/coinbase/cds/pull/733)] + ## 9.1.3 (5/28/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 44be6634e1..f430c1a681 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.1.3", + "version": "9.2.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx index 2e72d35ca6..47c6290064 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { forwardRef, memo, useCallback, useMemo } from 'react'; import { Pressable, type StyleProp, TouchableOpacity, type ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useInputVariant } from '@coinbase/cds-common/hooks/useInputVariant'; @@ -46,6 +46,7 @@ export const DefaultSelectControlComponent = memo( open, placeholder, disabled, + readOnly, setOpen, variant, helperText, @@ -57,10 +58,15 @@ export const DefaultSelectControlComponent = memo( compact, align = 'start', font = 'body', + labelColor = 'fg', + labelFont = 'label1', bordered = true, borderWidth = bordered ? 100 : 0, focusedBorderWidth = bordered ? undefined : 200, + inputBackground = !disabled && readOnly ? 'bgSecondary' : 'bg', + borderRadius, maxSelectedOptionsToShow = 3, + accessibilityHint, accessibilityLabel, hiddenSelectedOptionsLabel = 'more', removeSelectedOptionAccessibilityLabel = 'Remove', @@ -76,6 +82,13 @@ export const DefaultSelectControlComponent = memo( ? SelectOptionValue | SelectOptionValue[] | null : SelectOptionValue | null; + const isInteractionBlocked = disabled || readOnly; + + const handleToggleOpen = useCallback(() => { + if (isInteractionBlocked) return; + setOpen((currentOpen) => !currentOpen); + }, [isInteractionBlocked, setOpen]); + const theme = useTheme(); // When compact, labelVariant is ignored const labelVariant = compact ? undefined : labelVariantProp; @@ -193,28 +206,52 @@ export const DefaultSelectControlComponent = memo( if (typeof label === 'string') { return ( - + {label} ); } return label; - }, [shouldShowInsideLabel, shouldShowCompactLabel, label, styles?.controlLabelNode]); + }, [ + shouldShowInsideLabel, + shouldShowCompactLabel, + label, + labelColor, + labelFont, + styles?.controlLabelNode, + ]); const inlineLabelNode = useMemo(() => { if (!shouldShowInsideLabel && !shouldShowCompactLabel) return null; if (typeof label === 'string') { return ( - + {label} ); } return label; - }, [shouldShowInsideLabel, shouldShowCompactLabel, label, styles?.controlLabelNode]); + }, [ + shouldShowInsideLabel, + shouldShowCompactLabel, + label, + labelColor, + labelFont, + styles?.controlLabelNode, + ]); const valueAlignment = useMemo( () => (align === 'end' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start'), @@ -255,10 +292,14 @@ export const DefaultSelectControlComponent = memo( disabled={disabled || option.disabled} invertColorScheme={false} maxWidth={200} - onPress={(event) => { - event?.stopPropagation(); - onChange?.(option.value as ValueType); - }} + onPress={ + isInteractionBlocked + ? undefined + : (event) => { + event?.stopPropagation(); + onChange?.(option.value as ValueType); + } + } > {option.label ?? option.description ?? option.value ?? ''} @@ -294,6 +335,7 @@ export const DefaultSelectControlComponent = memo( removeSelectedOptionAccessibilityLabel, disabled, onChange, + isInteractionBlocked, ]); // onBlur/onFocus on ViewProps allow null returns but TouchableOpacity's onBlur/onFocus props do not. @@ -302,14 +344,14 @@ export const DefaultSelectControlComponent = memo( () => ( setOpen((s) => !s)} + onPress={handleToggleOpen} style={[{ flexGrow: 1 }, styles?.controlInputNode]} - {...props} > setOpen((s) => !s)} + onPress={handleToggleOpen} > - {customEndNode ? ( - customEndNode - ) : ( - - )} + {customEndNode ? customEndNode : } ), - [styles?.controlEndNode, disabled, customEndNode, open, variant, setOpen], + [styles?.controlEndNode, disabled, customEndNode, open, handleToggleOpen], ); const inputStackStyles: StyleProp = useMemo( @@ -426,18 +461,21 @@ export const DefaultSelectControlComponent = memo( return ( { }); }); + describe('readOnly', () => { + it('does not open the tray when readOnly', () => { + render( + + + , + ); + + expect(screen.getByRole('button').props.accessibilityState?.disabled).not.toBe(true); + }); + }); + + describe('ComponentConfig', () => { + it('applies read-only background from component config resolver', () => { + render( + + ({ + inputBackground: readOnly ? 'bgSecondary' : 'bgAlternate', + }), + }} + > + + + , + ); + + expect(screen.getByText('Test Select')).toBeTruthy(); + }); + }); + describe('Ref Forwarding', () => { it('forwards ref correctly', () => { const ref = React.createRef(); diff --git a/packages/mobile/src/alpha/select/types.ts b/packages/mobile/src/alpha/select/types.ts index eb2dac878d..c838e99988 100644 --- a/packages/mobile/src/alpha/select/types.ts +++ b/packages/mobile/src/alpha/select/types.ts @@ -1,6 +1,7 @@ import type React from 'react'; import type { AccessibilityRole, StyleProp, TouchableOpacity, View, ViewStyle } from 'react-native'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types'; +import type { SharedInputProps } from '@coinbase/cds-common/types/InputBaseProps'; import type { CellBaseProps } from '../../cells/Cell'; import type { CellAccessoryProps } from '../../cells/CellAccessory'; @@ -237,16 +238,10 @@ export type SelectControlProps< Omit & Pick< InputStackBaseProps, - | 'disabled' - | 'startNode' - | 'variant' - | 'labelVariant' - | 'testID' - | 'endNode' - | 'borderWidth' - | 'focusedBorderWidth' + 'disabled' | 'startNode' | 'variant' | 'labelVariant' | 'testID' | 'endNode' | 'borderRadius' > & Pick & + Pick & SelectState & { /** * Alignment of the value node. @@ -259,6 +254,21 @@ export type SelectControlProps< * @default true */ bordered?: boolean; + /** + * Width of the border. + * @default 100 when bordered is true, 0 otherwise + */ + borderWidth?: InputStackBaseProps['borderWidth']; + /** + * Additional border width when focused. + * @default 200 when bordered is false, otherwise equals borderWidth + */ + focusedBorderWidth?: InputStackBaseProps['focusedBorderWidth']; + /** + * Background of the input. + * @default 'bgSecondary' when readOnly and not disabled, 'bg' otherwise + */ + inputBackground?: InputStackBaseProps['inputBackground']; /** Array of options to display in the select dropdown. Can be individual options or groups with `label` and `options` */ options: SelectOptionList; /** Label displayed above the control */ @@ -419,6 +429,13 @@ export type SelectBaseProps< | 'align' | 'font' | 'bordered' + | 'borderWidth' + | 'focusedBorderWidth' + | 'inputBackground' + | 'labelColor' + | 'labelFont' + | 'readOnly' + | 'borderRadius' > & Pick, 'accessory' | 'media' | 'end'> & Pick< diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx index cc691087e6..3b1249cdbc 100644 --- a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx @@ -114,15 +114,14 @@ export const customComponentConfig: ComponentConfig = { height: props.compact ? 24 : 32, }), - Select: (props) => ({ + Select: ({ readOnly, ...props }) => ({ bordered: false, variant: 'foregroundMuted', - inputBackground: 'bgAlternate', + inputBackground: readOnly ? 'bgSecondary' : 'bgAlternate', focusedBorderWidth: 100, - height: props.compact ? 24 : props.labelVariant === 'inside' ? 40 : 32, - font: props.compact ? 'label2' : 'body', + font: props.compact ? (props.align === 'end' ? 'label1' : 'label2') : 'body', labelColor: 'fgMuted', - labelFont: props.compact ? (props.align === 'end' ? 'label1' : 'label2') : 'body', + labelFont: 'label2', }), ListCell: (props) => { diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx index f5a009132c..07d51d44db 100644 --- a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx @@ -42,6 +42,14 @@ export const SelectExample = memo(() => { placeholder="Compact end align" value={selectValue} /> + ); }); diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 0e2e5ec3de..b97becef1f 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -68,6 +68,7 @@ const DefaultSelectControlComponent = memo( open, placeholder, disabled, + readOnly, setOpen, variant, helperText, @@ -80,9 +81,13 @@ const DefaultSelectControlComponent = memo( blendStyles, align = 'start', font = 'body', + labelColor = 'fg', + labelFont = 'label1', bordered = true, borderWidth = bordered ? 100 : 0, focusedBorderWidth = bordered ? undefined : 200, + inputBackground = !disabled && readOnly ? 'bgSecondary' : 'bg', + borderRadius, maxSelectedOptionsToShow = 6, hiddenSelectedOptionsLabel = 'more', removeSelectedOptionAccessibilityLabel = 'Remove', @@ -96,6 +101,14 @@ const DefaultSelectControlComponent = memo( }: SelectControlProps, ref: React.Ref, ) => { + const isInteractionBlocked = disabled || readOnly; + const disableFocusedStyle = !bordered && focusedBorderWidth === 200; + + const handleToggleOpen = useCallback(() => { + if (isInteractionBlocked) return; + setOpen((currentOpen) => !currentOpen); + }, [isInteractionBlocked, setOpen]); + type ValueType = Type extends 'multi' ? SelectOptionValue | SelectOptionValue[] | null : SelectOptionValue | null; @@ -246,7 +259,8 @@ const DefaultSelectControlComponent = memo( return ( @@ -262,6 +276,8 @@ const DefaultSelectControlComponent = memo( classNames?.controlLabelNode, styles?.controlLabelNode, label, + labelColor, + labelFont, ]); const inlineLabelNode = useMemo(() => { @@ -271,7 +287,8 @@ const DefaultSelectControlComponent = memo( return ( @@ -287,6 +304,8 @@ const DefaultSelectControlComponent = memo( classNames?.controlLabelNode, styles?.controlLabelNode, label, + labelColor, + labelFont, ]); const valueNode = useMemo(() => { @@ -318,7 +337,11 @@ const DefaultSelectControlComponent = memo( disabled={option.disabled} invertColorScheme={false} maxWidth={200} - onClick={(event) => handleUnselectValue(event, index)} + onClick={ + isInteractionBlocked + ? undefined + : (event) => handleUnselectValue(event, index) + } > {option.label ?? option.description ?? option.value ?? ''} @@ -362,6 +385,7 @@ const DefaultSelectControlComponent = memo( optionsMap, removeSelectedOptionAccessibilityLabel, handleUnselectValue, + isInteractionBlocked, ]); const inputNode = useMemo( @@ -372,6 +396,7 @@ const DefaultSelectControlComponent = memo( accessibilityLabel={computedControlAccessibilityLabel} aria-expanded={open} aria-haspopup={ariaHaspopup} + aria-readonly={readOnly} as={role === 'combobox' ? 'div' : 'button'} background="transparent" blendStyles={interactableBlendStyles} @@ -382,7 +407,7 @@ const DefaultSelectControlComponent = memo( flexShrink={1} focusable={false} minWidth={0} - onClick={() => setOpen((s) => !s)} + onClick={handleToggleOpen} onKeyDown={onKeyDown} role={role} style={styles?.controlInputNode} @@ -468,6 +493,7 @@ const DefaultSelectControlComponent = memo( classNames?.controlStartNode, classNames?.controlValueNode, disabled, + readOnly, styles?.controlInputNode, styles?.controlStartNode, styles?.controlValueNode, @@ -480,13 +506,13 @@ const DefaultSelectControlComponent = memo( align, valueNode, contentNode, - setOpen, + handleToggleOpen, ], ); const endNode = useMemo( () => ( - setOpen((s) => !s)} tabIndex={-1}> + - {customEndNode ? ( - customEndNode - ) : ( - - )} + {customEndNode ? customEndNode : } ), @@ -513,8 +532,7 @@ const DefaultSelectControlComponent = memo( styles?.controlEndNode, customEndNode, open, - variant, - setOpen, + handleToggleOpen, ], ); @@ -532,11 +550,15 @@ const DefaultSelectControlComponent = memo( { - if (disabled || open) return; + if (disabled || readOnly || open) return; if (event.ctrlKey || event.metaKey || event.altKey) return; const key = event.key; @@ -158,7 +165,7 @@ const SelectBase = memo( setOpen(true); } }, - [disabled, open, setOpen], + [disabled, readOnly, open, setOpen], ); useEffect(() => { @@ -307,16 +314,22 @@ const SelectBase = memo( align={align} ariaHaspopup={accessibilityRoles?.dropdown} blendStyles={styles?.controlBlendStyles} + borderRadius={borderRadius} + borderWidth={borderWidth} bordered={bordered} className={classNames?.control} classNames={controlClassNames} compact={compact} disabled={disabled} endNode={endNode} + focusedBorderWidth={focusedBorderWidth} font={font} helperText={helperText} hiddenSelectedOptionsLabel={hiddenSelectedOptionsLabel} + inputBackground={inputBackground} label={label} + labelColor={labelColor} + labelFont={labelFont} labelVariant={labelVariant} maxSelectedOptionsToShow={maxSelectedOptionsToShow} onChange={onChange} @@ -324,6 +337,7 @@ const SelectBase = memo( open={open} options={options} placeholder={placeholder} + readOnly={readOnly} removeSelectedOptionAccessibilityLabel={removeSelectedOptionAccessibilityLabel} setOpen={setOpen} startNode={startNode} @@ -354,7 +368,7 @@ const SelectBase = memo( label={label} media={media} onChange={onChange} - open={hasMounted && open} + open={hasMounted && open && !readOnly} options={options} selectAllLabel={selectAllLabel} setOpen={setOpen} diff --git a/packages/web/src/alpha/select/__tests__/Select.test.tsx b/packages/web/src/alpha/select/__tests__/Select.test.tsx index 29b82019b6..b311555e71 100644 --- a/packages/web/src/alpha/select/__tests__/Select.test.tsx +++ b/packages/web/src/alpha/select/__tests__/Select.test.tsx @@ -3,6 +3,7 @@ import { renderA11y } from '@coinbase/cds-web-utils/jest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ComponentConfigProvider } from '../../../system'; import { DefaultThemeProvider } from '../../../utils/test'; import { Select, type SelectProps } from '../Select'; @@ -670,6 +671,34 @@ describe('Select', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); + it('does not open dropdown when readOnly', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('o'); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + it('does not open dropdown when modifier key + letter is pressed', async () => { const user = userEvent.setup(); render( @@ -686,6 +715,94 @@ describe('Select', () => { }); }); + describe('readOnly', () => { + it('does not apply disabled opacity styling', () => { + render( + + + + , + ); + + const inputArea = screen.getByTestId('input-interactable-area'); + expect(inputArea).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + }); + + it('applies Select defaults from ComponentConfigProvider', () => { + render( + + + + + , + ); + + const inputArea = screen.getByTestId('input-interactable-area'); + expect(inputArea).toHaveStyle({ backgroundColor: 'var(--color-bgPrimary)' }); + expect(screen.getByText(defaultProps.label as string)).toHaveStyle({ + color: 'var(--color-fg)', + }); + }); + }); + describe('Ref Forwarding', () => { it('forwards ref correctly', () => { const ref = React.createRef(); diff --git a/packages/web/src/alpha/select/types.ts b/packages/web/src/alpha/select/types.ts index 850dd99c84..3f673f3460 100644 --- a/packages/web/src/alpha/select/types.ts +++ b/packages/web/src/alpha/select/types.ts @@ -1,5 +1,6 @@ import type React from 'react'; import type { SharedAccessibilityProps } from '@coinbase/cds-common'; +import type { SharedInputProps } from '@coinbase/cds-common/types/InputBaseProps'; import type { CellBaseProps } from '../../cells/Cell'; import type { CellAccessoryProps } from '../../cells/CellAccessory'; @@ -393,8 +394,9 @@ export type SelectControlProps< Omit, 'borderWidth' | 'onChange'> & Pick< InputStackBaseProps, - 'disabled' | 'startNode' | 'variant' | 'labelVariant' | 'testID' | 'endNode' + 'disabled' | 'startNode' | 'variant' | 'labelVariant' | 'testID' | 'endNode' | 'borderRadius' > & + Pick & SelectState & { /** * Alignment of the value node. @@ -414,9 +416,14 @@ export type SelectControlProps< borderWidth?: InputStackBaseProps['borderWidth']; /** * Additional border width when focused. - * @default 200 when bordered is false, undefined otherwise + * @default 200 when bordered is false, otherwise equals borderWidth */ focusedBorderWidth?: InputStackBaseProps['focusedBorderWidth']; + /** + * Background of the input. + * @default 'bgSecondary' when readOnly and not disabled, 'bg' otherwise + */ + inputBackground?: InputStackBaseProps['inputBackground']; /** Array of options to display in the select dropdown. Can be individual options or groups with `label` and `options` */ options: SelectOptionList; /** Label displayed above the control */ @@ -510,6 +517,13 @@ export type SelectBaseProps< | 'align' | 'font' | 'bordered' + | 'borderWidth' + | 'focusedBorderWidth' + | 'inputBackground' + | 'labelColor' + | 'labelFont' + | 'readOnly' + | 'borderRadius' > & Pick, 'accessory' | 'media' | 'end'> & Pick<