diff --git a/apps/website/pages/components/slider/code.tsx b/apps/website/pages/components/slider/code.tsx new file mode 100644 index 0000000000..8f37e01dbe --- /dev/null +++ b/apps/website/pages/components/slider/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import SliderPageLayout from "screens/components/slider/SliderPageLayout"; +import SliderCodePage from "screens/components/slider/code/SliderCodePage"; + +const Code = () => ( + <> + + Slider code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/slider/index.tsx b/apps/website/pages/components/slider/index.tsx index c09eca28bd..c62aaf82a6 100644 --- a/apps/website/pages/components/slider/index.tsx +++ b/apps/website/pages/components/slider/index.tsx @@ -1,21 +1,17 @@ import Head from "next/head"; import type { ReactElement } from "react"; import SliderPageLayout from "screens/components/slider/SliderPageLayout"; -import SliderCodePage from "screens/components/slider/code/SliderCodePage"; +import SliderOverviewPage from "screens/components/slider/overview/SliderOverviewPage"; -const Index = () => { - return ( - <> - - Slider — Halstack Design System - - - - ); -}; +const Index = () => ( + <> + + Slider — Halstack Design System + + + +); -Index.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; +Index.getLayout = (page: ReactElement) => {page}; export default Index; diff --git a/apps/website/pages/components/slider/specifications.tsx b/apps/website/pages/components/slider/specifications.tsx deleted file mode 100644 index 5dec61c532..0000000000 --- a/apps/website/pages/components/slider/specifications.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SliderPageLayout from "screens/components/slider/SliderPageLayout"; -import SliderSpecsPage from "screens/components/slider/specs/SliderSpecsPage"; - -const Specifications = () => { - return ( - <> - - Slider Specs — Halstack Design System - - - - ); -}; - -Specifications.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Specifications; diff --git a/apps/website/pages/components/slider/usage.tsx b/apps/website/pages/components/slider/usage.tsx deleted file mode 100644 index 4e62329393..0000000000 --- a/apps/website/pages/components/slider/usage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SliderPageLayout from "screens/components/slider/SliderPageLayout"; -import SliderUsagePage from "screens/components/slider/usage/SliderUsagePage"; - -const Usage = () => { - return ( - <> - - Slider Usage — Halstack Design System - - - - ); -}; - -Usage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Usage; diff --git a/apps/website/screens/components/contextual-menu/overview/ContextualMenuOverviewPage.tsx b/apps/website/screens/components/contextual-menu/overview/ContextualMenuOverviewPage.tsx index b2a4d6dc58..106f757706 100644 --- a/apps/website/screens/components/contextual-menu/overview/ContextualMenuOverviewPage.tsx +++ b/apps/website/screens/components/contextual-menu/overview/ContextualMenuOverviewPage.tsx @@ -89,42 +89,40 @@ const sections = [ { title: "Best practices", content: ( - <> - - - Use meaningful icons: Select icons that accurately represent menu items, ensuring clarity - and intuitive navigation. - - - Align properly: position the contextual menu to the left or right, avoiding placement in - the center to prevent obstruction of main content. - - - Enhance navigation with hierarchy: structure menu items using different levels to maintain - logical organization. - - - Use badges for status indication: incorporate a Badge component to display status updates, - counts or categories for navigable sections. - - - Default selection: when pre-selecting an option, try to limit it to the first menu item to - maintain intuitive user interactions. - - - Avoid deep hierarchies: limit navigation depth to a maximum of three levels to prevent - excessive indentation and complexity. - - - Restrict icon usage: use icons only at the first navigation level to maintain readability - and avoid visual clutter. - - - Don't overload with icons: too many icons can create confusion rather than improve - usability. Keep them purposeful and minimal. - - - + + + Use meaningful icons: Select icons that accurately represent menu items, ensuring clarity and + intuitive navigation. + + + Align properly: position the contextual menu to the left or right, avoiding placement in the + center to prevent obstruction of main content. + + + Enhance navigation with hierarchy: structure menu items using different levels to maintain + logical organization. + + + Use badges for status indication: incorporate a Badge component to display status updates, + counts or categories for navigable sections. + + + Default selection: when pre-selecting an option, try to limit it to the first menu item to + maintain intuitive user interactions. + + + Avoid deep hierarchies: limit navigation depth to a maximum of three levels to prevent + excessive indentation and complexity. + + + Restrict icon usage: use icons only at the first navigation level to maintain readability and + avoid visual clutter. + + + Don't overload with icons: too many icons can create confusion rather than improve usability. + Keep them purposeful and minimal. + + ), }, ]; diff --git a/apps/website/screens/components/slider/SliderPageLayout.tsx b/apps/website/screens/components/slider/SliderPageLayout.tsx index fe27cda196..98d94da228 100644 --- a/apps/website/screens/components/slider/SliderPageLayout.tsx +++ b/apps/website/screens/components/slider/SliderPageLayout.tsx @@ -6,9 +6,8 @@ import { ReactNode } from "react"; const SliderPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Code", path: "/components/slider" }, - { label: "Usage", path: "/components/slider/usage" }, - { label: "Specifications", path: "/components/slider/specifications" }, + { label: "Overview", path: "/components/slider" }, + { label: "Code", path: "/components/slider/code" }, ]; return ( @@ -17,11 +16,10 @@ const SliderPageHeading = ({ children }: { children: ReactNode }) => { - Slider control allows users to select a specific value or a range of values from a set. Usually, slider - presents a relatively large dataset and the way that the user interacts with it is helpful to explore the - multiple options swiftly. + Slider control enables users to select a specific value from a predefined set by dragging a thumb along a + track. - + {children} diff --git a/apps/website/screens/components/slider/code/SliderCodePage.tsx b/apps/website/screens/components/slider/code/SliderCodePage.tsx index c063cb414a..38eb04fbe3 100644 --- a/apps/website/screens/components/slider/code/SliderCodePage.tsx +++ b/apps/website/screens/components/slider/code/SliderCodePage.tsx @@ -8,6 +8,7 @@ import controlled from "./examples/controlled"; import uncontrolled from "./examples/uncontrolled"; import formatLabel from "./examples/formatLabel"; import TableCode from "@/common/TableCode"; +import complex from "./examples/complex"; const sections = [ { @@ -24,39 +25,36 @@ const sections = [ - defaultValue + ariaLabel - number + string + + + Specifies a string to be used as the name for the slider element when no label is provided. + + + 'Slider' - Initial value of the slider, only when it is uncontrolled. - - - value + defaultValue number + Initial value of the slider, only when it is uncontrolled. - The selected value. If undefined, the component will be uncontrolled and the value will be managed - internally by the component. + 0 - - - label + disabled - string + boolean - Text to be placed above the slider. - - - - - name + If true, the component will be disabled. - string + false - Name attribute of the input element. - - helperText @@ -67,76 +65,71 @@ const sections = [ - - minValue - - number - - The minimum value available for selection. + label - 0 + string + Text to be placed above the slider. + - - maxValue + labelFormatCallback - number + {"(value: number) => string"} - The maximum value available for selection. - 100 + This function will be used to format the labels displayed next to the slider. The value will be passed as + parameter and the function must return the formatted value. + - - step - - number - - The step interval between values available for selection. + name - 1 + string + Name attribute of the input element. + - - showLimitsValues + margin - boolean + 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin - Whether the min/max value labels should be displayed next to the slider. - false + Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and + 'right' properties in order to specify different margin sizes. + - - showInput + marks boolean - - Whether the input element for displaying/controlling the slider value should be displayed next to the - slider. - + Whether the marks between each step should be shown or not. false - disabled + maxValue - boolean + number - If true, the component will be disabled. + The maximum value available for selection. - false + 100 - marks + minValue - boolean + number - Whether the marks between each step should be shown or not. + The minimum value available for selection. - false + 0 @@ -162,26 +155,35 @@ const sections = [ - - labelFormatCallback + ref - {"(value: number) => string"} + {"React.Ref"} + + Reference to the component. + - + + + showInput + + boolean - This function will be used to format the labels displayed next to the slider. The value will be passed as - parameter and the function must return the formatted value. + Whether the input element for displaying/controlling the slider value should be displayed next to the + slider. + + + false - - - margin + showLimitsValues - 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + boolean + Whether the min/max value labels should be displayed next to the slider. - Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and - 'right' properties in order to specify different margin sizes. + false - - size @@ -194,34 +196,25 @@ const sections = [ - tabIndex + step number + The step interval between values available for selection. - Value of the tabindex attribute. - - - 0 - - - - ref - - {"React.Ref"} + 1 - Reference to the component. - - - ariaLabel + value - string + number - Specifies a string to be used as the name for the slider element when no label is provided. + The selected value. If undefined, the component will be uncontrolled and the value will be managed + internally by the component. - 'Slider' + - @@ -242,19 +235,21 @@ const sections = [ title: "Format label", content: , }, + { + title: "Decimals and negatives", + content: , + }, ], }, ]; -const SliderCodePage = () => { - return ( - - - - - - - ); -}; +const SliderCodePage = () => ( + + + + + + +); export default SliderCodePage; diff --git a/apps/website/screens/components/slider/code/examples/complex.ts b/apps/website/screens/components/slider/code/examples/complex.ts new file mode 100644 index 0000000000..6a69e40d00 --- /dev/null +++ b/apps/website/screens/components/slider/code/examples/complex.ts @@ -0,0 +1,35 @@ +import { DxcSlider, DxcInset } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const [value, changeValue] = useState(0); + const onChange = (newValue) => { + changeValue(newValue); + }; + + return ( + + + Current value: {value} + + ); +}`; + +const scope = { + DxcSlider, + DxcInset, + useState, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/slider/overview/SliderOverviewPage.tsx b/apps/website/screens/components/slider/overview/SliderOverviewPage.tsx new file mode 100644 index 0000000000..629719ed13 --- /dev/null +++ b/apps/website/screens/components/slider/overview/SliderOverviewPage.tsx @@ -0,0 +1,232 @@ +import { DxcBulletedList, DxcFlex, DxcParagraph, DxcInset } from "@dxc-technology/halstack-react"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; +import DocFooter from "@/common/DocFooter"; +import Example from "@/common/example/Example"; +import continuous from "./examples/continuous"; +import discrete from "./examples/discrete"; +import input from "./examples/input"; +import Image from "@/common/Image"; +import anatomy from "./images/slider_anatomy.png"; + +const sections = [ + { + title: "Introduction", + content: ( + + The slider component enhance user experience by providing a quick and intuitive way to adjust + settings. It is particularly useful for adjusting parameters within a continuous or discrete scale, making it + easier to explore multiple options efficiently + + ), + }, + { + title: "Anatomy", + content: ( + <> + Slider's anatomy + + + Label: describes the purpose of the slider (e.g., "Select coverage amount"). + + + Helper text (Optional): provides additional guidance or context for the user. + + + Minimum value: the lowest selectable value (e.g., 0). + + + Track (bar): the line along which the thumb moves, visually representing the range. + + + Maximum value: the highest selectable value (e.g., 100). + + + Input field (Optional): displays the selected value, allowing manual input. + + + Tick marks (Optional): small indicators on the track that represent key + increments. + + + Thumb: the draggable element that allows users to adjust the value. + + + Selected value indicator: highlights the current value along the track. + + + + ), + }, + { + title: "Key interactions and features", + content: ( + <> + + Sliders provide an intuitive way for users to adjust values dynamically by interacting with a + draggable thumb. Depending on the implementation, sliders can offer various interaction methods and features + to enhance usability. + + + + Dragging the thumb + + + + Users can click and drag the thumb along the track to adjust the value. + + + In discrete sliders, the thumb snaps to predefined increments. + + + In continuous sliders, the thumb moves smoothly without fixed steps. + + + + + + Clicking the track + + + + Users can click anywhere on the track to move the thumb directly to that position. + + + In some implementations, clicking moves the thumb instantly, while in others, it may{" "} + animate toward the new position. + + + + + + Keyboard support + + + + Users can adjust the slider using the arrow keys for precise control: + + + + Left / Down arrow: decrease value. + + + Right / Up arrow: increase value. + + + + + + + + + + ), + }, + { + title: "Variants", + content: ( + <> + + Sliders come in two variants: discrete and continuous. Choosing the right + variant depends on whether precise steps or smooth adjustments are needed. + + + ), + subSections: [ + { + title: "Discrete slider", + content: ( + <> + + Allows users to select only predefined values along the track. + + + Each step is marked, and the thumb "snaps" to these values. + + Best for limited, meaningful choices where precision matters. + + + + + ), + }, + { + title: "Continuous slider", + content: ( + <> + + Lets users select any value within the range, without fixed steps. + + + Offers smooth, fine-grained control over the selection. + + Best for gradual adjustments where any value is valid. + + + + + ), + }, + ], + }, + { + title: "Best practices", + subSections: [ + { + title: "Provide a clear label and context", + content: ( + + + Use a descriptive label that explains what the slider controls (i.e., instead of "Adjust + value", use "Select your coverage amount."). + + + Add helper text if additional guidance is needed. + + + ), + }, + { + title: "Set logical minimum and maximum values", + content: ( + + + Ensure the range matches real-world expectations (e.g., an insurance deductible slider + should not start at $0 if the lowest option is $250). + + + Keep increment steps meaningful (e.g., increments of $10 instead of $1 for large ranges). + + + ), + }, + { + title: "Allow manual input when precise values are needed", + content: ( + <> + + + Some users prefer entering a value directly instead of using the slider. + + + Providing the input field next to the slider helps with this. + + + + + ), + }, + ], + }, +]; + +const SliderOverviewPage = () => ( + + + + + + +); + +export default SliderOverviewPage; diff --git a/apps/website/screens/components/slider/overview/examples/continuous.ts b/apps/website/screens/components/slider/overview/examples/continuous.ts new file mode 100644 index 0000000000..e60f8f6baf --- /dev/null +++ b/apps/website/screens/components/slider/overview/examples/continuous.ts @@ -0,0 +1,34 @@ +import { DxcSlider, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const [value, changeValue] = useState(45); + const onChange = (newValue) => { + changeValue(newValue); + }; + + return ( + + + + Current value: {value} + + + ); +}`; + +const scope = { + DxcSlider, + DxcInset, + DxcFlex, + useState, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/slider/overview/examples/discrete.ts b/apps/website/screens/components/slider/overview/examples/discrete.ts new file mode 100644 index 0000000000..773290e280 --- /dev/null +++ b/apps/website/screens/components/slider/overview/examples/discrete.ts @@ -0,0 +1,34 @@ +import { DxcSlider, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const [value, changeValue] = useState(40); + const onChange = (newValue) => { + changeValue(newValue); + }; + + return ( + + + Current value: {value} + + ); +}`; + +const scope = { + DxcSlider, + DxcInset, + DxcFlex, + useState, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/slider/usage/examples/input.ts b/apps/website/screens/components/slider/overview/examples/input.ts similarity index 100% rename from apps/website/screens/components/slider/usage/examples/input.ts rename to apps/website/screens/components/slider/overview/examples/input.ts diff --git a/apps/website/screens/components/slider/overview/images/slider_anatomy.png b/apps/website/screens/components/slider/overview/images/slider_anatomy.png new file mode 100644 index 0000000000..20c0f108d6 Binary files /dev/null and b/apps/website/screens/components/slider/overview/images/slider_anatomy.png differ diff --git a/apps/website/screens/components/slider/specs/SliderSpecsPage.tsx b/apps/website/screens/components/slider/specs/SliderSpecsPage.tsx deleted file mode 100644 index 7373da6b41..0000000000 --- a/apps/website/screens/components/slider/specs/SliderSpecsPage.tsx +++ /dev/null @@ -1,773 +0,0 @@ -import { DxcLink, DxcBulletedList, DxcFlex, DxcTable, DxcParagraph } from "@dxc-technology/halstack-react"; -import DocFooter from "@/common/DocFooter"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Figure from "@/common/Figure"; -import Image from "@/common/Image"; -import Code from "@/common/Code"; -import sliderAnatomy from "./images/slider_anatomy.png"; -import sliderSpecs from "./images/slider_specs.png"; -import sliderStates from "./images/slider_states.png"; - -const sections = [ - { - title: "Specifications", - content: ( -
- Slider design specifications -
- ), - }, - { - title: "States", - content: ( - <> - - The slider component has the following states: enabled, hover,{" "} - focus, active and disabled. - -
- Slider states -
- - ), - }, - - { - title: "Anatomy", - content: ( - <> - Slider anatomy - - Label - Helper text - - Floor label (Optional) - - Total line - - Ceil label (Optional) - - - Value input (Optional) - - Tick - Thumb - Track line - - - ), - }, - { - title: "Design tokens", - subSections: [ - { - title: "Color", - content: ( - - - - Component token - Element - Core token - Value - - - - - - labelFontColor - - Label - - color-black - - #000000 - - - - disabledLabelFontColor - - Label:disabled - - color-grey-500 - - #999999 - - - - helperTextFontColor - - Helper text - - color-black - - #000000 - - - - disabledhelperTextFontColor - - Helper text:disabled - - color-grey-500 - - #999999 - - - - thumbBackgroundColor - - Thumb - - color-blue-800 - - #0067b3 - - - - hoverThumbBackgroundColor - - Thumb:hover - - color-blue-900 - - #003c66 - - - - focusThumbBackgroundColor - - Thumb:focus - - color-blue-600 - - #0095ff - - - - activeThumbBackgroundColor - - Thumb:active - - color-blue-900 - - #003c66 - - - - disabledThumbBackgroundColor - - Thumb:disabled - - color-grey-500 - - #999999 - - - - tickBackgroundColor - - Tick - - color-blue-800 - - #0067b3 - - - - disabledTickBackgroundColor - - Tick:disabled - - color-grey-500 - - #999999 - - - - trackLineColor - - Track line - - color-blue-800 - - #0067b3 - - - - disabledTrackLineColor - - Track line:disabled - - color-blue-500 - - #999999 - - - - totalLineColor - - Total line - - color-grey-200 - - #e6e6e6 - - - - disabledTotalLineColor - - Total line:disabled - - color-grey-100 - - #f2f2f2 - - - - limitValuesFontColor - - Ceil/Floor label - - color-black - - #000000 - - - - disabledLimitValuesFontColor - - Ceil/Floor label - - color-grey-500 - - #999999 - - - - focusColor - - Focus indicator - - color-blue-600 - - #0095ff - - - - ), - }, - { - title: "Typography", - content: ( - - - - Component token - Element - Core token - Value - - - - - - fontFamily - - Label - - font-family - - 'Open Sans', sans-serif - - - - fontSize - - Label - - font-scale-02 - - 0.875rem / 14px - - - - fontStyle - - Label - - font-style-normal - - normal - - - - fontWeight - - Label - - font-weight-semibold - - 600 - - - - lineHeight - - Label - - font-leading-loose-01 - - 1.715em - - - - fontFamily - - Helper text - - font-family - - 'Open Sans', sans-serif - - - - fontSize - - Helper text - - font-scale-01 - - 0.75rem / 12px - - - - fontStyle - - Helper text - - font-style-normal - - normal - - - - fontWeight - - Helper text - - font-weight-regular - - 400 - - - - lineHeight - - Helper text - - font-leading-normal - - 1.5em - - - - limitValuesFontFamily - - Ceil/Floor label - - font-family - - 'Open Sans', sans-serif - - - - limitValuesFontSize - - Ceil/Floor label - - font-scale-03 - - 1rem / 16px - - - - limitValuesFontStyle - - Ceil/Floor label - - font-style-normal - - normal - - - - limitValuesFontWeight - - Ceil/Floor label - - font-weight-regular - - 400 - - - - fontFamily - - Floor/Ceil label - - font-family-sans - - 'Open Sans', sans-serif - - - - fontSize - - Floor/Ceil label - - font-scale-03 - - 1rem / 16px - - - - fontWeight - - Floor/Ceil label - - font-weight-regular - - 400 - - - - fontStyle - - Floor/Ceil label - - font-style-normal - - normal - - - - ), - }, - { - title: "Spacing", - content: ( - - - - Property - Element - Core token - Value - - - - - - margin-left - - Floor label - - spacing-16 - - 1rem / 16px - - - - margin-right - - Ceil label - - spacing-16 - - 1rem / 16px - - - - margin-left - - Input - - spacing-32 - - 2rem / 32px - - - - ), - }, - { - title: "Size", - content: ( - <> - - - - Property - Element - Core token - Value - - - - - - height - - Total line - - - 2px - - - - height - - Track line - - - 2px - - - - height - - Thumb - - - 12px - - - - width - - Thumb - - - 12px - - - - height - - Thumb:hover* - - - 14px - - - - width - - Thumb:hover - - - 14px - - - - height - - Tick - - - 4px - - - - width - - Tick - - - 4px - - - - - [*] The thumb element size is 14x14px in the following states: :hover and{" "} - :active. - - - ), - }, - { - title: "Border", - content: ( - - - - Property - Element - Core token - Value - - - - - - border-width - - Track line - - border-width-0 - - 0rem / 0px - - - - border-style - - Track line - - border-style-none - - none - - - - border-radius - - Track line - - border-radius-full - - 9999px - - - - border-width - - Thumb - - border-width-0 - - 0rem / 0px - - - - border-style - - Thumb - - border-style-none - - none - - - - border-radius - - Thumb - - border-radius-full - - 9999px - - - - outline - - Focus indicator - - - auto 1px - - - - outline-offset - - Focus indicator - - - 2px - - - - ), - }, - ], - }, - { - title: "Accessibility", - subSections: [ - { - title: "WCAG", - content: ( - - - Understanding WCAG 2.2 -{" "} - - SC 1.3.1 Info and Relationships - - - - Understanding WCAG 2.2 -{" "} - - SC 1.3.2 Meaningful Sequence - - - - Understanding WCAG 2.2 -{" "} - - SC 2.1.1 Keyboard - - - - Understanding WCAG 2.2 -{" "} - - SC 2.4.3 Focus Order - - - - Understanding WCAG 2.2 -{" "} - - SC 2.4.6 Headings and Labels - - - - Understanding WCAG 2.2 -{" "} - - SC 2.4.7 Focus Visible - - - - Understanding WCAG 2.2 -{" "} - - SC 4.1.2 Name, Role, Value - - - - ), - }, - { - title: "WAI-ARIA", - content: ( - - - WAI-ARIA Authoring practices 1.2 -{" "} - - 3.19 Slider - - - - WAI-ARIA Authoring practices 1.2 -{" "} - - Slider example - - - - ), - }, - ], - }, -]; - -const SliderSpecsPage = () => { - return ( - - - - - - - ); -}; - -export default SliderSpecsPage; diff --git a/apps/website/screens/components/slider/specs/images/slider_anatomy.png b/apps/website/screens/components/slider/specs/images/slider_anatomy.png deleted file mode 100644 index 2ee6da6b2d..0000000000 Binary files a/apps/website/screens/components/slider/specs/images/slider_anatomy.png and /dev/null differ diff --git a/apps/website/screens/components/slider/specs/images/slider_specs.png b/apps/website/screens/components/slider/specs/images/slider_specs.png deleted file mode 100644 index 623d22ea38..0000000000 Binary files a/apps/website/screens/components/slider/specs/images/slider_specs.png and /dev/null differ diff --git a/apps/website/screens/components/slider/specs/images/slider_states.png b/apps/website/screens/components/slider/specs/images/slider_states.png deleted file mode 100644 index 44acbbeb55..0000000000 Binary files a/apps/website/screens/components/slider/specs/images/slider_states.png and /dev/null differ diff --git a/apps/website/screens/components/slider/usage/SliderUsagePage.tsx b/apps/website/screens/components/slider/usage/SliderUsagePage.tsx deleted file mode 100644 index c1892a668a..0000000000 --- a/apps/website/screens/components/slider/usage/SliderUsagePage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { DxcBulletedList, DxcFlex, DxcTable, DxcParagraph } from "@dxc-technology/halstack-react"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import DocFooter from "@/common/DocFooter"; -import Example from "@/common/example/Example"; -import variants from "./examples/variants"; -import input from "./examples/input"; -import HeaderDescriptionCell from "@/common/HeaderDescriptionCell"; - -const sections = [ - { - title: "Usage", - content: ( - - - Visualize the output of the slider as feedback to the user of the current state. - - - As more information can give it to the user, the easier the selection will be. - - - If the value has to be specific, give some resource to the user to fill a precise input, i.e. an input next to - the slider. - - - ), - }, - { - title: "Variants", - content: ( - <> - - The slider component has two variants that can be used depending on the requirements of the application. - - - - - - Variant - Description - - - - - - Discrete - - Slider can only get the value marked alongside the total line. - - - - Continuos - - Slider can take every value mapped. - - - - - ), - }, - { - title: "Slider with input", - content: ( - <> - - To accomplish these considerations, some slider's variations were designed with the purpose of offering a - great user experience within the application. - - - - ), - }, -]; - -const SliderUsagePage = () => { - return ( - - - - - - - ); -}; - -export default SliderUsagePage; diff --git a/apps/website/screens/components/slider/usage/examples/variants.ts b/apps/website/screens/components/slider/usage/examples/variants.ts deleted file mode 100644 index e981472c41..0000000000 --- a/apps/website/screens/components/slider/usage/examples/variants.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { DxcSlider, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; -import { useState } from "react"; - -const code = `() => { - const [discreteValue, changeDiscreteValue] = useState(40); - const onChangeDiscrete = (newValue) => { - changeDiscreteValue(newValue); - }; - const [continuousValue, changeContinuousValue] = useState(45); - const onChangeContinuous = (newValue) => { - changeContinuousValue(newValue); - }; - - return ( - - - - - - - ); -}`; - -const scope = { - DxcSlider, - DxcInset, - DxcFlex, - useState, -}; - -export default { code, scope }; diff --git a/packages/lib/src/slider/Slider.stories.tsx b/packages/lib/src/slider/Slider.stories.tsx index a3deb0d622..d1318903e3 100644 --- a/packages/lib/src/slider/Slider.stories.tsx +++ b/packages/lib/src/slider/Slider.stories.tsx @@ -1,7 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; import Title from "../../.storybook/components/Title"; -import { HalstackProvider } from "../HalstackContext"; import DxcSlider from "./Slider"; export default { @@ -11,17 +10,9 @@ export default { const labelFormat = (value: number) => `${value}E100000000000000000000000`; -const opinionatedTheme = { - slider: { - baseColor: "#0067b3", - fontColor: "#000000", - totalLineColor: "#e6e6e6", - }, -}; - const Slider = () => ( <> - + <Title title="Thumb states" theme="light" level={2} /> <ExampleContainer pseudoState="pseudo-hover"> <Title title="Hovered" theme="light" level={4} /> <DxcSlider label="Slider" helperText="Help message" showLimitsValues /> @@ -66,14 +57,14 @@ const Slider = () => ( <Title title="Discrete slider with input" theme="light" level={4} /> <DxcSlider defaultValue={20} - minValue={0} + minValue={10} maxValue={50} label="Slider" helperText="Help message" showLimitsValues showInput marks - step={10} + step={20} /> </ExampleContainer> <Title title="Margins" theme="light" level={2} /> @@ -128,53 +119,9 @@ const Slider = () => ( size="large" /> </ExampleContainer> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSlider label="Slider" helperText="Help message" showLimitsValues /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSlider label="Slider" helperText="Help message" showLimitsValues /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSlider label="Slider" helperText="Help message" showLimitsValues /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled discrete slider with input" theme="light" level={4} />{" "} - <HalstackProvider theme={opinionatedTheme}> - <DxcSlider - label="Slider" - helperText="Help message" - disabled - defaultValue={40} - minValue={0} - maxValue={50} - showLimitsValues - showInput - marks - step={10} - /> - </HalstackProvider> - </ExampleContainer> <ExampleContainer> - <Title title="Continuous slider" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSlider defaultValue={65} label="Slider" helperText="Help message" showLimitsValues /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Discrete slider" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSlider defaultValue={20} label="Slider" helperText="Help message" showLimitsValues marks step={5} /> - </HalstackProvider> + <Title title="Rounded up slider" theme="light" level={4} /> + <DxcSlider label="Slider" helperText="Help message" showLimitsValues showInput value={15} step={10} marks /> </ExampleContainer> </> ); diff --git a/packages/lib/src/slider/Slider.test.tsx b/packages/lib/src/slider/Slider.test.tsx index 41bf7ca366..e872b29bec 100644 --- a/packages/lib/src/slider/Slider.test.tsx +++ b/packages/lib/src/slider/Slider.test.tsx @@ -1,5 +1,4 @@ -import { act, fireEvent, render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { act, fireEvent, render, waitFor } from "@testing-library/react"; import DxcSlider from "./Slider"; // Mocking DOMRect for Radix Primitive Popover @@ -15,7 +14,7 @@ import DxcSlider from "./Slider"; describe("Slider component tests", () => { test("Slider renders with correct text and label id", () => { - const { getByText, getByRole } = render(<DxcSlider label="label" minValue={0} maxValue={100} showLimitsValues />); + const { getByText, getByRole } = render(<DxcSlider label="label" showLimitsValues />); expect(getByText("0")).toBeTruthy(); expect(getByText("100")).toBeTruthy(); const sliderId = getByText("label").getAttribute("id"); @@ -23,50 +22,50 @@ describe("Slider component tests", () => { expect(getByRole("slider").getAttribute("aria-orientation")).toBe("horizontal"); expect(getByRole("slider").getAttribute("aria-label")).toBeNull(); }); - test("Renders with correct error aria label", () => { - const { getByRole } = render( - <DxcSlider ariaLabel="Example aria label" minValue={0} maxValue={100} showLimitsValues /> - ); + const { getByRole } = render(<DxcSlider ariaLabel="Example aria label" showLimitsValues />); const slider = getByRole("slider"); expect(slider.getAttribute("aria-label")).toBe("Example aria label"); }); - test("Slider renders with correct initial value when it is uncontrolled", () => { - const { getByRole } = render( - <DxcSlider defaultValue={30} minValue={0} maxValue={100} showLimitsValues showInput /> - ); + const { getByRole } = render(<DxcSlider defaultValue={30} showLimitsValues showInput />); const slider = getByRole("slider"); - const input = getByRole("textbox") as HTMLInputElement; + const input = getByRole("spinbutton") as HTMLInputElement; expect(slider.getAttribute("aria-valuenow")).toBe("30"); expect(input.value).toBe("30"); }); - - test("Slider correct limit values", () => { + test("Slider correct limit values", async () => { const { getByRole, getByText } = render( - <DxcSlider defaultValue={125} minValue={30} maxValue={125} showLimitsValues /> + <DxcSlider defaultValue={-30} minValue={-30} maxValue={125} showLimitsValues /> ); const slider = getByRole("slider"); - expect(slider.getAttribute("aria-valuemin")).toBe("30"); + expect(slider.getAttribute("aria-valuemin")).toBe("-30"); expect(slider.getAttribute("aria-valuemax")).toBe("125"); - userEvent.tab(); - fireEvent.keyDown(slider, { - key: "ArrowRight", - code: "ArrowRight", - keyCode: 39, - charCode: 39, - }); - expect(slider.getAttribute("aria-valuenow")).toBe("125"); - expect(getByText("30")).toBeTruthy(); + expect(getByText("-30")).toBeTruthy(); expect(getByText("125")).toBeTruthy(); + expect(slider.getAttribute("aria-valuenow")).toBe("-30"); + fireEvent.input(slider, { target: { value: "-29" } }); + expect(slider.getAttribute("aria-valuenow")).toBe("-29"); + }); + test("Slider applies correct limit values and never surpasses them", () => { + const { getByRole, getByText } = render( + <DxcSlider defaultValue={-100} minValue={-100} maxValue={100} showLimitsValues step={100} /> + ); + const slider = getByRole("slider") as HTMLInputElement; + expect(slider.getAttribute("aria-valuemin")).toBe("-100"); + expect(slider.getAttribute("aria-valuemax")).toBe("100"); + expect(getByText("-100")).toBeTruthy(); + expect(getByText("100")).toBeTruthy(); + expect(slider.value).toBe("-100"); + fireEvent.input(slider, { target: { value: "-101" } }); + expect(slider.value).toBe("-100"); + fireEvent.input(slider, { target: { value: "101" } }); + expect(slider.value).toBe("100"); }); - test("Calls correct function onChange in controlled slider", () => { const onChange = jest.fn(); - const { getByRole } = render( - <DxcSlider minValue={0} maxValue={100} onChange={onChange} showLimitsValues value={13} showInput /> - ); - const input = getByRole("textbox") as HTMLInputElement; + const { getByRole } = render(<DxcSlider onChange={onChange} showLimitsValues value={13} showInput />); + const input = getByRole("spinbutton") as HTMLInputElement; expect(getByRole("slider").getAttribute("aria-valuenow")).toBe("13"); expect(input.value).toBe("13"); act(() => { @@ -74,37 +73,19 @@ describe("Slider component tests", () => { }); expect(onChange).toHaveBeenCalledWith(25); expect(getByRole("slider").getAttribute("aria-valuenow")).toBe("13"); - expect(input.value).toBe("13"); + expect(input.value).toBe("25"); }); - test("Calls correct function onChange in uncontrolled slider", () => { const onChange = jest.fn(); - const { getByRole } = render( - <DxcSlider minValue={0} maxValue={100} onChange={onChange} showLimitsValues showInput /> - ); - const input = getByRole("textbox") as HTMLInputElement; + const { getByRole } = render(<DxcSlider onChange={onChange} showLimitsValues showInput />); + const textInput = getByRole("spinbutton") as HTMLInputElement; act(() => { - fireEvent.change(input, { target: { value: 25 } }); + fireEvent.change(textInput, { target: { value: 25 } }); }); expect(onChange).toHaveBeenCalledWith(25); expect(getByRole("slider").getAttribute("aria-valuenow")).toBe("25"); - expect(input.value).toBe("25"); - }); - - test("Disabled slider have disabled input and slider", () => { - const onChange = jest.fn(); - const { getByRole } = render( - <DxcSlider minValue={0} maxValue={100} onChange={onChange} showLimitsValues disabled showInput value={13} /> - ); - const input = getByRole("textbox") as HTMLInputElement; - act(() => { - fireEvent.change(input, { target: { value: 25 } }); - }); - expect(input.hasAttribute("disabled")).toBeTruthy(); - expect(input.value).toBe("13"); - expect(getByRole("slider").hasAttribute("disabled")).toBeTruthy(); + expect(textInput.value).toBe("25"); }); - test("Calls correct function onDragEnd when it is uncontrolled", () => { const onDragEnd = jest.fn(); const { getByRole } = render(<DxcSlider minValue={0} maxValue={150} onDragEnd={onDragEnd} showInput />); @@ -117,7 +98,6 @@ describe("Slider component tests", () => { }); expect(onDragEnd).toHaveBeenCalledWith(120); }); - test("Calls correct function onDragEnd when it is controlled", () => { const onDragEnd = jest.fn(); const { getByRole } = render(<DxcSlider minValue={0} maxValue={150} value={50} onDragEnd={onDragEnd} showInput />); @@ -131,7 +111,6 @@ describe("Slider component tests", () => { expect(onDragEnd).toHaveBeenCalledWith(120); expect(slider.getAttribute("aria-valuenow")).toBe("50"); }); - test("Calls correct function labelFormatCallback", () => { const labelFormatCallback = jest.fn((x) => `${x}$`); const { getByText } = render( @@ -148,21 +127,52 @@ describe("Slider component tests", () => { expect(getByText("100$")).toBeTruthy(); expect(labelFormatCallback).toHaveBeenCalledTimes(2); }); - - test("Change value correctly to 0 from external function", () => { - const onChange = jest.fn(); - const { rerender, getByRole } = render( - <DxcSlider minValue={0} maxValue={100} onChange={onChange} showLimitsValues value={13} showInput /> - ); + test("Non-valid values in the number input do not update the slider value: special characters", () => { + const { getByRole } = render(<DxcSlider showLimitsValues showInput defaultValue={23} />); + const input = getByRole("spinbutton") as HTMLInputElement; const slider = getByRole("slider"); - userEvent.tab(); - fireEvent.keyDown(slider, { - key: "ArrowRight", - code: "ArrowRight", - keyCode: 39, - charCode: 39, + act(() => { + fireEvent.change(input, { target: { value: "-" } }); }); - rerender(<DxcSlider minValue={0} maxValue={100} onChange={onChange} showLimitsValues value={0} showInput />); expect(slider.getAttribute("aria-valuenow")).toBe("0"); + fireEvent.blur(input); + expect(slider.getAttribute("aria-valuenow")).toBe("0"); + expect(input.value).toBe("0"); + }); + test("Non-valid values in the number input: values which do not respect the step are rounded up", () => { + const { getByRole } = render(<DxcSlider showLimitsValues showInput step={0.1} minValue={-1} maxValue={1} />); + const input = getByRole("spinbutton") as HTMLInputElement; + const slider = getByRole("slider"); + act(() => { + fireEvent.change(input, { target: { value: "-0.15" } }); + }); + expect(slider.getAttribute("aria-valuenow")).toBe("-0.15"); + fireEvent.blur(input); + expect(slider.getAttribute("aria-valuenow")).toBe("-0.2"); + expect(input.value).toBe("-0.2"); + }); + test("Non-valid values in the number input: values that surpass the maximum limit are set to the maximum possible value", () => { + const { getByRole } = render(<DxcSlider showLimitsValues showInput step={5} minValue={-10} maxValue={10} />); + const input = getByRole("spinbutton") as HTMLInputElement; + const slider = getByRole("slider"); + act(() => { + fireEvent.change(input, { target: { value: "15" } }); + }); + expect(slider.getAttribute("aria-valuenow")).toBe("15"); + fireEvent.blur(input); + expect(slider.getAttribute("aria-valuenow")).toBe("10"); + expect(input.value).toBe("10"); + }); + test("Non-valid values in the number input: values that surpass the minimum limit are set to the minimum possible value", () => { + const { getByRole } = render(<DxcSlider showLimitsValues showInput step={5} minValue={-10} maxValue={10} />); + const input = getByRole("spinbutton") as HTMLInputElement; + const slider = getByRole("slider"); + act(() => { + fireEvent.change(input, { target: { value: "-200" } }); + }); + expect(slider.getAttribute("aria-valuenow")).toBe("-200"); + fireEvent.blur(input); + expect(slider.getAttribute("aria-valuenow")).toBe("-10"); + expect(input.value).toBe("-10"); }); }); diff --git a/packages/lib/src/slider/Slider.tsx b/packages/lib/src/slider/Slider.tsx index 741a49a23d..34782b2e02 100644 --- a/packages/lib/src/slider/Slider.tsx +++ b/packages/lib/src/slider/Slider.tsx @@ -1,31 +1,11 @@ -import { ChangeEvent, forwardRef, MouseEvent, useContext, useId, useMemo, useState } from "react"; -import styled, { ThemeProvider } from "styled-components"; -import DxcTextInput from "../text-input/TextInput"; +import { ChangeEvent, forwardRef, MouseEvent, useId, useMemo, useState } from "react"; +import styled, { css } from "styled-components"; import { spaces } from "../common/variables"; -import { getMargin } from "../common/utils"; -import HalstackContext from "../HalstackContext"; import SliderPropsType, { RefType } from "./types"; +import { calculateWidth, roundUp, stepPrecision } from "./utils"; +import DxcNumberInput from "../number-input/NumberInput"; -const sizes = { - medium: "360px", - large: "480px", - fillParent: "100%", -}; - -const calculateWidth = (margin: SliderPropsType["margin"], size: SliderPropsType["size"]) => - size === "fillParent" - ? `calc(${sizes[size]} - ${getMargin(margin, "left")} - ${getMargin(margin, "right")})` - : size && sizes[size]; - -const getChromeStyles = () => ` - width: 100%; - margin-right: 4px;`; - -const getFirefoxStyles = () => ` - width: calc(100% - 16px); - margin-right: 3px;`; - -const Container = styled.div<{ +const SliderContainer = styled.div<{ margin: SliderPropsType["margin"]; size: SliderPropsType["size"]; }>` @@ -44,330 +24,259 @@ const Container = styled.div<{ `; const Label = styled.label<{ disabled: SliderPropsType["disabled"] }>` - color: ${(props) => (props.disabled ? props.theme.disabledLabelFontColor : props.theme.labelFontColor)}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: ${(props) => props.theme.labelFontSize}; - font-style: ${(props) => props.theme.labelFontStyle}; - font-weight: ${(props) => props.theme.labelFontWeight}; - line-height: ${(props) => props.theme.labelLineHeight}; + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-semibold); `; const HelperText = styled.span<{ disabled: SliderPropsType["disabled"] }>` - color: ${(props) => (props.disabled ? props.theme.disabledHelperTextFontColor : props.theme.helperTextFontColor)}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: ${(props) => props.theme.helperTextFontSize}; - font-style: ${(props) => props.theme.helperTextFontStyle}; - font-weight: ${(props) => props.theme.helperTextFontWeight}; - line-height: ${(props) => props.theme.helperTextLineHeight}; + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-stronger)")}; + font-family: var(--typography-font-family); + font-size: var(--typography-helper-text-s); + font-weight: var(--typography-helper-text-regular); +`; + +const MainContainer = styled.div<{ showInput: SliderPropsType["showInput"] }>` + display: grid; + gap: var(--spacing-gap-l); + ${({ showInput }) => showInput && "grid-template-columns: 1fr 64px;"}; + height: var(--height-xxl); + place-items: center; +`; + +const LimitsValueGrid = styled.div<{ showLimitsValues: SliderPropsType["showLimitsValues"] }>` + display: grid; + align-items: center; + gap: var(--spacing-gap-ml); + ${({ showLimitsValues }) => showLimitsValues && "grid-template-columns: auto 1fr auto;"} + width: 100%; +`; + +const LimitLabel = styled.span<{ + disabled: SliderPropsType["disabled"]; +}>` + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + font-family: var(--typography-font-family); + font-size: var(--typography-label-l); + font-weight: var(--typography-label-regular); +`; + +const SliderInputContainer = styled.div` + position: relative; + display: flex; + align-items: center; + height: var(--height-xxxs); + min-width: 184px; `; +const thumbStyles = (disabled: SliderPropsType["disabled"]) => css` + -webkit-appearance: none; + width: 12px; + height: var(--height-xxxs); + background-color: ${disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-secondary-medium)"}; + border: none; + border-radius: 50%; + transition: + width 0.2s ease, + height 0.2s ease; + &:active { + ${!disabled && `background-color: var(--color-fg-secondary-stronger);`} + } + &:hover { + ${!disabled && + `background-color: var(--color-fg-secondary-strong); + height: var(--height-xxs); + width: 16px;`} + } +`; +const thumbFocusStyles = css` + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + outline-offset: 2px; +`; const SliderInput = styled.input<{ disabled: SliderPropsType["disabled"]; - value: SliderPropsType["value"]; - min: SliderPropsType["minValue"]; - max: SliderPropsType["maxValue"]; + max: Required<SliderPropsType>["maxValue"]; + min: Required<SliderPropsType>["minValue"]; + value: Required<SliderPropsType>["value"]; }>` - width: 100%; - min-width: 240px; - height: ${(props) => props.theme.trackLineThickness}; - display: inline-block; - vertical-align: middle; -webkit-appearance: none; - background-color: ${(props) => - props.disabled ? `${props.theme.disabledTotalLineColor}61` : props.theme.totalLineColor}; - background-image: ${(props) => - props.disabled - ? `linear-gradient(${props.theme.disabledTrackLineColor}, ${props.theme.disabledTrackLineColor})` - : `linear-gradient(${props.theme.trackLineColor}, ${props.theme.trackLineColor})`}; + margin: 0; + width: 100%; + height: 2px; + background-color: ${({ disabled }) => + disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-lighter)"}; + background-image: ${({ disabled }) => + disabled + ? "linear-gradient(var(--color-fg-neutral-medium), var(--color-fg-neutral-medium))" + : "linear-gradient(var(--color-fg-secondary-medium), var(--color-fg-secondary-medium))"}; background-repeat: no-repeat; - background-size: ${(props) => - props.value != null && - props.min != null && - props.max != null && - `${((props.value - props.min) * 100) / (props.max - props.min)}% 100%`}; - border-radius: 5px; - cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; - &::-webkit-slider-runnable-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; - margin: 0px -8px; - } + ${({ max, min, value }) => { + const base10 = ((value - min) / (max - min)) * 100; + return `background-size: ${base10}% 100%;`; + }} + border-radius: var(--border-radius-m); + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; &::-webkit-slider-thumb { - -webkit-appearance: none; - border: none; - height: ${(props) => props.theme.thumbHeight}; - width: ${(props) => props.theme.thumbWidth}; - border-radius: 25px; - background: ${(props) => - props.disabled ? props.theme.disabledThumbBackgroundColor : props.theme.thumbBackgroundColor}; - &:active { - ${(props) => - !props.disabled && - ` - background: ${props.theme.activeThumbBackgroundColor}; - transform: scale(1.16667);`} - } - &:hover { - ${(props) => - !props.disabled && - `height: ${props.theme.hoverThumbHeight}; - width: ${props.theme.hoverThumbWidth}; - transform: scale(1.16667); - transform-origin: center center; - background: ${props.theme.hoverThumbBackgroundColor};`} - } - } - &::-moz-range-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; + ${({ disabled }) => thumbStyles(disabled)} } &::-moz-range-thumb { - -webkit-appearance: none; - border: none; - height: ${(props) => props.theme.thumbHeight}; - width: ${(props) => props.theme.thumbWidth}; - border-radius: 25px; - background: ${(props) => - props.disabled ? props.theme.disabledThumbBackgroundColor : props.theme.thumbBackgroundColor}; - &:active { - background: ${(props) => props.theme.activeThumbBackgroundColor}; - transform: scale(1.16667); - } - &:hover { - ${(props) => - !props.disabled && - `height: ${props.theme.hoverThumbHeight}; - width: ${props.theme.hoverThumbWidth}; - transform: scale(1.16667); - transform-origin: center center; - background: ${props.theme.hoverThumbBackgroundColor};`} - } + ${({ disabled }) => thumbStyles(disabled)} } &:focus { outline: none; - &::-webkit-slider-thumb { - outline: ${(props) => (props.disabled ? props.theme.disabledFocusColor : props.theme.focusColor)} auto 1px; - outline-offset: 2px; + ::-webkit-slider-thumb { + ${thumbFocusStyles} } - &::-moz-range-thumb { - outline: ${(props) => (props.disabled ? props.theme.disabledFocusColor : props.theme.focusColor)} auto 1px; - outline-offset: 2px; + ::-moz-range-thumb { + ${thumbFocusStyles} } } `; -const SliderContainer = styled.div` +const TicksContainer = styled.div` + position: absolute; display: flex; - height: 48px; align-items: center; -`; - -const LimitLabelContainer = styled.span<{ - disabled: SliderPropsType["disabled"]; -}>` - color: ${(props) => (props.disabled ? props.theme.disabledLimitValuesFontColor : props.theme.limitValuesFontColor)}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: ${(props) => props.theme.limitValuesFontSize}; - font-style: ${(props) => props.theme.limitValuesFontStyle}; - font-weight: ${(props) => props.theme.limitValuesFontWeight}; - letter-spacing: ${(props) => props.theme.limitValuesFontLetterSpacing}; - white-space: nowrap; -`; - -const MinLabelContainer = styled(LimitLabelContainer)` - margin-right: ${(props) => props.theme.floorLabelMarginRight}; -`; - -const MaxLabelContainer = styled(LimitLabelContainer)<{ step: number }>` - margin-left: ${(props) => (props.step === 1 ? props.theme.ceilLabelMarginLeft : "1.25rem")}; -`; - -const SliderInputContainer = styled.div` - position: relative; + justify-content: space-between; width: 100%; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - margin-right: -2px; - padding-top: 1px; - z-index: 0; -`; - -const MarksContainer = styled.div<{ isFirefox: boolean }>` - ${(props) => (props.isFirefox ? getFirefoxStyles() : getChromeStyles())} - position: absolute; pointer-events: none; - height: 100%; - display: flex; - align-items: center; `; -const TickMark = styled.span<{ - stepPosition: number; +const Tick = styled.span<{ disabled: SliderPropsType["disabled"]; - stepValue: SliderPropsType["value"]; + currentTick: boolean; }>` - position: absolute; - background: ${(props) => - props.disabled ? props.theme.disabledTickBackgroundColor : props.theme.tickBackgroundColor}; - height: ${(props) => props.theme.tickHeight}; - width: ${(props) => props.theme.tickWidth}; - border-radius: 18px; - left: ${(props) => `calc(${props.stepPosition} * 100%)`}; - z-index: ${(props) => props.stepValue != null && `${props.stepPosition <= props.stepValue ? "-1" : "0"}`}; -`; - -const TextInputContainer = styled.div` - margin-left: ${(props) => props.theme.inputMarginLeft}; - max-width: 70px; + background-color: ${({ disabled }) => + disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-secondary-medium)"}; + border-radius: 50%; + height: 4px; + width: 4px; + ${({ currentTick }) => currentTick && "visibility: hidden;"}; `; const DxcSlider = forwardRef<RefType, SliderPropsType>( ( { - label = "", - name = "", - defaultValue, - value, - helperText = "", - minValue = 0, + ariaLabel = "Slider", + defaultValue = 0, + disabled, + helperText, + label, + labelFormatCallback, + margin, + marks, maxValue = 100, - step = 1, - showLimitsValues = false, - showInput = false, - disabled = false, - marks = false, + minValue = 0, + name, onChange, onDragEnd, - labelFormatCallback, - margin, + showLimitsValues, + showInput, size = "fillParent", - ariaLabel = "Slider", + step = 1, + value, }, ref - ): JSX.Element => { + ) => { const labelId = `label-${useId()}`; - const [innerValue, setInnerValue] = useState(defaultValue ?? 0); - const [dragging, setDragging] = useState(false); - const colorsTheme = useContext(HalstackContext); - const isFirefox = navigator.userAgent.indexOf("Firefox") !== -1; - - const minLabel = useMemo( - () => (labelFormatCallback ? labelFormatCallback(minValue) : minValue), - [labelFormatCallback, minValue] + const [innerValue, setInnerValue] = useState(defaultValue); + const [inputValue, setInputValue] = useState((value ?? defaultValue).toString()); + const roundedUpValue = useMemo( + () => roundUp(value ?? innerValue, step, minValue, maxValue), + [innerValue, maxValue, minValue, step, value] ); + const minLabel = useMemo(() => labelFormatCallback?.(minValue) ?? minValue, [labelFormatCallback, minValue]); + const maxLabel = useMemo(() => labelFormatCallback?.(maxValue) ?? maxValue, [labelFormatCallback, maxValue]); - const maxLabel = useMemo( - () => (labelFormatCallback ? labelFormatCallback(maxValue) : maxValue), - [labelFormatCallback, maxValue] - ); - - const tickMarks = useMemo(() => { - const numberOfMarks = Math.floor(maxValue / step - minValue / step); - const range = maxValue - minValue; - const ticks = []; - - if (marks) { - for (let index = 0; index <= numberOfMarks; index++) { - ticks.push( - <TickMark - disabled={disabled} - stepPosition={(step * index) / range} - stepValue={(value ?? innerValue) / maxValue} - key={`tickmark-${index}`} - /> - ); - } - return ticks; - } - return null; - }, [minValue, maxValue, step, value, innerValue]); + const changeValue = (newValue: string) => { + if (showInput) setInputValue(newValue); + const numberValue = Number(newValue); + if (value == null) setInnerValue(numberValue); + onChange?.(numberValue); + }; - const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => { - const intValue = parseInt(event.target.value, 10); - if (intValue !== value || intValue !== innerValue) { - setInnerValue(intValue); - } - onChange?.(intValue); + const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => { + changeValue(event.target.value); }; - const handleSliderDragging = () => { - setDragging(true); + const handleOnMouseUp = (event: MouseEvent<HTMLInputElement>) => { + const sliderIntegerValue = Number((event.target as HTMLInputElement).value); + onDragEnd?.(sliderIntegerValue); }; - const handleSliderOnChangeCommitted = (event: MouseEvent<HTMLInputElement>) => { - const intValue = parseInt((event.target as HTMLInputElement).value, 10); - if (dragging) { - setDragging(false); - onDragEnd?.(intValue); - } + const handlerNumberInputOnChange = (event: { value: string; error?: string }) => { + changeValue(event.value); }; - const handlerInputChange = (event: { value: string; error?: string }) => { - const intValue = parseInt(event.value, 10); - if (!Number.isNaN(intValue)) { - if (value == null) setInnerValue(intValue > maxValue ? maxValue : intValue); - onChange?.(intValue > maxValue ? maxValue : intValue); - } + const handlerNumberInputOnBlur = (event: { value: string; error?: string }) => { + const textInputIntegerValue = Number(event.value); + if (textInputIntegerValue < minValue) changeValue(minValue.toString()); + else if (textInputIntegerValue > maxValue) changeValue(maxValue.toString()); + else changeValue(roundUp(textInputIntegerValue, step, minValue, maxValue).toString()); }; return ( - <ThemeProvider theme={colorsTheme.slider}> - <Container margin={margin} size={size} ref={ref}> - {label && ( - <Label id={labelId} disabled={disabled}> - {label} - </Label> - )} - <HelperText disabled={disabled}>{helperText}</HelperText> - <SliderContainer> - {showLimitsValues && <MinLabelContainer disabled={disabled}>{minLabel}</MinLabelContainer>} + <SliderContainer margin={margin} size={size} ref={ref}> + {label && ( + <Label id={labelId} disabled={disabled}> + {label} + </Label> + )} + {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} + <MainContainer showInput={showInput}> + <LimitsValueGrid showLimitsValues={showLimitsValues}> + {showLimitsValues && <LimitLabel disabled={disabled}>{minLabel}</LimitLabel>} <SliderInputContainer> <SliderInput - role="slider" - type="range" - value={value != null && value >= 0 ? value : innerValue} - min={minValue} - max={maxValue} - step={step} - disabled={disabled} + aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? labelId : undefined} aria-orientation="horizontal" aria-valuemax={maxValue} aria-valuemin={minValue} - aria-valuenow={value != null && value >= 0 ? value : innerValue} - aria-label={label ? undefined : ariaLabel} - onChange={handleSliderChange} - onMouseUp={handleSliderOnChangeCommitted} - onMouseDown={handleSliderDragging} + aria-valuenow={value ?? innerValue} + disabled={disabled} + max={maxValue} + min={minValue} + onChange={handleOnChange} + onMouseUp={handleOnMouseUp} + role="slider" + step={step} + type="range" + value={roundedUpValue} /> - {marks && <MarksContainer isFirefox={isFirefox}>{tickMarks}</MarksContainer>} + {marks && ( + <TicksContainer> + {Array.from({ length: Math.floor((maxValue - minValue) / step) + 1 }, (_, index) => { + const tick = minValue + index * step; + return ( + <Tick + currentTick={roundedUpValue === stepPrecision(tick, step)} + disabled={disabled} + key={`tickmark-${index}`} + /> + ); + })} + </TicksContainer> + )} </SliderInputContainer> - {showLimitsValues && ( - <MaxLabelContainer disabled={disabled} step={step}> - {maxLabel} - </MaxLabelContainer> - )} - {showInput && ( - <TextInputContainer> - <DxcTextInput - name={name} - value={value != null && value >= 0 ? value.toString() : innerValue.toString()} - disabled={disabled} - onChange={handlerInputChange} - size="fillParent" - /> - </TextInputContainer> - )} - </SliderContainer> - </Container> - </ThemeProvider> + {showLimitsValues && <LimitLabel disabled={disabled}>{maxLabel}</LimitLabel>} + </LimitsValueGrid> + {showInput && ( + <DxcNumberInput + disabled={disabled} + name={name} + onBlur={handlerNumberInputOnBlur} + onChange={handlerNumberInputOnChange} + showControls={false} + size="fillParent" + step={step} + value={inputValue} + /> + )} + </MainContainer> + </SliderContainer> ); } ); diff --git a/packages/lib/src/slider/types.ts b/packages/lib/src/slider/types.ts index 56b65570ce..2361ed43f3 100644 --- a/packages/lib/src/slider/types.ts +++ b/packages/lib/src/slider/types.ts @@ -2,53 +2,51 @@ import { Margin, Space } from "../common/utils"; type Props = { /** - * Text to be placed above the slider. - */ - label?: string; - /** - * Name attribute of the input element. + * Specifies a string to be used as the name for the slider element when no `label` is provided. */ - name?: string; + ariaLabel?: string; /** * Initial value of the slider, only when it is uncontrolled. */ defaultValue?: number; /** - * The selected value. If undefined, the component will be uncontrolled and the value will be managed internally by the component. + * If true, the component will be disabled. */ - value?: number; + disabled?: boolean; /** * Helper text to be placed above the slider. */ helperText?: string; /** - * The minimum value available for selection. + * Text to be placed above the slider. */ - minValue?: number; + label?: string; /** - * The maximum value available for selection. + * This function will be used to format the labels displayed next to the slider. + * The value will be passed as parameter and the function must return the formatted value. */ - maxValue?: number; + labelFormatCallback?: (value: number) => string; /** - * The step interval between values available for selection. + * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). + * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. */ - step?: number; + margin?: Space | Margin; /** - * Whether the min/max value labels should be displayed next to the slider + * Whether the marks between each step should be shown or not. */ - showLimitsValues?: boolean; + marks?: boolean; /** - * Whether the input element for displaying/controlling the slider value should be displayed next to the slider. + * The maximum value available for selection. */ - showInput?: boolean; + maxValue?: number; /** - * If true, the component will be disabled. + * The minimum value available for selection. */ - disabled?: boolean; + minValue?: number; /** - * Whether the marks between each step should be shown or not. + * Name attribute of the input element. */ - marks?: boolean; + name?: string; /** * This function will be called when the slider changes its value, as it's being dragged. * The new value will be passed as a parameter when this function is executed. @@ -60,23 +58,25 @@ type Props = { */ onDragEnd?: (value: number) => void; /** - * This function will be used to format the labels displayed next to the slider. - * The value will be passed as parameter and the function must return the formatted value. + * Whether the input element for displaying/controlling the slider value should be displayed next to the slider. */ - labelFormatCallback?: (value: number) => string; + showInput?: boolean; /** - * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). - * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. + * Whether the min/max value labels should be displayed next to the slider */ - margin?: Space | Margin; + showLimitsValues?: boolean; /** * Size of the component. */ size?: "medium" | "large" | "fillParent"; /** - * Specifies a string to be used as the name for the slider element when no `label` is provided. + * The step interval between values available for selection. */ - ariaLabel?: string; + step?: number; + /** + * The selected value. If undefined, the component will be uncontrolled and the value will be managed internally by the component. + */ + value?: number; }; /** diff --git a/packages/lib/src/slider/utils.ts b/packages/lib/src/slider/utils.ts new file mode 100644 index 0000000000..f6572672b0 --- /dev/null +++ b/packages/lib/src/slider/utils.ts @@ -0,0 +1,58 @@ +import SliderPropsType from "./types"; +import { getMargin } from "../common/utils"; + +const sizes = { + medium: "360px", + large: "480px", + fillParent: "100%", +}; + +export const calculateWidth = (margin: SliderPropsType["margin"], size: SliderPropsType["size"]) => + size === "fillParent" + ? `calc(${sizes[size]} - ${getMargin(margin, "left")} - ${getMargin(margin, "right")})` + : size && sizes[size]; + +/** + * Rounds a number to a specific number of decimal places. + * this function tries to avoid floating point inaccuracies, present in JS: + * + * 0.1 + 0.2 === 0.3 // false + * + * @param number the number to round + * @param step slider step value that defines the number of decimal places + * @returns the rounded number + */ +export const stepPrecision = (target: number, step: number) => { + const precision = step.toString().split(".")[1]?.length ?? 0; + return Number(target.toFixed(precision)); +}; + +/** + * This function calculates the closest tick value to the target value within the range [min, max]. + * + * @param target the target value to round up + * @param step the step value that defines the ticks from the range + * @param min the minimum value of the range + * @param max the maximum value of the range + * @returns the closest tick value to the target value + */ +export const roundUp = (target: number, step: number, min: number, max: number): number => { + if (target === 0) return 0; + else if (target <= min) return min; + else if (target >= max) return max; + else if (step === 1) return Math.round(target); + + const ticks = Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, index) => stepPrecision(min + index * step, step)); + if (ticks.includes(target)) return target; + + let rounded = 0; + let acc = Infinity; + for (const tick of ticks) { + const diff = Math.abs(stepPrecision(target - tick, target)); + if (diff < Math.abs(acc) || (diff === Math.abs(acc) && target > 0)) { + rounded = tick; + acc = diff; + } else break; + }; + return rounded; +};