diff --git a/chartlets.js/CHANGES.md b/chartlets.js/CHANGES.md index 4318f0e1..7e5fd11f 100644 --- a/chartlets.js/CHANGES.md +++ b/chartlets.js/CHANGES.md @@ -42,6 +42,7 @@ - `RadioGroup` and `Radio` - `Switch` - `Tabs` + - `Slider` * Supporting `tooltip` property for interactive MUI components. diff --git a/chartlets.js/packages/lib/src/plugins/mui/Slider.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Slider.test.tsx new file mode 100644 index 00000000..f3960a47 --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Slider.test.tsx @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Slider } from "./Slider"; +import "@testing-library/jest-dom"; +import { createChangeHandler } from "@/plugins/mui/common.test"; +import { useState } from "react"; +import type { ComponentChangeEvent } from "@/types/state/event"; + +describe("Slider", () => { + it("should render the Slider component", () => { + render( + {}} + />, + ); + + const slider = screen.getByRole("slider"); + expect(slider).toBeDefined(); + + expect(slider.getAttribute("aria-orientation")).toEqual("horizontal"); + expect(slider.getAttribute("min")).toEqual("0"); + expect(slider.getAttribute("max")).toEqual("100"); + expect(slider.getAttribute("value")).toEqual("50"); + }); + + it("should render the Slider component", () => { + render( + {}} + />, + ); + + const slider = screen.getByRole("slider"); + expect(slider).toBeDefined(); + + expect(slider.getAttribute("aria-orientation")).toEqual("horizontal"); + expect(slider.getAttribute("min")).toEqual("0"); + expect(slider.getAttribute("max")).toEqual("100"); + expect(slider.getAttribute("value")).toEqual("50"); + }); + + it("should fire 'value' property", () => { + const { recordedEvents, onChange } = createChangeHandler(); + + const TestSlider = () => { + const [sliderValue, setSliderValue] = useState(60); + + const handleChange = (event: ComponentChangeEvent) => { + setSliderValue(event.value as number); + onChange(event); + }; + + return ( + + ); + }; + + render(); + const slider = screen.getByTestId("sliderId"); + expect(slider).toBeInTheDocument(); + expect(screen.getByRole("slider")).toHaveValue("60"); + + const input = document.querySelector("input")?.value; + expect(input).toEqual("60"); + + const sliderBounds = { + left: 100, + width: 200, + top: 0, + bottom: 0, + height: 20, + }; + vi.spyOn(slider, "getBoundingClientRect").mockReturnValue( + sliderBounds as DOMRect, + ); + + // The value selected should be 100 + const clientX = sliderBounds.left + sliderBounds.width * 0.1; + + fireEvent.mouseDown(slider, { clientX: clientX }); + fireEvent.mouseMove(slider, { clientX: clientX }); + fireEvent.mouseUp(slider); + expect(recordedEvents.length).toEqual(1); + + expect(recordedEvents[0]).toEqual({ + componentType: "Slider", + id: "sliderId", + property: "value", + value: 100, + }); + + expect(screen.getByRole("slider")).toHaveValue("100"); + const updated_input = document.querySelector("input"); + expect(updated_input?.value).toEqual("100"); + }); +}); diff --git a/chartlets.js/packages/lib/src/plugins/mui/Slider.tsx b/chartlets.js/packages/lib/src/plugins/mui/Slider.tsx new file mode 100644 index 00000000..b85d447d --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Slider.tsx @@ -0,0 +1,92 @@ +import MuiSlider from "@mui/material/Slider"; +import type { OverridableStringUnion } from "@mui/types"; + +import type { ComponentProps, ComponentState } from "@/index"; +import type { ReactNode } from "react"; + +interface SliderState extends ComponentState { + defaultValue?: number; + ariaLabel?: string; + color?: OverridableStringUnion< + "primary" | "secondary" | "success" | "error" | "info" | "warning", + string + >; + disableSwap?: boolean; + getAriaValueText?: (value: number, index: number) => string; + min?: number; + max?: number; + marks?: boolean | { value: number; label?: ReactNode }[]; + orientation?: "horizontal" | "vertical"; + step?: number; + size?: OverridableStringUnion<"small" | "medium", string>; + track?: "inverted" | "normal" | false; + value?: number | number[]; + valueLabelDisplay?: "auto" | "on" | "off"; + ["data-testid"]?: string; +} + +interface SliderProps extends ComponentProps, SliderState {} + +export const Slider = ({ + type, + id, + style, + defaultValue, + ariaLabel, + color, + disableSwap, + getAriaValueText, + min, + max, + marks, + orientation, + step, + size, + track, + value, + valueLabelDisplay, + onChange, + ...props +}: SliderProps) => { + // We need to drop children prop because we want to access the data-testid for + // tests and slider does not accept children components + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children: _, ...sliderProps } = props; + + const handleSlide = ( + _event: Event, + value: number | number[], + _activeThumb: number, + ) => { + if (id) { + onChange({ + componentType: type, + id: id, + property: "value", + value: value, + }); + } + }; + return ( + + ); +}; diff --git a/chartlets.js/packages/lib/src/plugins/mui/index.ts b/chartlets.js/packages/lib/src/plugins/mui/index.ts index 3b76cff9..a72f6c64 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/index.ts +++ b/chartlets.js/packages/lib/src/plugins/mui/index.ts @@ -9,6 +9,7 @@ import { Select } from "./Select"; import { Switch } from "./Switch"; import { Tabs } from "./Tabs"; import { Typography } from "./Typography"; +import { Slider } from "./Slider"; export default function mui(): Plugin { return { @@ -20,6 +21,7 @@ export default function mui(): Plugin { ["IconButton", IconButton], ["RadioGroup", RadioGroup], ["Select", Select], + ["Slider", Slider], ["Switch", Switch], ["Tabs", Tabs], ["Typography", Typography], diff --git a/chartlets.py/CHANGES.md b/chartlets.py/CHANGES.md index ba05df62..6dd0fc46 100644 --- a/chartlets.py/CHANGES.md +++ b/chartlets.py/CHANGES.md @@ -22,6 +22,7 @@ - `Switch` - `RadioGroup` and `Radio` - `Tabs` + - `Slider` ## Version 0.0.29 (from 2024/11/26) diff --git a/chartlets.py/chartlets/components/__init__.py b/chartlets.py/chartlets/components/__init__.py index 8b1c0ada..615d95c0 100644 --- a/chartlets.py/chartlets/components/__init__.py +++ b/chartlets.py/chartlets/components/__init__.py @@ -2,14 +2,15 @@ from .button import Button from .button import IconButton from .checkbox import Checkbox +from .charts.vega import VegaChart from .progress import CircularProgress from .progress import CircularProgressWithLabel from .progress import LinearProgress from .progress import LinearProgressWithLabel -from .charts.vega import VegaChart from .radiogroup import Radio from .radiogroup import RadioGroup from .select import Select +from .slider import Slider from .switch import Switch from .tabs import Tab from .tabs import Tabs diff --git a/chartlets.py/chartlets/components/slider.py b/chartlets.py/chartlets/components/slider.py new file mode 100644 index 00000000..6f3ca4e7 --- /dev/null +++ b/chartlets.py/chartlets/components/slider.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass +from typing import Literal, TypedDict, Callable + +from chartlets import Component + + +@dataclass(frozen=True) +class Slider(Component): + """Sliders allow users to make selections from a range of values along a + bar.""" + + aria_label: str | None = None + """The label of the slider.""" + + color: str | None = None + """The color of the component. It supports both default and custom theme + colors + """ + + defaultValue: list[int] | int | None = None + """The default value. Use when the component is not controlled. If used + as an array, it will create multiple sliding points on the bar + """ + + disableSwap: bool | None = None + """If true, the active thumb doesn't swap when moving pointer over a thumb + while dragging another thumb. + """ + + getAriaValueText: Callable[[int, int], str] | None = None + """Accepts a function which returns a string value that provides a + user-friendly name for the current value of the slider. This is important + for screen reader users. + + Signature: + function(value: number, index: number) => string + + value: The thumb label's value to format. + index: The thumb label's index to format. + """ + + min: int | None = None + """The minimum allowed value of the slider. Should not be equal to max.""" + + max: int | None = None + """The maximum allowed value of the slider. Should not be equal to min.""" + + marks: (bool | + list[TypedDict("marks", {"value": int, "label": str})] | + None) = None + """Marks indicate predetermined values to which the user can move the + slider. If true the marks are spaced according the value of the step + prop. If an array, it should contain objects with value and an optional + label keys. + """ + + orientation: Literal["horizontal", "vertical"] | None = None + """The component orientation.""" + + step: int | None = None + """The granularity with which the slider can step through values. (A + "discrete" slider.) The min prop serves as the origin for the valid values. + We recommend (max - min) to be evenly divisible by the step. When step is + null, the thumb can only be slid onto marks provided with the marks prop. + """ + + size: str | None = None + """The size of the slider.""" + + tooltip: str | None = None + """Tooltip title. Optional.""" + + track: Literal["inverted", "normal"] | False | None = None + """The track presentation: + + `normal`: the track will render a bar representing the slider value. + `inverted`: the track will render a bar representing the remaining slider + value. + `false`: the track will render without a bar. + """ + + value: list[int] | int | None = None + """The value of the slider. For ranged sliders, provide an array with two + values. + """ + + valueLabelDisplay: Literal['auto', 'on', 'off'] | None = None + """Controls when the value label is displayed: + + `auto` the value label will display when the thumb is hovered or focused. + `on` will display persistently. + `off` will never display. + """ diff --git a/chartlets.py/demo/my_extension/__init__.py b/chartlets.py/demo/my_extension/__init__.py index 032838da..0f969eeb 100644 --- a/chartlets.py/demo/my_extension/__init__.py +++ b/chartlets.py/demo/my_extension/__init__.py @@ -2,8 +2,10 @@ from .my_panel_1 import panel as my_panel_1 from .my_panel_2 import panel as my_panel_2 from .my_panel_3 import panel as my_panel_3 +from .my_panel_4 import panel as my_panel_4 ext = Extension(__name__) ext.add(my_panel_1) ext.add(my_panel_2) ext.add(my_panel_3) +ext.add(my_panel_4) diff --git a/chartlets.py/demo/my_extension/my_panel_4.py b/chartlets.py/demo/my_extension/my_panel_4.py new file mode 100644 index 00000000..c087f04e --- /dev/null +++ b/chartlets.py/demo/my_extension/my_panel_4.py @@ -0,0 +1,58 @@ +from chartlets import Component, Input, State, Output +from chartlets.components import Box, Slider, Typography + +from server.context import Context +from server.panel import Panel + + +panel = Panel(__name__, title="Panel D") + + +@panel.layout() +def render_panel( + ctx: Context, +) -> Component: + marks = [ + { + "value": 0, + "label": "0", + }, + { + "value": 20, + "label": "20", + }, + { + "value": 37, + "label": "37", + }, + { + "value": 100, + "label": "100", + }, + ] + slider = Slider( + id="slider", min=0, max=100, step=5, marks=marks, valueLabelDisplay="auto" + ) + + info_text = Typography(id="info_text", children=["Move the slider."]) + + return Box( + style={ + "display": "flex", + "flexDirection": "column", + "width": "100%", + "height": "100%", + "gap": "6px", + }, + children=[slider, info_text], + ) + + +# noinspection PyUnusedLocal +@panel.callback(Input("slider"), Output("info_text", "children")) +def update_info_text( + ctx: Context, + slider: int, +) -> list[str]: + slider = slider or 0 + return [f"The value is {slider}."] diff --git a/chartlets.py/tests/components/slider_test.py b/chartlets.py/tests/components/slider_test.py new file mode 100644 index 00000000..096a92a4 --- /dev/null +++ b/chartlets.py/tests/components/slider_test.py @@ -0,0 +1,29 @@ +from chartlets.components import Slider +from tests.component_test import make_base + + +class SliderTest(make_base(Slider)): + def test_is_json_serializable(self): + self.assert_is_json_serializable( + self.cls( + aria_label="Temperature", + color="primary", + min=0, + max=50, + step=5, + marks=[5, 15, 50], + tooltip="Choose a temperature", + valueLabelDisplay="on", + ), + { + "type": "Slider", + "aria_label": "Temperature", + "color": "primary", + "min": 0, + "max": 50, + "step": 5, + "marks": [5, 15, 50], + "tooltip": "Choose a temperature", + "valueLabelDisplay": "on", + }, + )