diff --git a/CHANGELOG.md b/CHANGELOG.md index 6471758b..8cfc13fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - If you were using `dmc < 0.15.0`, please follow our [migration guide](https://www.dash-mantine-components.com/migration). - ⚠️ **Important:** Apps using `dmc < 1.0.0` must pin `dash < 3` to avoid compatibility issues. +### Added + +- Added `CheckboxCard` `CheckboxIndicator` `RadioCard` `RadioIndicator` components #486 by @deadkex + ### Changed - Updated to handle changes in Dash 3 #506 by @AnnMarieW: - Removed `defaultProps` to be compatible with React 18.3 diff --git a/src/ts/components/core/checkbox/CheckboxCard.tsx b/src/ts/components/core/checkbox/CheckboxCard.tsx new file mode 100644 index 00000000..2d98f80e --- /dev/null +++ b/src/ts/components/core/checkbox/CheckboxCard.tsx @@ -0,0 +1,59 @@ +import { + CheckboxCard as MantineCheckboxCard, + MantineRadius, + MantineSize, +} from "@mantine/core"; +import { BoxProps } from "props/box"; +import { DashBaseProps, PersistenceProps } from "props/dash"; +import { StylesApiProps } from "props/styles"; +import React from "react"; +import { setPersistence, getLoadingState } from "../../../utils/dash3"; + +interface Props + extends BoxProps, + StylesApiProps, + DashBaseProps, + PersistenceProps { + /** State of check box */ + checked?: boolean; + /** Uncontrolled component default value */ + defaultChecked?: boolean; + /** Determines whether the card should have border, `true` by default */ + withBorder?: boolean; + /** Key of `theme.radius` or any valid CSS value to set `border-radius,` `theme.defaultRadius` by default */ + radius?: MantineRadius; + /** To be used with checkbox group */ + value?: string; + /** Props passed down to the root element */ + wrapperProps?: Record; + /** Determines whether CheckboxCard is disabled and non-selectable */ + disabled?: boolean; + /** CheckboxCard content */ + children?: React.ReactNode; +} + +/** CheckboxCard */ +const CheckboxCard = ({ + children, + setProps, + loading_state, + persistence, + persisted_props, + persistence_type, + ...others + }: Props) => { + + return ( + setProps({ checked: state })} + {...others} + > + {children} + + ); +}; + +setPersistence(CheckboxCard, ["checked"]); + +export default CheckboxCard; diff --git a/src/ts/components/core/checkbox/CheckboxIndicator.tsx b/src/ts/components/core/checkbox/CheckboxIndicator.tsx new file mode 100644 index 00000000..709b5a96 --- /dev/null +++ b/src/ts/components/core/checkbox/CheckboxIndicator.tsx @@ -0,0 +1,71 @@ +import { + CheckboxIndicator as MantineCheckboxIndicator, + MantineColor, + MantineRadius, + MantineSize, +} from "@mantine/core"; +import { BoxProps } from "props/box"; +import { DashBaseProps, PersistenceProps } from "props/dash"; +import { StylesApiProps } from "props/styles"; +import React from "react"; +import { setPersistence, getLoadingState, applyDashProps } from "../../../utils/dash3"; + +interface Props + extends BoxProps, + StylesApiProps, + DashBaseProps, + PersistenceProps { + /** Key of `theme.colors` or any valid CSS color to set input background color in checked state, `theme.primaryColor` by default */ + color?: MantineColor; + /** Controls size of the component, `'sm'` by default */ + size?: MantineSize | (string & {}); + /** Key of `theme.radius` or any valid CSS value to set `border-radius,` `theme.defaultRadius` by default */ + radius?: MantineRadius; + /** Props passed down to the root element */ + wrapperProps?: Record; + /** Indeterminate state of the checkbox. If set, `checked` prop is ignored. */ + indeterminate?: boolean; + /** Key of `theme.colors` or any valid CSS color to set icon color, by default value depends on `theme.autoContrast` */ + iconColor?: MantineColor; + /** Determines whether icon color with filled variant should depend on `background-color`. If luminosity of the `color` prop is less than `theme.luminosityThreshold`, then `theme.white` will be used for text color, otherwise `theme.black`. Overrides `theme.autoContrast`. */ + autoContrast?: boolean; + /** State of check box */ + checked?: boolean; + /** Whether component is disabled */ + disabled?: boolean; + /** Icon */ + icon?: React.ReactNode; + /** Indeterminate icon */ + indeterminateIcon?: React.ReactNode; +} + +/** CheckboxIndicator */ +const CheckboxIndicator = ({ + setProps, + loading_state, + persistence, + persisted_props, + persistence_type, + icon, + indeterminateIcon, + ...others + }: Props) => { + + + const iconFunc = ({ indeterminate, ...others }) => { + const selected: any = indeterminate ? indeterminateIcon : icon; + return applyDashProps(selected, others); + }; + + return ( + + ); +}; + +setPersistence(CheckboxIndicator, ["checked"] ) + +export default CheckboxIndicator; diff --git a/src/ts/components/core/radio/Radio.tsx b/src/ts/components/core/radio/Radio.tsx index 07908228..18a7ff9b 100644 --- a/src/ts/components/core/radio/Radio.tsx +++ b/src/ts/components/core/radio/Radio.tsx @@ -50,6 +50,7 @@ const Radio = (props: Props) => { persistence, persisted_props, persistence_type, + value, ...others } = props; @@ -59,7 +60,8 @@ const Radio = (props: Props) => { setProps({ checked: ev.currentTarget.checked })} - onClick={radioOnClick} + onClick={radioOnClick ? () => radioOnClick(value) : null} + value={value} {...others} /> ); diff --git a/src/ts/components/core/radio/RadioCard.tsx b/src/ts/components/core/radio/RadioCard.tsx new file mode 100644 index 00000000..9a1492f3 --- /dev/null +++ b/src/ts/components/core/radio/RadioCard.tsx @@ -0,0 +1,64 @@ +import { + RadioCard as MantineRadioCard, + MantineRadius, +} from "@mantine/core"; +import { BoxProps } from "props/box"; +import { DashBaseProps, PersistenceProps } from "props/dash"; +import { StylesApiProps } from "props/styles"; +import React from "react"; +import { setPersistence, getLoadingState } from "../../../utils/dash3"; +import RadioGroupContext from "./RadioGroupContext"; + +interface Props + extends BoxProps, + StylesApiProps, + DashBaseProps, + PersistenceProps { + /** Checked state */ + checked?: boolean; + /** Determines whether the card should have border, `true` by default */ + withBorder?: boolean; + /** Key of `theme.radius` or any valid CSS value to set `border-radius,` "xl" by default */ + radius?: MantineRadius; + /** To be used with Radio group */ + value?: string; + /** Value used to associate all related radio cards, required for accessibility if used outside of `Radio.Group` */ + name?: string; + /** Props passed down to the root element */ + wrapperProps?: Record; + /** Determines whether RadioCard is disabled and non-selectable */ + disabled?: boolean; + /** RadioCard content */ + children?: React.ReactNode; +} + +/** RadioCard */ +const RadioCard = (props: Props) => { + const { + children, + setProps, + loading_state, + persistence, + persisted_props, + persistence_type, + value, + ...others + } = props; + + const { radioOnClick } = React.useContext(RadioGroupContext) || {}; + + return ( + radioOnClick(value) : null} + value={value} + {...others} + > + {children} + + ); +}; + +setPersistence(RadioCard, ["checked"]); + +export default RadioCard; diff --git a/src/ts/components/core/radio/RadioGroup.tsx b/src/ts/components/core/radio/RadioGroup.tsx index a4c4bdc9..23839152 100644 --- a/src/ts/components/core/radio/RadioGroup.tsx +++ b/src/ts/components/core/radio/RadioGroup.tsx @@ -40,11 +40,11 @@ const RadioGroup = (props: Props) => { setProps({ value }); }; - const handleRadioClick = (event: React.MouseEvent) => { - if (event.currentTarget.value === value) { + const handleRadioClick = (val?: string) => { + if (val === value) { setProps({ value: null }); } - }; + }; return ( ) => void; + radioOnClick?: (val?: string) => void; } const RadioGroupContext = createContext(null); diff --git a/src/ts/components/core/radio/RadioIndicator.tsx b/src/ts/components/core/radio/RadioIndicator.tsx new file mode 100644 index 00000000..6bf81d9b --- /dev/null +++ b/src/ts/components/core/radio/RadioIndicator.tsx @@ -0,0 +1,57 @@ +import { + MantineColor, + RadioIndicator as MantineRadioIndicator, + MantineRadius, + MantineSize, +} from "@mantine/core"; +import { BoxProps } from "props/box"; +import { DashBaseProps, PersistenceProps } from "props/dash"; +import { StylesApiProps } from "props/styles"; +import React from "react"; +import { setPersistence, getLoadingState } from "../../../utils/dash3"; + +interface Props + extends BoxProps, + StylesApiProps, + DashBaseProps, + PersistenceProps { + /** Key of `theme.colors` or any valid CSS color to set input color in checked state, `theme.primaryColor` by default */ + color?: MantineColor; + /** Controls size of the component, `'sm'` by default */ + size?: MantineSize; + /** Props passed down to the root element */ + wrapperProps?: Record; + /** Key of `theme.radius` or any valid CSS value to set `border-radius,` "xl" by default */ + radius?: MantineRadius; + /** Key of `theme.colors` or any valid CSS color to set icon color, by default value depends on `theme.autoContrast` */ + iconColor?: MantineColor; + /** Determines whether icon color with filled variant should depend on `background-color`. If luminosity of the `color` prop is less than `theme.luminosityThreshold`, then `theme.white` will be used for text color, otherwise `theme.black`. Overrides `theme.autoContrast`. */ + autoContrast?: boolean; + /** Determines whether the component should have checked styles */ + checked?: boolean; + /** Determines whether Radio disabled and non-selectable */ + disabled?: boolean; +} + +/** RadioIndicator */ +const RadioIndicator = (props: Props) => { + const { + setProps, + loading_state, + persistence, + persisted_props, + persistence_type, + ...others + } = props; + + return ( + + ); +}; + +setPersistence(RadioIndicator, ['checked']) + +export default RadioIndicator; diff --git a/src/ts/index.ts b/src/ts/index.ts index fe2fcdd2..38fd958a 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -76,6 +76,8 @@ import Card from "./components/core/card/Card"; import CardSection from "./components/core/card/CardSection"; import Checkbox from "./components/core/checkbox/Checkbox"; import CheckboxGroup from "./components/core/checkbox/CheckboxGroup"; +import CheckboxCard from "./components/core/checkbox/CheckboxCard"; +import CheckboxIndicator from "./components/core/checkbox/CheckboxIndicator"; import Chip from "./components/core/chip/Chip"; import ColorInput from "./components/core/color/ColorInput"; import ColorPicker from "./components/core/color/ColorPicker"; @@ -113,6 +115,8 @@ import ProgressRoot from "./components/core/progress/ProgressRoot"; import ProgressSection from "./components/core/progress/ProgressSection"; import Radio from "./components/core/radio/Radio"; import RadioGroup from "./components/core/radio/RadioGroup"; +import RadioIndicator from "./components/core/radio/RadioIndicator"; +import RadioCard from "./components/core/radio/RadioCard"; import RangeSlider from "./components/core/slider/RangeSlider"; import SemiCircleProgress from "./components/core/SemiCircleProgress"; import Slider from "./components/core/slider/Slider"; @@ -194,6 +198,8 @@ export { Center, Checkbox, CheckboxGroup, + CheckboxCard, + CheckboxIndicator, Chip, ChipGroup, Code, @@ -267,6 +273,8 @@ export { RadarChart, Radio, RadioGroup, + RadioIndicator, + RadioCard, RangeSlider, Rating, RingProgress, diff --git a/tests/test_checkbox.py b/tests/test_checkbox.py new file mode 100644 index 00000000..88e05aab --- /dev/null +++ b/tests/test_checkbox.py @@ -0,0 +1,56 @@ +from dash import Dash, html, Output, Input, _dash_renderer +import dash_mantine_components as dmc + +_dash_renderer._set_react_version("18.2.0") + + +def checkboxgroup_app(**kwargs): + app = Dash(__name__) + + app.layout = dmc.MantineProvider( + html.Div( + [ + dmc.CheckboxGroup( + id="checkbox-group", + children=dmc.Group( + [ + dmc.Checkbox(value="option1", label="Option 1", id="o1"), + dmc.Checkbox(value="option2", label="Option 2", disabled=True, id="o2"), + dmc.Checkbox(value="option3", label="Option 3", id="o3"), + ] + ), + **kwargs, + ), + html.Div(id="output"), + ] + ) + ) + + @app.callback(Output("output", "children"), Input("checkbox-group", "value")) + def update_output(selected_values): + return f"Selected: {selected_values}" + + return app + + +def test_001chb_checkbox_group(dash_duo): + + app = checkboxgroup_app() + dash_duo.start_server(app) + + # Wait for the app to load + dash_duo.wait_for_element("div[aria-labelledby='checkbox-group-label']") + + option1 = dash_duo.find_element("#o1") + option1.click() + dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1']") + + option2 = dash_duo.find_element("#o2") + option2.click() # Not clickable, check is done below + + option3 = dash_duo.find_element("#o3") + option3.click() + dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1', 'option3']") + + assert dash_duo.get_logs() == [] + diff --git a/tests/test_checkboxcard.py b/tests/test_checkboxcard.py new file mode 100644 index 00000000..daeb703b --- /dev/null +++ b/tests/test_checkboxcard.py @@ -0,0 +1,56 @@ +from dash import Dash, html, Output, Input, _dash_renderer +import dash_mantine_components as dmc + +_dash_renderer._set_react_version("18.2.0") + + +def checkboxgroup_app(**kwargs): + app = Dash(__name__) + + app.layout = dmc.MantineProvider( + html.Div( + [ + dmc.CheckboxGroup( + id="checkbox-group", + children=dmc.Group( + [ + dmc.CheckboxCard(value="option1", children=dmc.CheckboxIndicator(), id="o1"), + dmc.CheckboxCard(value="option2", disabled=True, id="o2"), + dmc.CheckboxCard(value="option3", children=dmc.Text("Text"), id="o3"), + ] + ), + **kwargs, + ), + html.Div(id="output"), + ] + ) + ) + + @app.callback(Output("output", "children"), Input("checkbox-group", "value")) + def update_output(selected_values): + return f"Selected: {selected_values}" + + return app + + +def test_001chc_checkbox_group(dash_duo): + + app = checkboxgroup_app() + dash_duo.start_server(app) + + # Wait for the app to load + dash_duo.wait_for_element("div[aria-labelledby='checkbox-group-label']") + + option1 = dash_duo.find_element("#o1") + option1.click() + dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1']") + + option2 = dash_duo.find_element("#o2") + option2.click() # Not clickable, check is done below + + option3 = dash_duo.find_element("#o3") + option3.click() + dash_duo.wait_for_text_to_equal("#output", "Selected: ['option1', 'option3']") + + assert dash_duo.get_logs() == [] + diff --git a/tests/test_radiocard.py b/tests/test_radiocard.py new file mode 100644 index 00000000..06d517c2 --- /dev/null +++ b/tests/test_radiocard.py @@ -0,0 +1,74 @@ +import time + +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.RadioCard(value="option1", children=dmc.RadioIndicator(), id="o1"), + dmc.RadioCard(value="option2", id="o2"), + dmc.RadioCard(value="option3", children=dmc.Text("Text"), id="o3"), + ] + ), + **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_001rc_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("#o1") + option2 = dash_duo.find_element("#o2") + 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() + time.sleep(0.5) + dash_duo.wait_for_text_to_equal("#output", "Selected: option2") + + assert dash_duo.get_logs() == [] + + +def test_002rc_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("#o1") + option1.click() + dash_duo.wait_for_text_to_equal("#output", "Selected: option1") + option1.click() + dash_duo.wait_for_text_to_equal("#output", "Selected: None") + + assert dash_duo.get_logs() == []