Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added deselectable prop for radio-type components #351

Merged
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Added the `ChipGroup` component and updated `Chip` to work with it. #327 by @AnnMarieW and @BSd3v
Note: `Chip` was available before but not documented. If you used `Chip` in dmc<=0.14.5, you’ll now need to add `controlled=True` for it to work properly on its own.
- Added GitHub actions workflow for automated tests on PRs by @BSd3v
- RadioGroup and ChipGroup (in single mode) now have a `deselectable` argument to allow resetting the radio value #351


### Fixed
Expand Down
6 changes: 6 additions & 0 deletions src/ts/components/core/chip/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BoxProps } from "props/box";
import { DashBaseProps, PersistenceProps } from "props/dash";
import { StylesApiProps } from "props/styles";
import React from "react";
import ChipGroupContext from "./ChipGroupContext"

interface Props
extends BoxProps,
Expand Down Expand Up @@ -59,11 +60,15 @@ const Chip = (props: Props) => {
const onChange = (checked: boolean) => {
setProps({ checked });
};

const { chipOnClick } = React.useContext(ChipGroupContext) || {};
Copy link
Collaborator

@alexcjohnson alexcjohnson Oct 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice @RenaudLN!

I wonder if we can use this to get rid of the controlled prop entirely (cc @BSd3v)? something like:

const eventProps = {};
if (chipOnClick) {  // we have a ChipGroup context
    eventProps.onClick = chipOnClick;
} else {  // no ChipGroup so this Chip controls itself
    eventProps.checked = checked;
    eventProps.onChange = onChange;
}
return <MantineChip
    data-dash-is-loading={
        (loading_state && loading_state.is_loading) || undefined
    }
    {...eventProps}
    {...others}
>
    {children}
</MantineChip>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something for another PR maybe?

Copy link
Sponsor Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That works too - it would be good to get this PR across the finish line.
We can apply what you did here to make the Chip component even better as Alex suggested -- thanks for the head start.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that’s fine, @AnnMarieW let’s just do it before we make a release and it becomes a breaking change.


if (controlled) {
return (
<MantineChip
checked={checked}
onChange={onChange}
onClick={chipOnClick}
data-dash-is-loading={
(loading_state && loading_state.is_loading) || undefined
}
Expand All @@ -78,6 +83,7 @@ const Chip = (props: Props) => {
data-dash-is-loading={
(loading_state && loading_state.is_loading) || undefined
}
onClick={chipOnClick}
{...others}
>
{children}
Expand Down
14 changes: 13 additions & 1 deletion src/ts/components/core/chip/ChipGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Chip } from "@mantine/core";
import { useDidUpdate } from "@mantine/hooks";
import { DashBaseProps, PersistenceProps } from "props/dash";
import React, { useState } from "react";
import ChipGroupContext from "./ChipGroupContext";

interface Props extends DashBaseProps, PersistenceProps {
/** Determines whether it is allowed to select multiple values, `false` by default */
Expand All @@ -10,6 +11,8 @@ interface Props extends DashBaseProps, PersistenceProps {
value?: string[] | string | null;
/** `Chip` components and any other elements */
children?: React.ReactNode;
/** Allow to deselect Chip in Radio mode */
deselectable?: boolean;
}

/** ChipGroup */
Expand All @@ -22,6 +25,7 @@ const ChipGroup = (props: Props) => {
persisted_props,
persistence_type,
loading_state,
deselectable,
...others
} = props;
const [val, setVal] = useState(value);
Expand All @@ -34,6 +38,12 @@ const ChipGroup = (props: Props) => {
setProps({ value: val });
}, [val]);

const handleChipClick = (event: React.MouseEvent<HTMLInputElement>) => {
if (event.currentTarget.value === value) {
setVal(null);
}
};

return (
<Chip.Group
value={val}
Expand All @@ -43,7 +53,9 @@ const ChipGroup = (props: Props) => {
}
{...others}
>
{children}
<ChipGroupContext.Provider value={{ chipOnClick: deselectable ? handleChipClick : null }}>
{children}
</ChipGroupContext.Provider>
</Chip.Group>
);
};
Expand Down
9 changes: 9 additions & 0 deletions src/ts/components/core/chip/ChipGroupContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React, { createContext } from "react";

interface ChipGroupContextProps {
chipOnClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}

const ChipGroupContext = createContext<ChipGroupContextProps | null>(null);

export default ChipGroupContext;
4 changes: 4 additions & 0 deletions src/ts/components/core/radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BoxProps } from "props/box";
import { DashBaseProps, PersistenceProps } from "props/dash";
import { StylesApiProps } from "props/styles";
import React from "react";
import RadioGroupContext from "./RadioGroupContext";

interface Props
extends BoxProps,
Expand Down Expand Up @@ -49,12 +50,15 @@ const Radio = (props: Props) => {
...others
} = props;

const { radioOnClick } = React.useContext(RadioGroupContext) || {};

return (
<MantineRadio
data-dash-is-loading={
(loading_state && loading_state.is_loading) || undefined
}
onChange={(ev) => setProps({ checked: ev.currentTarget.checked })}
onClick={radioOnClick}
{...others}
/>
);
Expand Down
14 changes: 13 additions & 1 deletion src/ts/components/core/radio/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MantineSize, Radio } from "@mantine/core";
import { DashBaseProps, PersistenceProps } from "props/dash";
import { InputWrapperProps } from "props/input";
import React from "react";
import RadioGroupContext from "./RadioGroupContext";

