diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx index 859d8038dc..0ef0bc1946 100644 --- a/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx +++ b/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx @@ -51,7 +51,7 @@ export const customComponentConfig: ComponentConfig = { Radio: (props) => ({ background: 'bg', - borderWidth: props.checked ? 200 : 100, + borderWidth: props.checked ? 200 : 200, borderColor: props.checked ? 'bgPrimary' : 'bgLinePrimarySubtle', controlColor: 'bgPrimary', dotSize: 20 / 3, @@ -117,7 +117,7 @@ export const customComponentConfig: ComponentConfig = { Select: (props) => ({ bordered: false, variant: 'foregroundMuted', - inputBackground: 'bgAlternate', + inputBackground: props.readOnly || props.disabled ? 'bgSecondary' : 'bgAlternate', focusedBorderWidth: 100, height: props.compact ? 24 : props.labelVariant === 'inside' ? 40 : 32, font: props.compact ? 'label2' : 'body', diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx index c5514d87c9..73f9937c1a 100644 --- a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx @@ -15,18 +15,14 @@ const selectOptions = [ export const SelectExample = memo(() => { const [selectValue, setSelectValue] = useState(null); - // Select stories run with a11y test off due to a known nested-interactive issue - return ( - + { labelVariant="inside" onChange={setSelectValue} options={selectOptions} - placeholder="Select an option" - style={{ flexGrow: 1 }} + placeholder="Default input" + style={{ width: '100%' }} + value={selectValue} + /> + diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Tabs.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Tabs.tsx index 20d4a72fb9..9baf750392 100644 --- a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Tabs.tsx +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Tabs.tsx @@ -1,6 +1,5 @@ import { memo, useState } from 'react'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; -import { DefaultTabsActiveIndicator } from '@coinbase/cds-web/tabs/DefaultTabsActiveIndicator'; import { Tabs } from '@coinbase/cds-web/tabs/Tabs'; import { VStack } from '../../../layout'; diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 2c93074c3f..5971b4f150 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -21,9 +21,6 @@ import { type SelectType, } from './Select'; -// The height is smaller for the inside label variant since the label takes -// up space above the input. -const LABEL_VARIANT_INSIDE_HEIGHT = 32; const COMPACT_HEIGHT = 40; const DEFAULT_HEIGHT = 56; @@ -74,11 +71,14 @@ const DefaultSelectControlComponent = memo( open, placeholder, disabled, + readOnly = false, setOpen, variant, helperText, label, labelVariant, + labelColor = 'fg', + labelFont = 'label1', contentNode, startNode, endNode: customEndNode, @@ -98,6 +98,7 @@ const DefaultSelectControlComponent = memo( onKeyDown, styles, classNames, + height, ...props }: SelectControlProps, ref: React.Ref, @@ -106,6 +107,7 @@ const DefaultSelectControlComponent = memo( ? SelectOptionValue | SelectOptionValue[] | null : SelectOptionValue | null; const isMultiSelect = type === 'multi'; + const canOpen = !disabled && !readOnly; const shouldShowCompactLabel = compact && label && !isMultiSelect; const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); // Map of options to their values @@ -189,6 +191,7 @@ const DefaultSelectControlComponent = memo( const valueNodeContainerRef = useRef(null); const handleUnselectValue = useCallback( (event: React.MouseEvent, index: number) => { + if (readOnly) return; // Unselect the value event.stopPropagation(); const currentValue = [...(value as SelectOptionValue[])]; @@ -208,7 +211,7 @@ const DefaultSelectControlComponent = memo( if (focusIndex === null) return controlPressableRef.current?.focus(); (valueNodes[focusIndex] as HTMLElement)?.focus(); }, - [onChange, value], + [onChange, readOnly, value], ); const interactableBlendStyles = useMemo( @@ -243,12 +246,15 @@ const DefaultSelectControlComponent = memo( const labelNode = useMemo( () => labelVariant === 'inside' ? ( - setOpen((s) => !s)} tabIndex={-1}> + canOpen && setOpen((s) => !s)} tabIndex={-1}> {label} @@ -257,7 +263,8 @@ const DefaultSelectControlComponent = memo( ) : typeof label === 'string' ? ( {label} @@ -265,7 +272,17 @@ const DefaultSelectControlComponent = memo( ) : ( label ), - [labelVariant, classNames?.controlLabelNode, styles?.controlLabelNode, label, setOpen], + [ + labelVariant, + classNames?.controlLabelNode, + styles?.controlLabelNode, + label, + setOpen, + canOpen, + labelColor, + labelFont, + startNode, + ], ); const valueNode = useMemo(() => { @@ -294,7 +311,7 @@ const DefaultSelectControlComponent = memo( accessibilityLabel={`${removeSelectedOptionAccessibilityLabel} ${accessibilityLabel}`} borderWidth={0} classNames={{ content: selectedOptionChipContentCss }} - disabled={option.disabled} + disabled={readOnly || option.disabled} invertColorScheme={false} maxWidth={200} onClick={(event) => handleUnselectValue(event, index)} @@ -340,6 +357,7 @@ const DefaultSelectControlComponent = memo( hiddenSelectedOptionsLabel, optionsMap, removeSelectedOptionAccessibilityLabel, + readOnly, handleUnselectValue, ]); @@ -362,15 +380,17 @@ const DefaultSelectControlComponent = memo( focusable={false} minHeight={ labelVariant === 'inside' - ? LABEL_VARIANT_INSIDE_HEIGHT - : compact - ? COMPACT_HEIGHT - : DEFAULT_HEIGHT + ? undefined + : height !== undefined && height !== null + ? height + : compact + ? COMPACT_HEIGHT + : DEFAULT_HEIGHT } minWidth={0} - onClick={() => setOpen((s) => !s)} + onClick={() => canOpen && setOpen((s) => !s)} onKeyDown={onKeyDown} - paddingStart={1} + paddingStart={labelVariant === 'inside' ? 0 : 1} role={role} style={styles?.controlInputNode} tabIndex={tabIndex} @@ -397,7 +417,7 @@ const DefaultSelectControlComponent = memo( alignItems="center" flexGrow={1} flexShrink={1} - height="100%" + height={labelVariant === 'inside' ? undefined : '100%'} justifyContent="space-between" minWidth={0} width="100%" @@ -413,8 +433,12 @@ const DefaultSelectControlComponent = memo( justifyContent="flex-start" minWidth={0} overflow="hidden" - paddingX={1} - paddingY={labelVariant === 'inside' && !isMultiSelect ? 0 : compact ? 1 : 1.5} + paddingBottom={labelVariant === 'inside' && !isMultiSelect ? 1 : compact ? 1 : 1.5} + paddingEnd={labelVariant === 'inside' && !isMultiSelect ? 2 : 1} + paddingStart={ + labelVariant === 'inside' && !isMultiSelect ? (startNode ? 0.5 : 2) : 1 + } + paddingTop={labelVariant === 'inside' && !isMultiSelect ? 0 : compact ? 1 : 1.5} style={styles?.controlValueNode} > {valueNode} @@ -433,6 +457,7 @@ const DefaultSelectControlComponent = memo( classNames?.controlStartNode, classNames?.controlValueNode, disabled, + canOpen, labelVariant, compact, styles?.controlInputNode, @@ -448,12 +473,18 @@ const DefaultSelectControlComponent = memo( valueNode, contentNode, setOpen, + height, ], ); const endNode = useMemo( () => ( - setOpen((s) => !s)} tabIndex={-1}> + canOpen && setOpen((s) => !s)} + tabIndex={-1} + > { - 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(() => { @@ -308,15 +315,21 @@ const SelectBase = memo( ariaHaspopup={accessibilityRoles?.dropdown} blendStyles={styles?.controlBlendStyles} bordered={bordered} + borderWidth={borderWidth} className={classNames?.control} classNames={controlClassNames} compact={compact} disabled={disabled} endNode={endNode} font={font} + focusedBorderWidth={focusedBorderWidth} helperText={helperText} hiddenSelectedOptionsLabel={hiddenSelectedOptionsLabel} + height={height} + 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} @@ -347,7 +361,7 @@ const SelectBase = memo( clearAllLabel={clearAllLabel} compact={compact} controlRef={refs.reference as React.MutableRefObject} - disabled={disabled} + disabled={disabled || !!readOnly} emptyOptionsLabel={emptyOptionsLabel} end={end} hideSelectAll={hideSelectAll} diff --git a/packages/web/src/alpha/select/types.ts b/packages/web/src/alpha/select/types.ts index 547a6aaea0..2e51e5bcdd 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 { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { CellBaseProps } from '../../cells/Cell'; import type { InputStackBaseProps } from '../../controls/InputStack'; @@ -388,7 +389,7 @@ export type SelectControlProps< Omit, 'borderWidth' | 'onChange'> & Pick< InputStackBaseProps, - 'disabled' | 'startNode' | 'variant' | 'labelVariant' | 'testID' | 'endNode' + 'disabled' | 'startNode' | 'variant' | 'labelVariant' | 'testID' | 'endNode' | 'inputBackground' > & SelectState & { /** @@ -440,6 +441,15 @@ export type SelectControlProps< ariaHaspopup?: AriaHasPopupType; /** Whether to use compact styling for the control */ compact?: boolean; + /** + * When true, the value cannot be changed and the menu will not open (similar to a read-only text field). + * Unlike `disabled`, this does not reduce the control’s opacity. + */ + readOnly?: boolean; + /** Typography token for the field label. */ + labelFont?: ThemeVars.Font; + /** Color token for the field label. */ + labelColor?: ThemeVars.Color; /** Inline styles for the control element */ style?: React.CSSProperties; /** Custom styles for individual elements of the control */ @@ -500,11 +510,18 @@ export type SelectBaseProps< | 'startNode' | 'variant' | 'disabled' + | 'readOnly' | 'labelVariant' | 'endNode' | 'align' | 'font' | 'bordered' + | 'borderWidth' + | 'focusedBorderWidth' + | 'height' + | 'inputBackground' + | 'labelColor' + | 'labelFont' > & Pick, 'accessory' | 'media' | 'end'> & Pick<