diff --git a/.github/workflows/figma.yml b/.github/workflows/figma.yml index f22aed4ae8..2c8bd3fde8 100644 --- a/.github/workflows/figma.yml +++ b/.github/workflows/figma.yml @@ -109,7 +109,14 @@ jobs: audit-figma-integrations: name: Audit Figma Integrations runs-on: ubuntu-latest - if: github.ref_name == 'master' && github.event_name == 'push' + if: github.event_name == 'workflow_dispatch' || (github.ref_name == 'master' && github.event_name == 'push') + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -122,4 +129,15 @@ jobs: - name: Run Audit env: FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} - run: yarn audit-figma-integration + run: yarn audit-figma-integration --html + - name: Prepare Pages directory + run: find temp/ -name "figma-audit-*.html" -exec cp {} temp/index.html \; + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload audit report + uses: actions/upload-pages-artifact@v3 + with: + path: temp/ + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.percy.js b/.percy.js index abbb673f91..fb337fd3bc 100644 --- a/.percy.js +++ b/.percy.js @@ -3,6 +3,7 @@ module.exports = { storybook: { // Useful for isolating Percy diffs when running from the command line exclude: [ + 'Accessibility', 'Core Components/AccessibilityAnnouncer', 'Interactive/Table', 'Interactive/TabNavigation', diff --git a/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx index 388ca2f354..837d4bb3c8 100644 --- a/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx @@ -1,10 +1,32 @@ -## A note on search logic +## Basics -We use [fuse.js](https://www.fusejs.io/) to power the fuzzy search logic for Combobox. You can override this search logic with your own using the `filterFunction` prop. +To start, you can provide a label, an array of options, control state. -## Multi-Select +```jsx +function SingleSelect() { + const singleSelectOptions = [ + { value: null, label: 'Remove selection' }, + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + ]; + const [value, setValue] = useState(null); + + return ( + + ); +} +``` -Basic multi-selection combobox with search. +### Multiple Selections + +You can also allow users to select multiple options with `type="multi"`. ```jsx function MultiSelect() { @@ -14,11 +36,6 @@ function MultiSelect() { { value: '3', label: 'Option 3' }, { value: '4', label: 'Option 4' }, { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, - { value: '10', label: 'Option 10' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'] }); @@ -35,56 +52,109 @@ function MultiSelect() { } ``` -## Single Select +## Search -Single selection combobox with an option to clear the current choice. +We use [fuse.js](https://www.fusejs.io/) for fuzzy search by default. You can override with `filterFunction`. ```jsx -function SingleSelect() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, +function CustomFilter() { + const cryptoOptions = [ + { value: 'btc', label: 'Bitcoin', description: 'BTC • Digital Gold' }, + { value: 'eth', label: 'Ethereum', description: 'ETH • Smart Contracts' }, + { value: 'usdc', label: 'USD Coin', description: 'USDC • Stablecoin' }, + { value: 'sol', label: 'Solana', description: 'SOL • High Performance' }, ]; - const [value, setValue] = useState(null); + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + const filterFunction = (options, searchText) => { + const search = searchText.toLowerCase().trim(); + if (!search) return options; + return options.filter((option) => { + const label = typeof option.label === 'string' ? option.label.toLowerCase() : ''; + const description = + typeof option.description === 'string' ? option.description.toLowerCase() : ''; + return label.startsWith(search) || description.startsWith(search); + }); + }; return ( ); } ``` -## Controlled Search +## Grouped -Manage the search text externally while letting the combobox stay in sync. +Display options under headers using `label` and `options`. Sort options by the same dimension you group by. ```jsx -function ControlledSearch() { +function GroupedOptions() { + const groupedOptions = [ + { + label: 'Fruits', + options: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ], + }, + { + label: 'Vegetables', + options: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'broccoli', label: 'Broccoli' }, + { value: 'spinach', label: 'Spinach' }, + ], + }, + ]; const { value, onChange } = useMultiSelect({ initialValue: [] }); - const [searchText, setSearchText] = useState(''); - const fruitOptions = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'cherry', label: 'Cherry' }, - { value: 'date', label: 'Date' }, + return ( + + ); +} +``` + +## Accessibility + +Use `accessibilityLabel` and `accessibilityHint` to describe purpose and additional context. For multi-select, add hidden-selection labels so screen readers can describe +X summaries. + +```jsx +function AccessibilityProps() { + const priorityOptions = [ + { value: 'high', label: 'High Priority' }, + { value: 'medium', label: 'Medium Priority' }, + { value: 'low', label: 'Low Priority' }, ]; + const { value, onChange } = useMultiSelect({ initialValue: ['medium'] }); + return ( @@ -92,28 +162,34 @@ function ControlledSearch() { } ``` -## Helper Text +## Styling -Use helper text to guide how many selections a user should make. +### Selection Display Limit -```jsx -function HelperTextExample() { - const { value, onChange } = useMultiSelect({ initialValue: [] }); +Cap visible chips with `maxSelectedOptionsToShow`; the rest show as +X more. Pair with `hiddenSelectedOptionsLabel` for screen readers. - const teamOptions = [ - { value: 'john', label: 'John Smith', description: 'Engineering' }, - { value: 'jane', label: 'Jane Doe', description: 'Design' }, - { value: 'bob', label: 'Bob Johnson', description: 'Product' }, - { value: 'alice', label: 'Alice Williams', description: 'Engineering' }, +```jsx +function LimitDisplayedSelections() { + const countryOptions = [ + { value: 'us', label: 'United States', description: 'North America' }, + { value: 'ca', label: 'Canada', description: 'North America' }, + { value: 'mx', label: 'Mexico', description: 'North America' }, + { value: 'uk', label: 'United Kingdom', description: 'Europe' }, + { value: 'fr', label: 'France', description: 'Europe' }, + { value: 'de', label: 'Germany', description: 'Europe' }, ]; + const { value, onChange } = useMultiSelect({ + initialValue: ['us', 'ca', 'mx', 'uk'], + }); return ( @@ -121,13 +197,9 @@ function HelperTextExample() { } ``` -## Alignment +### Alignment -The alignment of the selected value(s) can be adjusted using the `align` prop. - -::::note -Left / right alignment is preferred for styling. -:::: +Align selected values with the `align` prop. ```jsx function AlignmentExample() { @@ -136,27 +208,26 @@ function AlignmentExample() { { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, - { value: 'elderberry', label: 'Elderberry' }, ]; - - const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana', 'cherry'] }); + const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana'] }); return ( @@ -165,51 +236,277 @@ function AlignmentExample() { } ``` -## Borderless +### Borderless -You can remove the border from the combobox control by setting `bordered` to `false`. +Remove the border with `bordered={false}`. ```jsx function BorderlessExample() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const [value, setValue] = useState('apple'); + + return ( + + ); +} +``` + +### Compact + +Use smaller sizing with `compact`. + +```jsx +function CompactExample() { + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const { value, onChange } = useMultiSelect({ initialValue: ['apple'] }); + + return ( + + ); +} +``` + +### Helper Text + +Add guidance with `helperText`. + +```jsx +function HelperTextExample() { + const { value, onChange } = useMultiSelect({ initialValue: [] }); + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, ]; + return ( + + ); +} +``` + +## Composed Examples + +### Country Selection + +You can include flag emoji in labels to create a country selector. + +```jsx +function CountrySelectionExample() { + const getFlagEmoji = (cc) => + cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); + + const countryOptions = [ + { + label: 'North America', + options: [ + { value: 'us', label: `${getFlagEmoji('us')} United States` }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada` }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico` }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom` }, + { value: 'fr', label: `${getFlagEmoji('fr')} France` }, + { value: 'de', label: `${getFlagEmoji('de')} Germany` }, + ], + }, + { + label: 'Asia', + options: [ + { value: 'jp', label: `${getFlagEmoji('jp')} Japan` }, + { value: 'cn', label: `${getFlagEmoji('cn')} China` }, + { value: 'in', label: `${getFlagEmoji('in')} India` }, + ], + }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Free Solo + +You can add a dynamic option to Combobox to enable free solo where users can provide their own value. + +```jsx +function FreeSoloComboboxExample() { + const CREATE_OPTION_PREFIX = '__create__'; + + function FreeSoloCombobox({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps + }) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((o) => o.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prev) => { + const addedStillSelected = prev.filter( + (o) => !initialSet.has(o.value) && valueSet.has(o.value), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [value, freeSolo, initialOptions]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmed = searchText.trim(); + if (!trimmed) return options; + const alreadyExists = options.some( + (o) => typeof o.label === 'string' && o.label.toLowerCase() === trimmed.toLowerCase(), + ); + if (alreadyExists) return options; + return [ + ...options, + { value: `${CREATE_OPTION_PREFIX}${trimmed}`, label: `Add "${trimmed}"` }, + ]; + }, [options, searchText, freeSolo]); + + const handleChange = useCallback( + (newValue) => { + if (!freeSolo) { + onChange(newValue); + return; + } + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((v) => String(v).startsWith(CREATE_OPTION_PREFIX)); + if (createValue) { + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const newOption = { value: newLabel.toLowerCase(), label: newLabel }; + setOptions((prev) => [...prev, newOption]); + const updatedValues = values + .filter((v) => !String(v).startsWith(CREATE_OPTION_PREFIX)) + .concat(newOption.value); + onChange(comboboxProps.type === 'multi' ? updatedValues : newOption.value); + setSearchText(''); + } else { + onChange(newValue); + } + }, + [onChange, freeSolo, comboboxProps.type], + ); + + return ( + + ); + } + + const [standardSingleValue, setStandardSingle] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingle] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, ]; - const [singleValue, setSingleValue] = useState('apple'); - const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ - initialValue: ['apple'], - }); - return ( - - + + ); diff --git a/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx b/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx index 35a17198bb..87ba5ea6fa 100644 --- a/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx @@ -1,10 +1,34 @@ -## A note on search logic +## Basics -We use [fuse.js](https://www.fusejs.io/) to power the fuzzy search logic for Combobox. You can override this search logic with your own using the `filterFunction` prop. +To start, you can provide a label, an array of options, control state. -## Multi-Select +```tsx live +function SingleSelect() { + const singleSelectOptions = [ + { value: null, label: 'Remove selection' }, + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ]; -Basic multi-selection combobox with search. + const [value, setValue] = useState('apple'); + + return ( + + ); +} +``` + +### Multiple Selections + +You can also allow users to select multiple options with `type="multi"`. ```tsx live function MultiSelect() { @@ -26,7 +50,7 @@ function MultiSelect() { { value: 'strawberry', label: 'Strawberry' }, ]; - const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana'] }); + const { value, onChange } = useMultiSelect({ initialValue: [] }); return ( { + const search = searchText.toLowerCase().trim(); + if (!search) return options; + return options.filter((option) => { + const label = typeof option.label === 'string' ? option.label.toLowerCase() : ''; + const description = + typeof option.description === 'string' ? option.description.toLowerCase() : ''; + return label.startsWith(search) || description.startsWith(search); + }); + }, []); return ( ); } ``` -## Helper Text +## Grouped -Communicate limits or guidance by pairing helper text with multi-select usage. +Display options under headers using `label` and `options`. Sort options by the same dimension you group by. ```tsx live -function HelperText() { - const fruitOptions: SelectOption[] = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'cherry', label: 'Cherry' }, - { value: 'date', label: 'Date' }, - { value: 'elderberry', label: 'Elderberry' }, - { value: 'fig', label: 'Fig' }, - { value: 'grape', label: 'Grape' }, - { value: 'honeydew', label: 'Honeydew' }, - { value: 'kiwi', label: 'Kiwi' }, - { value: 'lemon', label: 'Lemon' }, - { value: 'mango', label: 'Mango' }, - { value: 'orange', label: 'Orange' }, - { value: 'papaya', label: 'Papaya' }, - { value: 'raspberry', label: 'Raspberry' }, - { value: 'strawberry', label: 'Strawberry' }, +function GroupedOptions() { + const groupedOptions = [ + { + label: 'Fruits', + options: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ], + }, + { + label: 'Vegetables', + options: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'broccoli', label: 'Broccoli' }, + { value: 'spinach', label: 'Spinach' }, + ], + }, ]; - const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana'] }); + const { value, onChange } = useMultiSelect({ initialValue: [] }); return ( @@ -109,13 +146,75 @@ function HelperText() { } ``` -## Alignment +## Accessibility + +Use accessibility labels to provide clear control and dropdown context. For multi-select, add remove and hidden-selection labels so screen readers can describe chip actions and +X summaries. + +```tsx live +function AccessibilityProps() { + const priorityOptions: SelectOption[] = [ + { value: 'high', label: 'High Priority' }, + { value: 'medium', label: 'Medium Priority' }, + { value: 'low', label: 'Low Priority' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` -The alignment of the selected value(s) can be adjusted using the `align` prop. +## Styling -::::note -Left / right alignment is preferred for styling. -:::: +### Selection Display Limit + +Cap visible chips with `maxSelectedOptionsToShow`; the rest show as +X more. Pair with `hiddenSelectedOptionsLabel` for screen readers. + +```tsx live +function LimitDisplayedSelections() { + const countryOptions: SelectOption[] = [ + { value: 'us', label: 'United States', description: 'North America' }, + { value: 'ca', label: 'Canada', description: 'North America' }, + { value: 'mx', label: 'Mexico', description: 'North America' }, + { value: 'uk', label: 'United Kingdom', description: 'Europe' }, + { value: 'fr', label: 'France', description: 'Europe' }, + { value: 'de', label: 'Germany', description: 'Europe' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Alignment + +Align selected values with the `align` prop. ```tsx live function AlignmentExample() { @@ -124,82 +223,322 @@ function AlignmentExample() { { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, - { value: 'elderberry', label: 'Elderberry' }, - { value: 'fig', label: 'Fig' }, ]; - const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ - initialValue: ['apple', 'banana'], - }); + const { value, onChange } = useMultiSelect({ initialValue: [] }); return ( - + ); } ``` -## Borderless +### Borderless -You can remove the border from the combobox control by setting `bordered` to `false`. +Remove the border with `bordered={false}`. -```jsx live +```tsx live function BorderlessExample() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const [value, setValue] = useState('apple'); + + return ( + + ); +} +``` + +### Compact + +Use smaller sizing with `compact`. + +```tsx live +function CompactExample() { + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Helper Text + +Add guidance with `helperText`. + +```tsx live +function HelperTextExample() { + const { value, onChange } = useMultiSelect({ initialValue: [] }); + const fruitOptions: SelectOption[] = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, ]; + return ( + + ); +} +``` + +## Composed Examples + +### Country Selection + +You can include flag emoji in labels to create a country selector. + +```tsx live +function CountrySelectionExample() { + const getFlagEmoji = (cc) => + cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); + + const countryOptions = [ + { + label: 'North America', + options: [ + { value: 'us', label: `${getFlagEmoji('us')} United States` }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada` }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico` }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom` }, + { value: 'fr', label: `${getFlagEmoji('fr')} France` }, + { value: 'de', label: `${getFlagEmoji('de')} Germany` }, + ], + }, + { + label: 'Asia', + options: [ + { value: 'jp', label: `${getFlagEmoji('jp')} Japan` }, + { value: 'cn', label: `${getFlagEmoji('cn')} China` }, + { value: 'in', label: `${getFlagEmoji('in')} India` }, + ], + }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Free Solo + +You can add a dynamic option to Combobox to enable free solo where users can provide their own value. + +```tsx live +function FreeSoloExample() { + const CREATE_OPTION_PREFIX = '__create__'; + + const FreeSoloCombobox = useMemo(() => { + function StableFreeSoloCombobox({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps + }) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((option) => option.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prevOptions) => { + const addedStillSelected = prevOptions.filter( + (option) => !initialSet.has(option.value) && valueSet.has(option.value), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [freeSolo, initialOptions, value]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmedSearch = searchText.trim(); + if (!trimmedSearch) return options; + + const alreadyExists = options.some( + (option) => + typeof option.label === 'string' && + option.label.toLowerCase() === trimmedSearch.toLowerCase(), + ); + if (alreadyExists) return options; + + return [ + ...options, + { value: `${CREATE_OPTION_PREFIX}${trimmedSearch}`, label: `Add "${trimmedSearch}"` }, + ]; + }, [freeSolo, options, searchText]); + + const handleChange = useCallback( + (newValue) => { + if (!freeSolo) { + onChange(newValue); + return; + } + + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((optionValue) => + String(optionValue).startsWith(CREATE_OPTION_PREFIX), + ); + + if (!createValue) { + onChange(newValue); + return; + } + + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const normalizedValue = newLabel.toLowerCase(); + const newOption = { value: normalizedValue, label: newLabel }; + + setOptions((prevOptions) => [...prevOptions, newOption]); + + const updatedValues = values + .filter((optionValue) => !String(optionValue).startsWith(CREATE_OPTION_PREFIX)) + .concat(normalizedValue); + + onChange(comboboxProps.type === 'multi' ? updatedValues : normalizedValue); + setSearchText(''); + }, + [comboboxProps.type, freeSolo, onChange], + ); + + return ( + + ); + } + + return StableFreeSoloCombobox; + }, [CREATE_OPTION_PREFIX]); + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, ]; - const [singleValue, setSingleValue] = useState('apple'); - const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ - initialValue: ['apple'], - }); + const [standardSingleValue, setStandardSingleValue] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingleValue] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); return ( - - + + ); diff --git a/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx b/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx index 7d8252d12f..fefb28f08d 100644 --- a/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx @@ -1,9 +1,35 @@ -### Basic usage +InputChip is built for remove actions. For other uses, see [Chip](/components/inputs/Chip/) which supports interaction. + +## Basics + +Use `onPress` for remove behavior. + +```tsx +function Example() { + const [selectedValues, setSelectedValues] = React.useState(['BTC', 'ETH', 'SOL']); + + return ( + + {selectedValues.map((value) => ( + setSelectedValues((current) => current.filter((item) => item !== value))} + value={value} + /> + ))} + + ); +} +``` + +### Disabled + +Use `disabled` when the value should stay visible but not removable. ```tsx function Example() { return ( - + console.log('Remove Basic')} value="Basic Chip" /> {}} value="Disabled Chip" /> @@ -11,20 +37,22 @@ function Example() { } ``` -### With Custom Start Element +## Styling + +### With start content ```tsx function Example() { return ( - + console.log('Remove Star')} value="With Icon" start={} /> - + console.log('Remove BTC')} value="BTC" @@ -41,16 +69,54 @@ function Example() { } ``` -### With Custom Accessibility Label +### Compact + +Use `compact` to reduce chip height and spacing in dense layouts. + +```tsx +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Compact')} value="Compact" /> + + ); +} +``` + +### Invert color scheme + +Use `invertColorScheme` to emphasize removable values. + +```tsx +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Inverted')} + value="Inverted" + /> + + ); +} +``` + +## Accessibility + +InputChip defaults to a remove label (`Remove ${children}` for string content, otherwise `Remove option`). +Override `accessibilityLabel` when you need more specific wording. ```tsx function Example() { return ( - + + console.log('Remove BTC')} value="BTC" /> console.log('Remove Custom')} value="Custom Label" - accessibilityLabel="Custom remove action" + accessibilityLabel="Remove custom selection" /> ); diff --git a/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx b/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx index 99c84e6612..31e9ba7b16 100644 --- a/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx @@ -1,9 +1,35 @@ -### Basic usage +InputChip is built for remove actions. For other uses, see [Chip](/components/inputs/Chip/) which supports interaction. + +## Basics + +Use `onClick` for remove behavior. + +```tsx live +function Example() { + const [selectedValues, setSelectedValues] = React.useState(['BTC', 'ETH', 'SOL']); + + return ( + + {selectedValues.map((value) => ( + setSelectedValues((current) => current.filter((item) => item !== value))} + value={value} + /> + ))} + + ); +} +``` + +### Disabled + +Use `disabled` when the value should stay visible but not removable. ```tsx live function Example() { return ( - + console.log('Remove Basic')} value="Basic Chip" /> {}} value="Disabled Chip" /> @@ -11,20 +37,22 @@ function Example() { } ``` -### With Custom Start Element +## Styling + +### With start content ```tsx live function Example() { return ( - + console.log('Remove Star')} value="With Icon" start={} /> - + console.log('Remove BTC')} value="BTC" @@ -41,16 +69,54 @@ function Example() { } ``` -### With Custom Accessibility Label +### Compact + +Use `compact` to reduce chip height and spacing in dense layouts. + +```tsx live +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Compact')} value="Compact" /> + + ); +} +``` + +### Invert color scheme + +Use `invertColorScheme` to emphasize removable values. + +```tsx live +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Inverted')} + value="Inverted" + /> + + ); +} +``` + +## Accessibility + +InputChip defaults to a remove label (`Remove ${children}` for string content, otherwise `Remove option`). +Override `accessibilityLabel` when you need more specific wording. ```tsx live function Example() { return ( - + + console.log('Remove BTC')} value="BTC" /> console.log('Remove Custom')} value="Custom Label" - accessibilityLabel="Custom remove action" + accessibilityLabel="Remove custom selection" /> ); diff --git a/apps/docs/docs/components/inputs/InputChip/index.mdx b/apps/docs/docs/components/inputs/InputChip/index.mdx index 62fb10616b..e0ef1ae47f 100644 --- a/apps/docs/docs/components/inputs/InputChip/index.mdx +++ b/apps/docs/docs/components/inputs/InputChip/index.mdx @@ -28,7 +28,7 @@ import mobileMetadata from './mobileMetadata.json'; title="InputChip" webMetadata={webMetadata} mobileMetadata={mobileMetadata} - description="InputChip is a compact, interactive element that represents a value or action. It's commonly used for filtering, selection, or data entry." + description="InputChip is a compact remove-action element for removable values. Use it when pressing the chip should remove or clear the represented selection." /> + Label only } @@ -22,9 +22,9 @@ MediaChip automatically calculates spacing based on the content you provide (sta ``` -### Configurations +## Layout Configurations -MediaChip supports all 6 spacing configurations automatically. +MediaChip supports all six content combinations automatically. ```tsx @@ -55,11 +55,13 @@ MediaChip supports all 6 spacing configurations automatically. ``` -### Compact Variant +## Styling + +### Compact The compact variant reduces spacing for denser layouts. -:::tip Recommended component sizes for compact chip +:::tip Recommended component sizes for compact chips - Start: **16×16** circular media - End: **xs** size icons @@ -82,15 +84,15 @@ The compact variant reduces spacing for denser layouts. ``` -### Inverted State +### Invert color scheme -Use the inverted prop to emphasize the chip with inverted colors. +Use `invertColorScheme` to emphasize the chip with inverted colors. ```tsx - - Selected + + Selected } start={} > @@ -99,12 +101,32 @@ Use the inverted prop to emphasize the chip with inverted colors. ``` -### Interactive +### Custom spacing + +Override automatic spacing with custom values when needed. + +```tsx + + + Custom spacing + + } + > + Asymmetric padding + + +``` + +## Interactivity -MediaChip can be made interactive by providing an onPress handler. +Provide `onPress` to make MediaChip interactive. Use `disabled` to prevent interaction. ```tsx - + console.log('Pressed!')}>Pressable console.log('Pressed!')} @@ -118,22 +140,23 @@ MediaChip can be made interactive by providing an onPress handler. ``` -### Custom Spacing +## Accessibility -You can override the automatic spacing with custom values if needed. +When `onPress` is provided and visible text is unclear (or absent), provide an `accessibilityLabel`. ```tsx - - - Custom spacing - + console.log('Open ETH')} start={} + /> + } + onPress={() => console.log('Open filter')} > - Asymmetric padding + Filter ``` diff --git a/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx b/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx index d40ac65348..bcf1e24a1b 100644 --- a/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx @@ -1,15 +1,15 @@ -### Basic Usage +MediaChip automatically adjusts spacing based on the combination of `start`, `children`, and `end` content. -MediaChip automatically calculates spacing based on the content you provide (start, children, end). +## Basics -:::tip Recommended component sizes for regular sized chip +:::tip Recommended component sizes for regular-sized chips - Start: **24×24** circular media - End: **xs** size icons ::: ```tsx live - + Label only } @@ -22,9 +22,9 @@ MediaChip automatically calculates spacing based on the content you provide (sta ``` -### Configurations +## Layout Configurations -MediaChip supports all 6 spacing configurations automatically. +MediaChip supports all six content combinations automatically. ```tsx live @@ -55,11 +55,13 @@ MediaChip supports all 6 spacing configurations automatically. ``` -### Compact Variant +## Styling + +### Compact The compact variant reduces spacing for denser layouts. -:::tip Recommended component sizes for compact chip +:::tip Recommended component sizes for compact chips - Start: **16×16** circular media - End: **xs** size icons @@ -82,15 +84,15 @@ The compact variant reduces spacing for denser layouts. ``` -### Inverted State +### Invert color scheme -Use the inverted prop to emphasize the chip with inverted colors. +Use `invertColorScheme` to emphasize the chip with inverted colors. ```tsx live - - Selected + + Selected } start={} > @@ -99,12 +101,32 @@ Use the inverted prop to emphasize the chip with inverted colors. ``` -### Interactive +### Custom spacing + +Override automatic spacing with custom values when needed. + +```tsx live + + + Custom spacing + + } + > + Asymmetric padding + + +``` + +## Interactivity -MediaChip can be made interactive by providing an onClick handler. +Provide `onClick` to make MediaChip interactive. Use `disabled` to prevent interaction. ```tsx live - + alert('Clicked!')}>Clickable alert('Clicked!')} @@ -118,22 +140,23 @@ MediaChip can be made interactive by providing an onClick handler. ``` -### Custom Spacing +## Accessibility -You can override the automatic spacing with custom values if needed. +When `onClick` is provided and visible text is unclear (or absent), provide an `accessibilityLabel`. ```tsx live - - - Custom spacing - + alert('Open ETH')} start={} + /> + } + onClick={() => alert('Open filter')} > - Asymmetric padding + Filter ``` diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 154702cbc3..23983e49dd 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -17,10 +17,16 @@ const createClassName = (hash: string, title: string) => { const isAnalyze = process.env.ANALYZE === 'true'; const isAnalyzeModeJson = process.env.ANALYZE_MODE_JSON === 'true'; +const isPercyBuild = process.env.STORYBOOK_PERCY === 'true'; const bundleStatsFilename = path.resolve( MONOREPO_ROOT, process.env.ANALYZE_REPORT_PATH || 'bundle-stats.json', ); +const addons = [ + '@storybook-community/storybook-dark-mode', + '@storybook/addon-docs', + ...(!isPercyBuild ? ['@storybook/addon-a11y', '@storybook/addon-vitest'] : []), +]; if (isAnalyze) { console.log('Bundle analyzer enabled because process.env.ANALYZE === "true"'); @@ -38,12 +44,7 @@ const config: StorybookConfig = { name: '@storybook/react-vite', options: {}, }, - addons: [ - '@storybook-community/storybook-dark-mode', - '@storybook/addon-docs', - '@storybook/addon-a11y', - '@storybook/addon-vitest', - ], + addons, stories: [ '../../../packages/web/**/*.stories.@(tsx|mdx)', '../../../packages/web-visualization/**/*.stories.@(tsx|mdx)', diff --git a/apps/storybook/project.json b/apps/storybook/project.json index e903a5480d..0706bfb3da 100644 --- a/apps/storybook/project.json +++ b/apps/storybook/project.json @@ -29,6 +29,30 @@ "{projectRoot}/dist" ] }, + "build-for-percy": { + "command": "storybook build --output-dir dist", + "dependsOn": [ + "^build" + ], + "inputs": [ + "{projectRoot}/*", + "{projectRoot}/**/*", + "{projectRoot}/**/__stories__/**", + "{projectRoot}/**/*.stories.*", + "{workspaceRoot}/packages/web/**/*.stories.*", + "{workspaceRoot}/packages/web-visualization/**/*.stories.*", + "!{projectRoot}/scripts/**" + ], + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "cwd": "apps/storybook", + "env": { + "STORYBOOK_PERCY": "true" + } + } + }, "test-a11y": { "command": "vitest --project=storybook", "options": { @@ -134,7 +158,7 @@ "percy": { "command": "tsx ./scripts/run-percy.ts", "dependsOn": [ - "build" + "build-for-percy" ], "inputs": [ "{projectRoot}/dist" diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index a5c9193ced..e032db6a15 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. +## 8.53.0 ((3/16/2026, 01:45 PM PST)) + +This is an artificial version bump with no new change. + ## 8.52.2 ((3/11/2026, 10:02 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 584672b2cf..38dbe1b8a2 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.52.2", + "version": "8.53.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/common/src/tokens/page.ts b/packages/common/src/tokens/page.ts index e9c16f7210..0797730464 100644 --- a/packages/common/src/tokens/page.ts +++ b/packages/common/src/tokens/page.ts @@ -1,3 +1,5 @@ +/** @deprecated Will be removed in a future major release. */ export const pageHeaderHeight = 72; +/** @deprecated Will be removed in a future major release. */ export const pageFooterHeight = 80; diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 4d0fb24391..89394a8b5b 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. +## 8.53.0 ((3/16/2026, 01:45 PM PST)) + +This is an artificial version bump with no new change. + ## 8.52.2 ((3/11/2026, 10:02 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 173bdc8429..e0f459e913 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.52.2", + "version": "8.53.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index ecc684418a..cdefc0461d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 8.53.0 (3/16/2026 PST) + +#### 🚀 Updates + +- Feat: update Checkbox borderRadius to match design. [[#509](https://github.com/coinbase/cds/pull/509)] + ## 8.52.2 (3/11/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b531fbc414..3278ed4520 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.52.2", + "version": "8.53.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/alpha/combobox/__stories__/Combobox.stories.tsx b/packages/mobile/src/alpha/combobox/__stories__/Combobox.stories.tsx index 6d8a0e96df..2a80af6abe 100644 --- a/packages/mobile/src/alpha/combobox/__stories__/Combobox.stories.tsx +++ b/packages/mobile/src/alpha/combobox/__stories__/Combobox.stories.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMultiSelect } from '@coinbase/cds-common/select/useMultiSelect'; import { Button } from '../../../buttons'; @@ -56,15 +56,23 @@ const fruitOptions: SelectOption[] = [ { value: 'lemon', label: 'Lemon' }, ]; +function getFlagEmoji(cc: string): string { + return cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); +} + const countryOptions: SelectOption[] = [ - { value: 'us', label: 'United States', description: 'North America' }, - { value: 'ca', label: 'Canada', description: 'North America' }, - { value: 'mx', label: 'Mexico', description: 'North America' }, - { value: 'uk', label: 'United Kingdom', description: 'Europe' }, - { value: 'fr', label: 'France', description: 'Europe' }, - { value: 'de', label: 'Germany', description: 'Europe' }, - { value: 'jp', label: 'Japan', description: 'Asia' }, - { value: 'cn', label: 'China', description: 'Asia' }, + { value: 'us', label: `${getFlagEmoji('us')} United States`, description: 'North America' }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada`, description: 'North America' }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico`, description: 'North America' }, + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom`, description: 'Europe' }, + { value: 'fr', label: `${getFlagEmoji('fr')} France`, description: 'Europe' }, + { value: 'de', label: `${getFlagEmoji('de')} Germany`, description: 'Europe' }, + { value: 'jp', label: `${getFlagEmoji('jp')} Japan`, description: 'Asia' }, + { value: 'cn', label: `${getFlagEmoji('cn')} China`, description: 'Asia' }, ]; const cryptoOptions: SelectOption[] = [ @@ -84,6 +92,108 @@ const teamOptions: SelectOption[] = [ { value: 'charlie', label: 'Charlie Brown', description: 'Marketing' }, ]; +const CREATE_OPTION_PREFIX = '__create__'; + +type FreeSoloComboboxProps< + Type extends 'single' | 'multi' = 'multi', + SelectOptionValue extends string = string, +> = Omit< + React.ComponentProps, + 'onChange' | 'onSearch' | 'options' | 'searchText' +> & { + freeSolo?: boolean; + onChange: (value: Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null) => void; + options: SelectOption[]; + value: Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null; +}; + +function FreeSoloCombobox< + Type extends 'single' | 'multi' = 'multi', + SelectOptionValue extends string = string, +>({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps +}: FreeSoloComboboxProps) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((o) => o.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prev) => { + const addedStillSelected = prev.filter( + (o) => !initialSet.has(o.value) && valueSet.has(o.value as string), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [value, freeSolo, initialOptions]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmed = searchText.trim(); + if (!trimmed) return options; + const alreadyExists = options.some( + (o) => typeof o.label === 'string' && o.label.toLowerCase() === trimmed.toLowerCase(), + ); + if (alreadyExists) return options; + return [...options, { value: `${CREATE_OPTION_PREFIX}${trimmed}`, label: `Add "${trimmed}"` }]; + }, [options, searchText, freeSolo]); + + const handleChange = useCallback( + (newValue: string | string[] | null) => { + if (!freeSolo) { + onChange(newValue as Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null); + return; + } + + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((v) => String(v).startsWith(CREATE_OPTION_PREFIX)); + + if (createValue) { + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const newOption: SelectOption = { value: newLabel.toLowerCase(), label: newLabel }; + setOptions((prev) => [...prev, newOption]); + const updatedValues = values + .filter((v) => !String(v).startsWith(CREATE_OPTION_PREFIX)) + .concat(newOption.value as string); + + if (comboboxProps.type === 'multi') { + onChange(updatedValues as Type extends 'multi' ? SelectOptionValue[] : never); + } else { + onChange( + newOption.value as SelectOptionValue as Type extends 'multi' + ? never + : SelectOptionValue | null, + ); + } + setSearchText(''); + } else { + onChange(newValue as Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null); + } + }, + [onChange, freeSolo, comboboxProps.type], + ); + + const effectiveOptions = freeSolo ? optionsWithCreate : initialOptions; + const effectiveSearchProps = freeSolo ? { onSearch: setSearchText, searchText } : {}; + + return ( + + ); +} + // Example Components const DefaultExample = () => { const { value, onChange } = useMultiSelect({ initialValue: ['1'] }); @@ -302,6 +412,7 @@ const AccessibilityLabelExample = () => { return ( { ); }; +const FreeSoloComboboxExample = () => { + const [standardSingleValue, setStandardSingle] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingle] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); + + const baseOptions = fruitOptions.slice(0, 6); + + return ( + + + freeSolo={false} + label="Standard single" + onChange={setStandardSingle} + options={baseOptions} + placeholder="Search fruits..." + type="single" + value={standardSingleValue} + /> + + freeSolo + label="FreeSolo single" + onChange={setFreeSoloSingle} + options={baseOptions} + placeholder="Search or type to add..." + type="single" + value={freeSoloSingleValue} + /> + + + + ); +}; + const CustomComponent: ComboboxControlComponent = (props) => { return ; }; @@ -937,7 +1098,7 @@ const Default = () => { - + @@ -1033,6 +1194,9 @@ const Default = () => { + + + ); }; diff --git a/packages/mobile/src/controls/Checkbox.tsx b/packages/mobile/src/controls/Checkbox.tsx index 5f806978e5..0d91a87447 100644 --- a/packages/mobile/src/controls/Checkbox.tsx +++ b/packages/mobile/src/controls/Checkbox.tsx @@ -36,7 +36,7 @@ const CheckboxIcon = memo( controlColor = 'fgInverse', background = checked || indeterminate ? 'bgPrimary' : 'bg', borderColor = checked || indeterminate ? 'bgPrimary' : 'bgLineHeavy', - borderRadius, + borderRadius = 100, borderWidth = 100, elevation, animatedScaleValue, diff --git a/packages/mobile/src/page/PageFooter.tsx b/packages/mobile/src/page/PageFooter.tsx index 41f4dc21d8..cfe939ec22 100644 --- a/packages/mobile/src/page/PageFooter.tsx +++ b/packages/mobile/src/page/PageFooter.tsx @@ -1,7 +1,6 @@ import React, { forwardRef, memo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { pageFooterHeight } from '@coinbase/cds-common/tokens/page'; import type { SharedProps } from '@coinbase/cds-common/types'; import { Box, type BoxProps } from '../layout/Box'; @@ -22,17 +21,23 @@ export type PageFooterProps = PageFooterBaseProps & BoxProps; export const PageFooter = memo( forwardRef(function PageFooter( - { action, ...props }: PageFooterProps, + { + action, + paddingX = 3, + paddingY = 1.5, + alignItems = 'center', + accessibilityRole = 'toolbar', + ...props + }: PageFooterProps, ref: React.ForwardedRef, ) { return ( {action} diff --git a/packages/mobile/src/page/PageHeader.tsx b/packages/mobile/src/page/PageHeader.tsx index 0038001e15..a46e4560c9 100644 --- a/packages/mobile/src/page/PageHeader.tsx +++ b/packages/mobile/src/page/PageHeader.tsx @@ -1,7 +1,6 @@ import React, { forwardRef, memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { pageHeaderHeight } from '@coinbase/cds-common/tokens/page'; import type { SharedProps } from '@coinbase/cds-common/types'; import { Box, type BoxProps } from '../layout/Box'; @@ -57,7 +56,6 @@ export const PageHeader = memo( -## Unreleased +## 8.53.0 (3/16/2026 PST) + +#### 🚀 Updates + +- Feat: update Checkbox borderRadius to match design. [[#509](https://github.com/coinbase/cds/pull/509)] #### 📘 Misc diff --git a/packages/web/package.json b/packages/web/package.json index eaf1dc92f5..ad0ee291c0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.52.2", + "version": "8.53.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx b/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx index 961f5e48c5..12a1a17675 100644 --- a/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx +++ b/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMultiSelect } from '@coinbase/cds-common/select/useMultiSelect'; import { css } from '@linaria/core'; @@ -9,6 +9,7 @@ import { VStack } from '../../../layout/VStack'; import { Text } from '../../../typography/Text'; import type { SelectOptionList } from '../../select'; import type { SelectOption } from '../../select/Select'; +import type { ComboboxProps } from '../Combobox'; import { Combobox, type ComboboxControlComponent, @@ -1252,6 +1253,209 @@ export const DynamicOptions = () => { ); }; +function getFlagEmoji(cc: string): string { + return cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); +} + +const countrySelectionOptions: SelectOptionList<'multi'> = [ + { + label: 'North America', + options: [ + { value: 'us', label: `${getFlagEmoji('us')} United States` }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada` }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico` }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom` }, + { value: 'fr', label: `${getFlagEmoji('fr')} France` }, + { value: 'de', label: `${getFlagEmoji('de')} Germany` }, + ], + }, + { + label: 'Asia', + options: [ + { value: 'jp', label: `${getFlagEmoji('jp')} Japan` }, + { value: 'cn', label: `${getFlagEmoji('cn')} China` }, + { value: 'in', label: `${getFlagEmoji('in')} India` }, + ], + }, +]; + +export const CountrySelectionExample = () => { + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +}; + +const CREATE_OPTION_PREFIX = '__create__'; + +type FreeSoloComboboxProps< + Type extends 'single' | 'multi' = 'multi', + SelectOptionValue extends string = string, +> = Omit< + React.ComponentProps, + 'options' | 'searchText' | 'onSearch' | 'onChange' +> & { + freeSolo?: boolean; + options: SelectOption[]; + value: Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null; + onChange: (value: Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null) => void; +}; + +function FreeSoloCombobox< + Type extends 'single' | 'multi' = 'multi', + SelectOptionValue extends string = string, +>({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps +}: FreeSoloComboboxProps) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((o) => o.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prev) => { + const addedStillSelected = prev.filter( + (o) => !initialSet.has(o.value) && valueSet.has(o.value as string), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [value, freeSolo, initialOptions]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmed = searchText.trim(); + if (!trimmed) return options; + const alreadyExists = options.some( + (o) => typeof o.label === 'string' && o.label.toLowerCase() === trimmed.toLowerCase(), + ); + if (alreadyExists) return options; + return [...options, { value: `${CREATE_OPTION_PREFIX}${trimmed}`, label: `Add "${trimmed}"` }]; + }, [options, searchText, freeSolo]); + + const handleChange = useCallback( + (newValue: string | string[] | null) => { + if (!freeSolo) { + onChange(newValue as Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null); + return; + } + + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((v) => String(v).startsWith(CREATE_OPTION_PREFIX)); + + if (createValue) { + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const newOption: SelectOption = { value: newLabel.toLowerCase(), label: newLabel }; + setOptions((prev) => [...prev, newOption]); + const updatedValues = values + .filter((v) => !String(v).startsWith(CREATE_OPTION_PREFIX)) + .concat(newOption.value as string); + + if (comboboxProps.type === 'multi') { + onChange(updatedValues as Type extends 'multi' ? SelectOptionValue[] : never); + } else { + onChange( + newOption.value as SelectOptionValue as Type extends 'multi' + ? never + : SelectOptionValue | null, + ); + } + setSearchText(''); + } else { + onChange(newValue as Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null); + } + }, + [onChange, freeSolo, comboboxProps.type], + ); + + const effectiveOptions = freeSolo ? optionsWithCreate : initialOptions; + const effectiveSearchProps = freeSolo ? { searchText, onSearch: setSearchText } : {}; + + return ( + + ); +} + +export const FreeSoloComboboxExample = () => { + const [standardSingleValue, setStandardSingle] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingle] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); + + const baseOptions = fruitOptions.slice(0, 6); + + return ( + + + freeSolo={false} + label="Standard single" + onChange={setStandardSingle} + options={baseOptions} + placeholder="Search fruits..." + type="single" + value={standardSingleValue} + /> + + freeSolo + label="FreeSolo single" + onChange={setFreeSoloSingle} + options={baseOptions} + placeholder="Search or type to add..." + type="single" + value={freeSoloSingleValue} + /> + + + + ); +}; + const CustomComponent: ComboboxControlComponent = (props) => { return ; }; diff --git a/packages/web/src/controls/Checkbox.tsx b/packages/web/src/controls/Checkbox.tsx index b08cec4a67..391c93bbfd 100644 --- a/packages/web/src/controls/Checkbox.tsx +++ b/packages/web/src/controls/Checkbox.tsx @@ -62,7 +62,7 @@ const CheckboxWithRef = forwardRef(function CheckboxWithRef['paddingX' desktop: 4, } as const; +export const pageHeaderPaddingY: ResponsiveProps['paddingY'] = 2; + export type PageHeaderBaseProps = SharedProps & PositionStyles & { /** @@ -166,9 +167,9 @@ export const PageHeader = memo( {start} @@ -180,11 +181,12 @@ export const PageHeader = memo( className={cx(start && end ? gridStylesMobileTitleCss : undefined, classNames?.title)} justifyContent="flex-start" paddingStart={titleResponsivePaddingLeft} + paddingY={pageHeaderPaddingY} style={styles?.title} testID="responsive-title-container" > {typeof title === 'string' ? ( - + {title} ) : ( @@ -196,9 +198,9 @@ export const PageHeader = memo(