interface Props extends InputWrapperProps, DashBaseProps, PersistenceProps {
/** `Radio` components and any other elements */
Expand All @@ -16,6 +17,8 @@ interface Props extends InputWrapperProps, DashBaseProps, PersistenceProps {
name?: string;
/** If set, value cannot be changed */
readOnly?: boolean;
/** Allow to deselect Chip in Radio mode */
deselectable?: boolean;
}

/** RadioGroup */
Expand All @@ -28,13 +31,20 @@ const RadioGroup = (props: Props) => {
persistence,
persisted_props,
persistence_type,
deselectable,
...others
} = props;

const onChange = (value: string) => {
setProps({ value });
};

const handleRadioClick = (event: React.MouseEvent<HTMLInputElement>) => {
if (event.currentTarget.value === value) {
setProps({ value: null });
}
};

return (
<Radio.Group
data-dash-is-loading={
Expand All @@ -44,7 +54,9 @@ const RadioGroup = (props: Props) => {
value={value}
{...others}
>
{children}
<RadioGroupContext.Provider value={{radioOnClick: deselectable ? handleRadioClick : null}}>
{children}
</RadioGroupContext.Provider>
</Radio.Group>
);
};
Expand Down
9 changes: 9 additions & 0 deletions src/ts/components/core/radio/RadioGroupContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React, { createContext } from "react";

interface RadioGroupContextProps {
radioOnClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}

const RadioGroupContext = createContext<RadioGroupContextProps | null>(null);

export default RadioGroupContext;
100 changes: 100 additions & 0 deletions tests/test_chip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from dash import Dash, html, Output, Input, _dash_renderer
import dash_mantine_components as dmc

_dash_renderer._set_react_version("18.2.0")


def chipgroup_app(**kwargs):
app = Dash(__name__)

app.layout = dmc.MantineProvider(
html.Div([
dmc.ChipGroup(
id="chip-group",
children=dmc.Group([
dmc.Chip(value="option1", children="Option 1"),
dmc.Chip(value="option2", children="Option 2"),
dmc.Chip(value="option3", children="Option 3"),
]),
**kwargs,
),
html.Div(id="output")
])
)

@app.callback(
Output("output", "children"),
Input("chip-group", "value")
)
def update_output(selected_values):
return f'Selected: {selected_values}'

return app


def test_001ch_chip_group(dash_duo):

app = chipgroup_app()
dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("div.mantine-Group-root")

option1 = dash_duo.find_element("input[value='option1']").find_element_by_xpath("./..")
option2 = dash_duo.find_element("input[value='option2']").find_element_by_xpath("./..")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option1")
option2.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option2")
# Not deselectable
option2.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option2")


def test_002ch_chip_group_deselectable(dash_duo):

app = chipgroup_app(deselectable=True)
dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("div.mantine-Group-root")

option1 = dash_duo.find_element("input[value='option1']").find_element_by_xpath("./..")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option1")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: None")


def test_003ch_chip_group_deselectable_multiple(dash_duo):

app = chipgroup_app(deselectable=True, multiple=True)
dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("div.mantine-Group-root")

option1 = dash_duo.find_element("input[value='option1']").find_element_by_xpath("./..")
option2 = dash_duo.find_element("input[value='option2']").find_element_by_xpath("./..")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1']")
option2.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1', 'option2']")
option2.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1']")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: []")


def test_004ch_single_chip(dash_duo):

app = Dash(__name__)

app.layout = dmc.MantineProvider(dmc.Chip("Radio a", value="a", id="a"))

dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("#a")

assert not dash_duo.get_logs()
80 changes: 80 additions & 0 deletions tests/test_radio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from dash import Dash, html, Output, Input, _dash_renderer
import dash_mantine_components as dmc

_dash_renderer._set_react_version("18.2.0")


def radiogroup_app(**kwargs):
app = Dash(__name__)

app.layout = dmc.MantineProvider(
html.Div([
dmc.RadioGroup(
id="radio-group",
children=dmc.Group([
dmc.Radio(value="option1", label="Option 1"),
dmc.Radio(value="option2", label="Option 2"),
dmc.Radio(value="option3", label="Option 3"),
]),
**kwargs,
),
html.Div(id="output")
])
)

@app.callback(
Output("output", "children"),
Input("radio-group", "value")
)
def update_output(selected_values):
return f'Selected: {selected_values}'

return app


def test_001ra_radio_group(dash_duo):

app = radiogroup_app()
dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("div[aria-labelledby='radio-group-label']")

option1 = dash_duo.find_element("input[value='option1']")
option2 = dash_duo.find_element("input[value='option2']")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option1")
option2.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option2")
# Not deselectable
option2.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option2")


def test_002ra_radio_group_deselectable(dash_duo):

app = radiogroup_app(deselectable=True)
dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("div[aria-labelledby='radio-group-label']")

option1 = dash_duo.find_element("input[value='option1']")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: option1")
option1.click()
dash_duo.wait_for_text_to_equal("#output", "Selected: None")


def test_003ra_single_radio(dash_duo):

app = Dash(__name__)

app.layout = dmc.MantineProvider(dmc.Radio(label="Radio a", value="a", id="a"))

dash_duo.start_server(app)

# Wait for the app to load
dash_duo.wait_for_element("#a")

assert not dash_duo.get_logs()
Loading