Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions packages/ocean-react/src/Chips/Chips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ interface IChips {
* Whether the chip button is a multi-choice.
*/
multiChoice?: boolean;
/*
* Whether the options popover holds a single selection. Options render as
* radios and the footer keeps the clear/filter actions, so the selection is a
* draft until it is confirmed and "nothing selected" stays a valid state.
*/
singleSelection?: boolean;
/*
* The options of the chip button.
*/
Expand Down Expand Up @@ -90,6 +96,7 @@ const Chips: React.FunctionComponent<IChips> = ({
defaultValue,
options = [],
multiChoice = false,
singleSelection = false,
clearLabel = 'Limpar',
filterLabel = 'Filtrar',
initialCounter,
Expand All @@ -116,6 +123,7 @@ const Chips: React.FunctionComponent<IChips> = ({
} = useChip({
defaultValue,
multiChoice,
singleSelection,
onChange,
onClean,
onConfirm,
Expand All @@ -135,7 +143,7 @@ const Chips: React.FunctionComponent<IChips> = ({

const contextualMenuItems = React.useMemo(
() =>
multiChoice
multiChoice || singleSelection
? []
: options.map(
({
Expand All @@ -154,7 +162,7 @@ const Chips: React.FunctionComponent<IChips> = ({
type: 'neutral' as const,
})
),
[multiChoice, options]
[multiChoice, singleSelection, options]
);

const handleContextualMenuSelect = (value: string) => {
Expand All @@ -174,7 +182,7 @@ const Chips: React.FunctionComponent<IChips> = ({
const shouldRenderOptions = selectionIsOpen && hasOptions;

const renderOptions = () => {
if (multiChoice) {
if (multiChoice || singleSelection) {
return (
<MultipleChoiceOptions
options={options}
Expand All @@ -183,6 +191,7 @@ const Chips: React.FunctionComponent<IChips> = ({
clearLabel={clearLabel}
filterLabel={filterLabel}
multiChoice={multiChoice}
singleSelection={singleSelection}
clearOptions={clearOptions}
filterOptions={filterOptions}
headerOptions={headerOptions}
Expand Down
31 changes: 21 additions & 10 deletions packages/ocean-react/src/Chips/MultipleChoiceOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface IMultipleChoiceOptions {
clearLabel: string;
filterLabel: string;
multiChoice: boolean;
singleSelection?: boolean;
onSelect: (label: string, value: string) => void;
clearOptions: () => void;
filterOptions: () => void;
Expand All @@ -29,6 +30,7 @@ const MultipleChoiceOptions: React.FunctionComponent<
clearLabel,
filterLabel,
multiChoice,
singleSelection = false,
onSelect,
clearOptions,
filterOptions,
Expand Down Expand Up @@ -66,7 +68,7 @@ const MultipleChoiceOptions: React.FunctionComponent<
});
};

if (!multiChoice) {
if (!multiChoice && !singleSelection) {
return null;
}

Expand Down Expand Up @@ -102,7 +104,7 @@ const MultipleChoiceOptions: React.FunctionComponent<
<X />
</IconButton>
</div>
{selectAllOptions && (
{selectAllOptions && !singleSelection && (
<div className="ods-chips__options__select-all">
<ListSelectable
status="highlight"
Expand All @@ -129,6 +131,14 @@ const MultipleChoiceOptions: React.FunctionComponent<
)
: selectedOptions.value === value;

const selectionInput = {
id: `chips-option-${value}`,
checked: isSelected,
onClick: () => onSelect(label, value),
readOnly: true,
disabled,
};

return (
<div key={value}>
<ListSelectable
Expand All @@ -138,14 +148,15 @@ const MultipleChoiceOptions: React.FunctionComponent<
title={label}
showDivider={index !== options.length - 1}
platform={isMobile ? 'app' : 'web'}
checkbox={{
id: `chips-option-${value}`,
checked: isSelected,
onClick: () => onSelect(label, value),
readOnly: true,
disabled,
indeterminate: indeterminate || false,
}}
radio={singleSelection ? selectionInput : undefined}
checkbox={
singleSelection
? undefined
: {
...selectionInput,
indeterminate: indeterminate || false,
}
}
/>
</div>
);
Expand Down
96 changes: 96 additions & 0 deletions packages/ocean-react/src/Chips/__tests__/Chips.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,100 @@ describe('Chips', () => {

expect(screen.getByText('Option 1')).toBeInTheDocument();
});

describe('single selection', () => {
const options = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
];

const singleSelectionSetup = (props = {}) => {
render(
<Chips
label="Test Label"
options={options}
filterLabel="Test Filter"
clearLabel="Test Clear"
singleSelection
{...props}
/>
);

fireEvent.click(screen.getByText('Test Label'));
};

test('renders the options as radios, keeping the footer actions', async () => {
singleSelectionSetup();

await waitFor(() => {
expect(screen.getAllByRole('radio')).toHaveLength(2);
});

expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
expect(screen.getByText('Test Clear')).toBeInTheDocument();
expect(screen.getByText('Test Filter')).toBeInTheDocument();
});

test('keeps a single option selected', async () => {
const handleChange = jest.fn();
singleSelectionSetup({ onChange: handleChange });

await clickInOption('Option 1');
await clickInOption('Option 2');

expect(handleChange).toHaveBeenLastCalledWith([
{ label: 'Option 2', value: '2' },
]);

const [first, second] = screen.getAllByRole('radio');
expect(first).not.toBeChecked();
expect(second).toBeChecked();
});

test('clicking the selected option gives the empty state back', async () => {
const handleChange = jest.fn();
singleSelectionSetup({ onChange: handleChange });

await clickInOption('Option 1');
await clickInOption('Option 1');

expect(handleChange).toHaveBeenLastCalledWith([]);
expect(screen.getAllByRole('radio')[0]).not.toBeChecked();
});

test('confirms the selection on filter, without a counter badge', async () => {
const handleConfirm = jest.fn();
singleSelectionSetup({ onConfirm: handleConfirm });

await clickInOption('Option 1');
fireEvent.click(screen.getByText('Test Filter'));

expect(handleConfirm).toHaveBeenCalledWith([
{ label: 'Option 1', value: '1' },
]);
expect(screen.queryByRole('tag')).not.toBeInTheDocument();
expect(screen.queryByTestId('ods-chips-option')).not.toBeInTheDocument();
});

test('calls onClean when the selection is cleared', async () => {
const handleClean = jest.fn();
singleSelectionSetup({ onClean: handleClean });

await clickInOption('Option 1');
fireEvent.click(screen.getByText('Test Clear'));

expect(handleClean).toHaveBeenCalled();
expect(screen.getByText('Test Label')).toBeInTheDocument();
});

test('ignores the select all option', async () => {
singleSelectionSetup({ selectAllOptions: true });

await waitFor(() => {
expect(screen.getAllByRole('radio')).toHaveLength(2);
});

expect(screen.queryByText('Selecionar todos')).not.toBeInTheDocument();
});
});
});
35 changes: 30 additions & 5 deletions packages/ocean-react/src/Chips/hooks/useChip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import type { ChipValue, IUseChipOptions, IUseChipReturn } from '../types';

const shouldDefaultToEmpty = (
defaultValue?: ChipValue,
multiChoice?: boolean
) => Boolean(defaultValue) || Boolean(multiChoice);
multiChoice?: boolean,
singleSelection?: boolean
) => Boolean(defaultValue) || Boolean(multiChoice) || Boolean(singleSelection);

const useChip = ({
defaultValue,
multiChoice = false,
singleSelection = false,
onChange,
onClean,
onConfirm,
Expand All @@ -23,7 +25,7 @@ const useChip = ({
const [selectedOptions, setSelectedOptions] = useState<
ChipValue[] | ChipValue
>(() =>
shouldDefaultToEmpty(defaultValue, multiChoice)
shouldDefaultToEmpty(defaultValue, multiChoice, singleSelection)
? []
: { label: '', value: '' }
);
Expand All @@ -32,7 +34,7 @@ const useChip = ({
if (selectedValue && selectedValue !== selectedOptions) {
setSelectedOptions(selectedValue);

if (multiChoice && Array.isArray(selectedValue)) {
if (multiChoice && !singleSelection && Array.isArray(selectedValue)) {
setCounter(selectedValue.length);
}
}
Expand Down Expand Up @@ -71,6 +73,29 @@ const useChip = ({

const handleSelectOption = useCallback(
(labelProp: string, value: string) => {
if (singleSelection) {
setSelectedOptions((prev) => {
const previous = Array.isArray(prev) ? prev : [];
const isSameOption = previous.some(
(option) => option.value === value
);
const matchedOption = options.find(
(option) => option.value === value
);
const next = isSameOption
? []
: [matchedOption ?? { label: labelProp, value }];

if (onChange) {
onChange(next);
}

return next;
});

return;
}

if (!multiChoice) {
const nextSelection = { label: labelProp, value };
setSelectedOptions(nextSelection);
Expand Down Expand Up @@ -108,7 +133,7 @@ const useChip = ({
return copyOptions;
});
},
[multiChoice, onChange, options]
[multiChoice, onChange, options, singleSelection]
);

const clearOptions = useCallback(() => {
Expand Down
34 changes: 34 additions & 0 deletions packages/ocean-react/src/Chips/stories/Chips.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const meta: Meta<typeof Chips> = {
description: 'Permite seleção múltipla de opções.',
control: 'boolean',
},
singleSelection: {
description:
'Escolha única no dropdown: opções como radio, rodapé com limpar/filtrar e "nada selecionado" como estado válido.',
control: 'boolean',
},
options: {
description: 'Array de opções disponíveis para seleção.',
control: false,
Expand Down Expand Up @@ -153,6 +158,35 @@ export const SingleChoice: Story = {
),
};

export const SingleSelection: Story = {
parameters: {
controls: { disable: true },
},
render: () => (
<div
style={{
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
height: '340px',
}}
>
<Chips
label="Período"
options={[
{ label: 'Próximos 7 dias', value: '7' },
{ label: 'Próximos 15 dias', value: '15' },
{ label: 'Próximos 30 dias', value: '30' },
{ label: 'Próximos 60 dias', value: '60' },
]}
clearLabel="Limpar"
filterLabel="Filtrar"
singleSelection
/>
</div>
),
};

export const MultiChoiceWithAmount: Story = {
parameters: {
controls: { disable: true },
Expand Down
1 change: 1 addition & 0 deletions packages/ocean-react/src/Chips/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface IUseChipReturn {
export interface IUseChipOptions {
defaultValue?: ChipValue;
multiChoice?: boolean;
singleSelection?: boolean;
onChange?: (value: ChipValue[] | ChipValue) => void;
onClean?: () => void;
onConfirm?: (value: ChipValue[] | ChipValue) => void;
Expand Down
Loading