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(