diff --git a/apps/website/pages/components/spinner/code.tsx b/apps/website/pages/components/spinner/code.tsx new file mode 100644 index 0000000000..38d80168ab --- /dev/null +++ b/apps/website/pages/components/spinner/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import SpinnerCodePage from "screens/components/spinner/code/SpinnerCodePage"; +import SpinnerPageLayout from "screens/components/spinner/SpinnerPageLayout"; + +const Code = () => ( + <> + + Spinner code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/spinner/index.tsx b/apps/website/pages/components/spinner/index.tsx index bce3c4e3c3..817f8c061b 100644 --- a/apps/website/pages/components/spinner/index.tsx +++ b/apps/website/pages/components/spinner/index.tsx @@ -1,21 +1,17 @@ import Head from "next/head"; import type { ReactElement } from "react"; -import SpinnerCodePage from "screens/components/spinner/code/SpinnerCodePage"; +import SpinnerOverviewPage from "screens/components/spinner/overview/SpinnerOverviewPage"; import SpinnerPageLayout from "screens/components/spinner/SpinnerPageLayout"; -const Index = () => { - return ( - <> - - Spinner — Halstack Design System - - - - ); -}; +const Index = () => ( + <> + + Spinner — 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/spinner/specifications.tsx b/apps/website/pages/components/spinner/specifications.tsx deleted file mode 100644 index 3451cab33a..0000000000 --- a/apps/website/pages/components/spinner/specifications.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SpinnerSpecsPage from "screens/components/spinner/specs/SpinnerSpecsPage"; -import SpinnerPageLayout from "screens/components/spinner/SpinnerPageLayout"; - -const Specifications = () => { - return ( - <> - - Spinner Specs — Halstack Design System - - - - ); -}; - -Specifications.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Specifications; diff --git a/apps/website/pages/components/spinner/usage.tsx b/apps/website/pages/components/spinner/usage.tsx deleted file mode 100644 index 9e7b51b449..0000000000 --- a/apps/website/pages/components/spinner/usage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SpinnerUsagePage from "screens/components/spinner/usage/SpinnerUsagePage"; -import SpinnerPageLayout from "screens/components/spinner/SpinnerPageLayout"; - -const Usage = () => { - return ( - <> - - Spinner Usage — Halstack Design System - - - - ); -}; - -Usage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Usage; diff --git a/apps/website/screens/components/spinner/SpinnerPageLayout.tsx b/apps/website/screens/components/spinner/SpinnerPageLayout.tsx index 0390e9adf7..7dcb6a1752 100644 --- a/apps/website/screens/components/spinner/SpinnerPageLayout.tsx +++ b/apps/website/screens/components/spinner/SpinnerPageLayout.tsx @@ -6,9 +6,8 @@ import { ReactNode } from "react"; const SpinnerPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Code", path: "/components/spinner" }, - { label: "Usage", path: "/components/spinner/usage" }, - { label: "Specifications", path: "/components/spinner/specifications" }, + { label: "Overview", path: "/components/spinner" }, + { label: "Code", path: "/components/spinner/code" }, ]; return ( @@ -17,9 +16,9 @@ const SpinnerPageHeading = ({ children }: { children: ReactNode }) => { - Loading spinner is a waiting indicator in the user interface to communicate users an ongoing proccess. + Loading spinner is a waiting indicator in the user interface to communicate users an ongoing process. - + {children} diff --git a/apps/website/screens/components/spinner/code/SpinnerCodePage.tsx b/apps/website/screens/components/spinner/code/SpinnerCodePage.tsx index 2494b58b3d..9d65c905fb 100644 --- a/apps/website/screens/components/spinner/code/SpinnerCodePage.tsx +++ b/apps/website/screens/components/spinner/code/SpinnerCodePage.tsx @@ -22,6 +22,19 @@ const sections = [ + + ariaLabel + + string + + + Specifies a string to be used as the accessible name for the component when no label is + provided or the mode is set to small. + + + 'Spinner' + + label @@ -34,25 +47,25 @@ const sections = [ - - mode + margin - 'large' | 'small' | 'overlay' + 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin - The different variants of the components. - 'large' + 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. + - - value + mode - number + 'large' | 'small' | 'overlay' + The different variants of the components. - The value of the progress indicator. If it's received the component is determinate, otherwise is - indeterminate. + 'large' - - showValue @@ -65,27 +78,16 @@ const sections = [ - margin + value - 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + number - 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. + The value of the progress indicator. If it's received the component is determinate, otherwise is + indeterminate. - - - ariaLabel - - string - - - Specifies a string to be used as the name for the spinner element when no `label` is provided or the - `mode` is set to small. - - 'Spinner' - ), @@ -105,15 +107,13 @@ const sections = [ }, ]; -const SpinnerUsagePage = () => { - return ( - - - - - - - ); -}; +const SpinnerCodePage = () => ( + + + + + + +); -export default SpinnerUsagePage; +export default SpinnerCodePage; diff --git a/apps/website/screens/components/spinner/code/examples/basicUsage.ts b/apps/website/screens/components/spinner/code/examples/basicUsage.ts index bfb5a2b721..857f2f7655 100644 --- a/apps/website/screens/components/spinner/code/examples/basicUsage.ts +++ b/apps/website/screens/components/spinner/code/examples/basicUsage.ts @@ -3,7 +3,7 @@ import { DxcSpinner, DxcInset } from "@dxc-technology/halstack-react"; const code = `() => { return ( - + ); }`; diff --git a/apps/website/screens/components/spinner/overview/SpinnerOverviewPage.tsx b/apps/website/screens/components/spinner/overview/SpinnerOverviewPage.tsx new file mode 100644 index 0000000000..28eb4a29d3 --- /dev/null +++ b/apps/website/screens/components/spinner/overview/SpinnerOverviewPage.tsx @@ -0,0 +1,231 @@ +import { DxcParagraph, DxcBulletedList, DxcFlex, DxcInset } from "@dxc-technology/halstack-react"; +import Image from "@/common/Image"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; +import DocFooter from "@/common/DocFooter"; +import Example from "@/common/example/Example"; +import determinateIndeterminate from "./examples/determinateIndeterminate"; +import small from "./examples/small"; +import anatomy from "./images/spinner_anatomy.png"; +import overlay from "./images/spinner_overlay.png"; + +const sections = [ + { + title: "Introduction", + content: ( + + The spinner component is a visual indicator that communicates to users that a process is in progress. It is + commonly used for loading states, where content or data retrieval takes time, indicating that an action is being + executed. Spinners help maintain a smooth user experience by reducing uncertainty and providing feedback while + waiting. They can be adapted in size and color to fit different contexts, making them a versatile choice for + various loading scenarios. + + ), + }, + { + title: "Anatomy", + content: ( + <> + Spinner's anatomy + + + Label: provides a textual description of the loading process, enhancing accessibility and + helping users understand what is being loaded. + + + Total circle: represents the full circumference of the spinner, serving as the stating + background that defines the complete loading cycle. + + + Track circle: the dynamic element that visually indicates progress by filling up according + to the percentage of completion. + + + Percentage (Optional): displays a numerical value, typically in the center of the + spinner, showing the exact progress of the loading process. + + + + ), + }, + { + title: "Variants", + content: ( + + Depending on the size or position, there are three different variants for the spinner component:{" "} + defaultdefault, small and overlay. + + ), + subSections: [ + { + title: "Default", + content: ( + + The default variant of the spinner is the standard option, offering a clear visual + indication that a process is in progress. It is designed to be noticeable, making it ideal for situations + where users must wait for content or system responses. As well as the overlay variant, the default version + of our spinner can be determinate (it shows the percentage related to the progress of the + process) or indeterminate. + + ), + subSections: [ + { + title: "Use cases", + content: ( + <> + + + Page loading: indicating that a new page or section of an application is being + loaded. + + + Data fetching: warning users of a potential security breach, with actions to change + passwords or review account activity. + + + Form submission processing: letting users know that their input is being processed. + + + + + ), + }, + ], + }, + { + title: "Overlay", + content: ( + + The overlay variant of the spinner is designed for scenarios where the entire interface is temporarily + blocked while a process is running. It appears centered on the screen with a semi-transparent background,{" "} + preventing user interactions until the task is completed. This variant ensures users are + aware that the system is actively processing their requests and helps prevent unintended actions during + critical operations. + + ), + subSections: [ + { + title: "Use cases", + content: ( + <> + + + System-wide loading states: when an entire page or app is waiting for a response. + + + Blocking UI interactions: preventing user input while a critical process is being + executed. + + + Authentication processing: indicating login or security verification in progress. + + + Payment processing: ensuring transactions are completed before allowing further + actions. + + + Spinner overlay variants + + ), + }, + ], + }, + { + title: "Small", + content: ( + + The small variant of the spinner is a compact loading indicator designed for inline or + localized loading states. It is ideal for{" "} + non-blocking scenarios where users can continue interacting with other elements while + waiting for specific content or data to load. This variant seamlessly integrates within UI components + without overwhelming the interface. + + ), + subSections: [ + { + title: "Use cases", + content: ( + <> + + + Button loading states: indicating an action is in progress, such as form + submission. + + + Table or list updates: showing loading status when fetching additional rows or + filtering data. + + + Inline form validation: providing feedback when checking input validity. + + + + + ), + }, + ], + }, + ], + }, + { + title: "Best practices", + content: ( + <> + + + Use spinners for real-time feedback: implement the spinner only when there is a delay in + content loading or an ongoing process that requires user awareness. Avoid unnecessary use that may lead to + confusion. + + + Choose the right variant: + + + + The default (large) spinner is ideal for full-page or major loading states that + require user attention. + + + The overlay spinner works well for modal or section-based loading, preventing + interaction with specific content while keeping the rest of the UI accessible. + + + The small spinner is best for inline feedback, such as button actions, table updates, + or background data processing. + + + + + + Prevent indefinite loading states: always ensure the spinner disappears once the process is + complete. If an operation takes too long, consider displaying a message or progress indicator with more + details. + + + Avoid blocking critical interactions: if possible, allow users to navigate or interact with + other elements while waiting. Overlay spinners should be used cautiously to prevent unnecessary disruption. + + + Combine with descriptive labels when necessary: if the loading state might be unclear, + include a short label (e.g., "Loading data…" or "Processing request…") to provide context. + + + Optimize performance: if an operation takes longer than expected, consider showing an + estimated time or an alternative UI state to maintain user engagement. + + + + ), + }, +]; + +const SpinnerOverviewPage = () => ( + + + + + + +); + +export default SpinnerOverviewPage; diff --git a/apps/website/screens/components/spinner/usage/examples/determinateIndeterminate.ts b/apps/website/screens/components/spinner/overview/examples/determinateIndeterminate.ts similarity index 100% rename from apps/website/screens/components/spinner/usage/examples/determinateIndeterminate.ts rename to apps/website/screens/components/spinner/overview/examples/determinateIndeterminate.ts diff --git a/apps/website/screens/components/spinner/overview/examples/small.ts b/apps/website/screens/components/spinner/overview/examples/small.ts new file mode 100644 index 0000000000..f4b38a83c6 --- /dev/null +++ b/apps/website/screens/components/spinner/overview/examples/small.ts @@ -0,0 +1,20 @@ +import { DxcSpinner, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; + +const code = `() => { + return ( + + + + + + + ); +}`; + +const scope = { + DxcSpinner, + DxcInset, + DxcFlex, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/spinner/overview/images/spinner_anatomy.png b/apps/website/screens/components/spinner/overview/images/spinner_anatomy.png new file mode 100644 index 0000000000..c8feead275 Binary files /dev/null and b/apps/website/screens/components/spinner/overview/images/spinner_anatomy.png differ diff --git a/apps/website/screens/components/spinner/overview/images/spinner_overlay.png b/apps/website/screens/components/spinner/overview/images/spinner_overlay.png new file mode 100644 index 0000000000..86012302a2 Binary files /dev/null and b/apps/website/screens/components/spinner/overview/images/spinner_overlay.png differ diff --git a/apps/website/screens/components/spinner/specs/SpinnerSpecsPage.tsx b/apps/website/screens/components/spinner/specs/SpinnerSpecsPage.tsx deleted file mode 100644 index 352ec22de6..0000000000 --- a/apps/website/screens/components/spinner/specs/SpinnerSpecsPage.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { DxcTable, DxcFlex } from "@dxc-technology/halstack-react"; -import Image from "@/common/Image"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Figure from "@/common/Figure"; -import Code from "@/common/Code"; -import DocFooter from "@/common/DocFooter"; -import specsImage from "./images/spinner_specs.png"; - -const sections = [ - { - title: "Specifications", - content: ( -
- Spinner design specifications -
- ), - }, - { - title: "Design tokens", - subSections: [ - { - title: "Color", - content: ( - - - - Component token - Element - Core token - Value - - - - - - trackCircleColor - - Spinner circle (track) - - color-purple-700 - - #5f249f - - - - trackCircleColorOverlay - - Spinner circle (track) - - color-purple-500 - - #a46ede - - - - totalCircleColor - - Spinner circle (total) - - color-white - - #ffffff - - - - labelFontColor - - Label - - color-black - - #000000 - - - - progressValueFontColor - - Percentage - - color-black - - #000000 - - - - overlayBackgroundColor - - Overlay - - color-grey-800-a - - #000000b3 - - - - overlayLabelFontColor - - Overlay - - color-white - - #ffffff - - - - overlayProgressValueFontColor - - Overlay - - color-white - - #ffffff - - - - ), - }, - { - title: "Size", - content: ( - - - - Property - Element - Core token - Value - - - - - - width - - Spinner container (large) - - - 140px - - - - height - - Spinner container (large) - - - 140px - - - - width - - Spinner container (small) - - - 16px - - - - height - - Spinner container (small) - - - 16px - - - - max-width - - Overlay - - - 100vw - - - - max-height - - Overlay - - - 100vh - - - - ), - }, - { - title: "Typography", - content: ( - - - - Property - Element - Core token - Value - - - - - - font-size - - Loading label - - font-scale-02 - - 14px - - - - font-weight - - Loading label - - font-regular - - 400 - - - - font-size - - Percentage - - font-scale-02 - - 14px - - - - font-weight - - Percentage - - font-bold - - 700 - - - - ), - }, - { - title: "Border", - content: ( - - - - Property - Element - Core token - Value - - - - - - stroke - - Spinner circle (large) - - - 8.5px solid - - - - stroke - - Spinner circle (small) - - - 2px solid - - - - ), - }, - ], - }, -]; - -const SpinnerSpecsPage = () => { - return ( - - - - - - - ); -}; - -export default SpinnerSpecsPage; diff --git a/apps/website/screens/components/spinner/specs/images/spinner_specs.png b/apps/website/screens/components/spinner/specs/images/spinner_specs.png deleted file mode 100644 index 8955680da1..0000000000 Binary files a/apps/website/screens/components/spinner/specs/images/spinner_specs.png and /dev/null differ diff --git a/apps/website/screens/components/spinner/usage/SpinnerUsagePage.tsx b/apps/website/screens/components/spinner/usage/SpinnerUsagePage.tsx deleted file mode 100644 index 57d17f9cee..0000000000 --- a/apps/website/screens/components/spinner/usage/SpinnerUsagePage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { DxcParagraph, DxcBulletedList, DxcFlex } from "@dxc-technology/halstack-react"; -import Image from "@/common/Image"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Figure from "@/common/Figure"; -import Code from "@/common/Code"; -import DocFooter from "@/common/DocFooter"; -import Example from "@/common/example/Example"; -import determinateIndeterminate from "./examples/determinateIndeterminate"; -import variantsImage from "./images/spinner_variants.png"; - -const sections = [ - { - title: "Usage", - content: ( - - There should only be a single spinner on a page at one time. - - Only use the spinner component in a process that takes more than one second. - - The text of the action is not mandatory but recommendable. - - If only a portion of a page is displaying new content or being updated, use a medium or{" "} - small spinner in that part of the page. - - - ), - }, - { - title: "Variants", - content: ( - <> - - There are three different variants for the spinner component due to the size or the position:{" "} - large, small and overlay. - -
- Spinner variants -
- - ), - }, - { - title: "Determinate or indeterminate", - content: ( - <> - - - - Determinate indicators display how long a process will take. They should be used in longer processes. - - - - Indeterminate indicators express an unspecified amount of wait time. They should be used when: - - - The processing time is unknown. - - The wait time is expected to be short enough that it's not necessary to display. - - - - - - ), - }, -]; - -const SpinnerUsagePage = () => { - return ( - - - - - - - ); -}; - -export default SpinnerUsagePage; diff --git a/apps/website/screens/components/spinner/usage/images/spinner_variants.png b/apps/website/screens/components/spinner/usage/images/spinner_variants.png deleted file mode 100644 index 30e3c8194f..0000000000 Binary files a/apps/website/screens/components/spinner/usage/images/spinner_variants.png and /dev/null differ diff --git a/packages/lib/src/spinner/Spinner.stories.tsx b/packages/lib/src/spinner/Spinner.stories.tsx index 56c422086c..1499c9f792 100644 --- a/packages/lib/src/spinner/Spinner.stories.tsx +++ b/packages/lib/src/spinner/Spinner.stories.tsx @@ -1,85 +1,81 @@ import { Meta, StoryObj } from "@storybook/react"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; import Title from "../../.storybook/components/Title"; -import { HalstackProvider } from "../HalstackContext"; import DxcSpinner from "./Spinner"; +import { userEvent, within } from "@storybook/test"; export default { title: "Spinner", component: DxcSpinner, } as Meta; -const opinionatedTheme = { - spinner: { - accentColor: "#5f249f", - baseColor: "#ffffff", - fontColor: "#000000", - overlayColor: "#a46ede", - overlayFontColor: "#ffffff", - }, -}; - const Spinner = () => ( <> - <DxcSpinner label="Label" value={50}></DxcSpinner> + <DxcSpinner label="Label" value={50} /> + </ExampleContainer> + <ExampleContainer> <Title title="With value label" theme="light" level={4} /> - <DxcSpinner value={50} showValue></DxcSpinner> + <DxcSpinner value={50} showValue /> + </ExampleContainer> + <ExampleContainer> + <Title title="With value and label with ellipsis (triggers tooltip)" theme="light" level={4} /> + <DxcSpinner value={50} showValue label="Loading a full screen..." /> + </ExampleContainer> + <ExampleContainer> <Title title="With label and value label" theme="light" level={4} /> - <DxcSpinner label="Label" value={50} showValue></DxcSpinner> + <DxcSpinner label="Label" value={50} showValue /> + </ExampleContainer> + <ExampleContainer> <Title title="With 100%" theme="light" level={4} /> - <DxcSpinner label="Label" value={100} showValue></DxcSpinner> + <DxcSpinner label="Label" value={100} showValue /> </ExampleContainer> - <Title title="Modes" theme="light" level={2} /> <ExampleContainer> <Title title="Mode large" theme="light" level={4} /> - <DxcSpinner mode="large" value={50}></DxcSpinner> + <DxcSpinner mode="large" value={50} /> + </ExampleContainer> + <ExampleContainer> <Title title="Mode small" theme="light" level={4} /> - <DxcSpinner mode="small" value={50}></DxcSpinner> + <DxcSpinner mode="small" value={50} /> + </ExampleContainer> + <ExampleContainer> <Title title="Mode small with 100%" theme="light" level={4} /> - <DxcSpinner mode="small" value={100} showValue></DxcSpinner> + <DxcSpinner mode="small" value={100} showValue /> </ExampleContainer> <Title title="Margins with large mode" theme="light" level={2} /> <ExampleContainer> <Title title="Xxsmall margin" theme="light" level={4} /> - <DxcSpinner margin="xxsmall" value={75}></DxcSpinner> + <DxcSpinner margin="xxsmall" value={75} /> <Title title="Xsmall margin" theme="light" level={4} /> - <DxcSpinner margin="xsmall" value={75}></DxcSpinner> + <DxcSpinner margin="xsmall" value={75} /> <Title title="Small margin" theme="light" level={4} /> - <DxcSpinner margin="small" value={75}></DxcSpinner> + <DxcSpinner margin="small" value={75} /> <Title title="Medium margin" theme="light" level={4} /> - <DxcSpinner margin="medium" value={75}></DxcSpinner> + <DxcSpinner margin="medium" value={75} /> <Title title="Large margin" theme="light" level={4} /> - <DxcSpinner margin="large" value={75}></DxcSpinner> + <DxcSpinner margin="large" value={75} /> <Title title="Xlarge margin" theme="light" level={4} /> - <DxcSpinner margin="xlarge" value={75}></DxcSpinner> + <DxcSpinner margin="xlarge" value={75} /> <Title title="Xxlarge margin" theme="light" level={4} /> - <DxcSpinner margin="xxlarge" value={75}></DxcSpinner> + <DxcSpinner margin="xxlarge" value={75} /> </ExampleContainer> <Title title="Margins with small mode" theme="light" level={2} /> <ExampleContainer> <Title title="Xxsmall margin" theme="light" level={4} /> - <DxcSpinner margin="xxsmall" mode="small" value={75}></DxcSpinner> + <DxcSpinner margin="xxsmall" mode="small" value={75} /> <Title title="Xsmall margin" theme="light" level={4} /> - <DxcSpinner margin="xsmall" mode="small" value={75}></DxcSpinner> + <DxcSpinner margin="xsmall" mode="small" value={75} /> <Title title="Small margin" theme="light" level={4} /> - <DxcSpinner margin="small" mode="small" value={75}></DxcSpinner> + <DxcSpinner margin="small" mode="small" value={75} /> <Title title="Medium margin" theme="light" level={4} /> - <DxcSpinner margin="medium" mode="small" value={75}></DxcSpinner> + <DxcSpinner margin="medium" mode="small" value={75} /> <Title title="Large margin" theme="light" level={4} /> - <DxcSpinner margin="large" mode="small" value={75}></DxcSpinner> + <DxcSpinner margin="large" mode="small" value={75} /> <Title title="Xlarge margin" theme="light" level={4} /> - <DxcSpinner margin="xlarge" mode="small" value={75}></DxcSpinner> + <DxcSpinner margin="xlarge" mode="small" value={75} /> <Title title="Xxlarge margin" theme="light" level={4} /> - <DxcSpinner margin="xxlarge" mode="small" value={75}></DxcSpinner> - </ExampleContainer> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer> - <Title title="With label and value label" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSpinner label="Label" value={50} showValue></DxcSpinner> - </HalstackProvider> + <DxcSpinner margin="xxlarge" mode="small" value={75} /> </ExampleContainer> </> ); @@ -87,44 +83,35 @@ const Spinner = () => ( const SpinnerWithOverlay = () => ( <ExampleContainer> <Title title="Mode overlay" theme="light" level={4} /> - <DxcSpinner mode="overlay" value={25}></DxcSpinner> + <DxcSpinner mode="overlay" value={25} /> </ExampleContainer> ); const SpinnerOverlay100 = () => ( <ExampleContainer> <Title title="Mode overlay" theme="light" level={4} /> - <DxcSpinner mode="overlay" value={100}></DxcSpinner> + <DxcSpinner mode="overlay" value={100} /> </ExampleContainer> ); const SpinnerOverlayLabel = () => ( <ExampleContainer> <Title title="Mode overlay" theme="light" level={4} /> - <DxcSpinner mode="overlay" value={50} label="Label"></DxcSpinner> + <DxcSpinner mode="overlay" value={50} label="Label" /> </ExampleContainer> ); const SpinnerOverlayValue = () => ( <ExampleContainer> <Title title="Mode overlay" theme="light" level={4} /> - <DxcSpinner mode="overlay" value={50} showValue></DxcSpinner> + <DxcSpinner mode="overlay" value={50} showValue /> </ExampleContainer> ); const SpinnerOverlayValueAndLabel = () => ( <ExampleContainer> <Title title="Mode overlay" theme="light" level={4} /> - <DxcSpinner mode="overlay" label="Label" value={50} showValue></DxcSpinner> - </ExampleContainer> -); - -const SpinnerOverlayValueAndLabelOpinionated = () => ( - <ExampleContainer> - <HalstackProvider theme={opinionatedTheme}> - <Title title="Mode overlay" theme="light" level={4} /> - <DxcSpinner mode="overlay" label="Label" value={50} showValue></DxcSpinner> - </HalstackProvider> + <DxcSpinner mode="overlay" label="Label" value={50} showValue /> </ExampleContainer> ); @@ -132,8 +119,12 @@ type Story = StoryObj<typeof DxcSpinner>; export const Chromatic: Story = { render: Spinner, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.hover(canvas.getByText("Loading a full screen...")); + await userEvent.hover(canvas.getByText("Loading a full screen...")); + }, }; - export const SpinnerOverlay: Story = { render: SpinnerWithOverlay, }; @@ -143,13 +134,9 @@ export const SpinnerOverlayWith100: Story = { export const SpinnerOverlayWithLabel: Story = { render: SpinnerOverlayLabel, }; - export const SpinnerOverlayWithValue: Story = { render: SpinnerOverlayValue, }; export const SpinnerOverlayWithValueAndLabel: Story = { render: SpinnerOverlayValueAndLabel, }; -export const SpinnerOverlayWithValueAndLabelOpinionated: Story = { - render: SpinnerOverlayValueAndLabelOpinionated, -}; diff --git a/packages/lib/src/spinner/Spinner.test.tsx b/packages/lib/src/spinner/Spinner.test.tsx index 99501a7090..b1a5f7c3b5 100644 --- a/packages/lib/src/spinner/Spinner.test.tsx +++ b/packages/lib/src/spinner/Spinner.test.tsx @@ -6,12 +6,10 @@ describe("Spinner component tests", () => { const { getByText } = render(<DxcSpinner label="test-loading"></DxcSpinner>); expect(getByText("test-loading")).toBeTruthy(); }); - test("Spinner shows value correctly", () => { const { getByText } = render(<DxcSpinner label="test-loading" value={75} showValue></DxcSpinner>); expect(getByText("75%")).toBeTruthy(); }); - test("Small spinner hides value and label correctly", () => { const { queryByText, getByRole } = render( <DxcSpinner mode="small" label="test-loading" value={75} showValue></DxcSpinner> @@ -20,25 +18,21 @@ describe("Spinner component tests", () => { expect(queryByText("75%")).toBeFalsy(); expect(getByRole("progressbar").getAttribute("aria-label")).toBe("Spinner"); }); - test("Overlay spinner shows value and label correctly", () => { const { getByText } = render(<DxcSpinner mode="overlay" label="test-loading" value={75} showValue></DxcSpinner>); expect(getByText("test-loading")).toBeTruthy(); expect(getByText("75%")).toBeTruthy(); }); - test("Get spinner by role", () => { const { getByRole } = render(<DxcSpinner label="test-loading" value={75} showValue></DxcSpinner>); expect(getByRole("progressbar")).toBeTruthy(); }); - test("Test spinner aria-label to be undefined", () => { const { getByRole } = render(<DxcSpinner label="test-loading" value={75} showValue></DxcSpinner>); const spinner = getByRole("progressbar"); expect(spinner.getAttribute("aria-label")).toBeNull(); expect(spinner.getAttribute("aria-labelledby")).toBeTruthy(); }); - test("Test spinner aria-label to be applied correctly when mode is small", () => { const { getByRole } = render( <DxcSpinner label="test-loading" ariaLabel="Example aria label" value={75} mode="small" showValue></DxcSpinner> diff --git a/packages/lib/src/spinner/Spinner.tsx b/packages/lib/src/spinner/Spinner.tsx index dc24d6f3a6..d2bfb1fb63 100644 --- a/packages/lib/src/spinner/Spinner.tsx +++ b/packages/lib/src/spinner/Spinner.tsx @@ -1,96 +1,24 @@ -import { useContext, useId, useMemo } from "react"; -import styled, { ThemeProvider } from "styled-components"; +import { MouseEvent, useId, useMemo, useState } from "react"; +import styled from "styled-components"; import { spaces } from "../common/variables"; -import HalstackContext from "../HalstackContext"; import SpinnerPropsType from "./types"; +import { TooltipWrapper } from "../tooltip/Tooltip"; -const DxcSpinner = ({ - label, - value, - showValue = false, - mode = "large", - margin, - ariaLabel = "Spinner", -}: SpinnerPropsType): JSX.Element => { - const labelId = useId(); - const colorsTheme = useContext(HalstackContext); - const determinated = useMemo(() => value != null && value >= 0 && value <= 100, [value]); - - return ( - <ThemeProvider theme={colorsTheme.spinner}> - <DXCSpinner margin={margin} mode={mode}> - <SpinnerContainer mode={mode}> - {mode === "overlay" && <BackOverlay />} - <BackgroundSpinner> - {mode === "small" ? ( - <SVGBackground viewBox="0 0 16 16"> - <CircleBackground cx="8" cy="8" r="6" mode={mode} /> - </SVGBackground> - ) : ( - <SVGBackground viewBox="0 0 140 140"> - <CircleBackground cx="70" cy="70" r="65" mode={mode} /> - </SVGBackground> - )} - </BackgroundSpinner> - <Spinner - role="progressbar" - aria-valuenow={determinated && showValue ? value : undefined} - aria-valuemin={determinated ? 0 : undefined} - aria-valuemax={determinated ? 100 : undefined} - aria-labelledby={label && mode !== "small" ? labelId : undefined} - aria-label={!label ? ariaLabel : mode === "small" ? ariaLabel : undefined} - > - {mode === "small" ? ( - <SVGSpinner viewBox="0 0 16 16" determinated={determinated}> - <CircleSpinner cx="8" cy="8" r="6" mode={mode} determinated={determinated} value={value} /> - </SVGSpinner> - ) : ( - <SVGSpinner viewBox="0 0 140 140" determinated={determinated}> - <CircleSpinner cx="70" cy="70" r="65" mode={mode} determinated={determinated} value={value} /> - </SVGSpinner> - )} - </Spinner> - {mode !== "small" && ( - <LabelsContainer> - {label && ( - <SpinnerLabel id={labelId} mode={mode}> - {label} - </SpinnerLabel> - )} - {(value || value === 0) && showValue && ( - <SpinnerProgress value={value} mode={mode} showValue={showValue}> - {value}% - </SpinnerProgress> - )} - </LabelsContainer> - )} - </SpinnerContainer> - </DXCSpinner> - </ThemeProvider> - ); -}; - -const determinateValue = (value: SpinnerPropsType["value"], strokeDashArray: number) => { - let val = 0; - if (value != null && value >= 0 && value <= 100) { - val = strokeDashArray * (1 - value / 100); - } - return val; -}; - -const DXCSpinner = styled.div<{ - mode: SpinnerPropsType["mode"]; +const SpinnerContainer = styled.div<{ margin: SpinnerPropsType["margin"]; + mode: SpinnerPropsType["mode"]; }>` - height: ${(props) => (props.mode === "overlay" ? "100vh" : "")}; - width: ${(props) => (props.mode === "overlay" ? "100vw" : "")}; - display: ${(props) => (props.mode === "overlay" ? "flex" : "")}; - position: ${(props) => (props.mode === "overlay" ? "fixed" : "")}; - top: ${(props) => (props.mode === "overlay" ? 0 : "")}; - left: ${(props) => (props.mode === "overlay" ? 0 : "")}; - justify-content: ${(props) => (props.mode === "overlay" ? "center" : "")}; - align-items: ${(props) => (props.mode === "overlay" ? "center" : "")}; - z-index: ${(props) => (props.mode === "overlay" ? 1300 : "")}; + ${({ mode }) => + mode === "overlay" && + ` + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + z-index: 2147483647; + `}; margin: ${(props) => props.mode !== "overlay" ? (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px") : ""}; @@ -120,14 +48,12 @@ const DXCSpinner = styled.div<{ : ""}; `; -const SpinnerContainer = styled.div<{ mode: SpinnerPropsType["mode"] }>` - align-items: center; - display: flex; - height: ${(props) => (props.mode === "small" ? "16px" : "140px")}; - width: ${(props) => (props.mode === "small" ? "16px" : "140px")}; - justify-content: center; +const MainContainer = styled.div<{ mode: SpinnerPropsType["mode"] }>` position: relative; - background-color: transparent; + display: grid; + place-items: center; + height: ${({ mode }) => (mode === "small" ? "16px" : "140px")}; + width: ${({ mode }) => (mode === "small" ? "16px" : "140px")}; @keyframes spinner-svg { 0% { @@ -142,12 +68,10 @@ const SpinnerContainer = styled.div<{ mode: SpinnerPropsType["mode"] }>` stroke-dashoffset: 400; transform: rotate(0); } - 50% { stroke-dashoffset: 75; transform: rotate(45deg); } - 100% { stroke-dashoffset: 400; transform: rotate(360deg); @@ -158,12 +82,10 @@ const SpinnerContainer = styled.div<{ mode: SpinnerPropsType["mode"] }>` stroke-dashoffset: 35; transform: rotate(0); } - 50% { stroke-dashoffset: 8; transform: rotate(45deg); } - 100% { stroke-dashoffset: 35; transform: rotate(360deg); @@ -171,33 +93,23 @@ const SpinnerContainer = styled.div<{ mode: SpinnerPropsType["mode"] }>` } `; -const BackOverlay = styled.div` - width: 100vw; - height: 100vh; - opacity: 1; - transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; +const Overlay = styled.div` position: fixed; - top: 0; - left: 0; - background-color: ${(props) => `${props.theme.overlayBackgroundColor}`}; - opacity: ${(props) => `${props.theme.overlayOpacity}`}; + inset: 0; + height: 100%; + background-color: var(--color-bg-alpha-medium); `; -const BackgroundSpinner = styled.div` - height: inherit; - width: inherit; +const SVGTotalTrack = styled.svg` position: absolute; -`; - -const SVGBackground = styled.svg` height: inherit; width: inherit; `; -const CircleBackground = styled.circle<{ mode: SpinnerPropsType["mode"] }>` +const TotalTrack = styled.circle<{ mode: SpinnerPropsType["mode"] }>` animation: none; fill: transparent; - stroke: ${(props) => `${props.theme.totalCircleColor}`}; + stroke: var(--color-bg-neutral-lightest); stroke-dasharray: ${(props) => (props.mode !== "small" ? "409" : "38")}; stroke-linecap: initial; stroke-width: ${(props) => (props.mode !== "small" ? "8.5px" : "2px")}; @@ -206,12 +118,12 @@ const CircleBackground = styled.circle<{ mode: SpinnerPropsType["mode"] }>` `; const Spinner = styled.div` + position: relative; height: inherit; width: inherit; - position: relative; `; -const SVGSpinner = styled.svg<{ determinated: boolean }>` +const SVGSpinner = styled.svg<{ determined: boolean }>` height: inherit; width: inherit; transform: rotate(-90deg); @@ -219,84 +131,111 @@ const SVGSpinner = styled.svg<{ determinated: boolean }>` left: 0; transform-origin: center; overflow: visible; - animation: ${(props) => (!props.determinated ? "1.4s linear infinite both spinner-svg" : "")}; + animation: ${({ determined }) => !determined && "1.4s linear infinite both spinner-svg"}; `; +const determinateValue = (value: SpinnerPropsType["value"], strokeDashArray: number) => + value != null && value >= 0 && value <= 100 ? strokeDashArray * (1 - value / 100) : 0; + const CircleSpinner = styled.circle<{ + determined: boolean; value: SpinnerPropsType["value"]; - determinated: boolean; }>` fill: transparent; stroke-linecap: initial; vector-effect: non-scaling-stroke; animation: ${(props) => - props.determinated + props.determined ? "none" : props.mode !== "small" ? "1.4s ease-in-out infinite both svg-circle-large" : "1.4s ease-in-out infinite both svg-circle-small"}; - stroke: ${(props) => (props.mode === "overlay" ? props.theme.trackCircleColorOverlay : props.theme.trackCircleColor)}; - transform-origin: ${(props) => (!props.determinated ? "50% 50%" : "")}; - stroke-dasharray: ${(props) => (props.mode !== "small" ? "409" : "38")}; - stroke-width: ${(props) => (props.mode !== "small" ? "8.5px" : "2px")}; - stroke-dashoffset: ${(props) => - props.determinated - ? props.mode !== "small" - ? determinateValue(props.value, 409) - : determinateValue(props.value, 38) - : ""}; + stroke: ${({ mode }) => (mode === "overlay" ? "var(--color-fg-primary-medium)" : "var(--color-fg-primary-strong)")}; + transform-origin: ${({ determined }) => (!determined ? "50% 50%" : "")}; + stroke-dasharray: ${({ mode }) => (mode !== "small" ? "409" : "38")}; + stroke-width: ${({ mode }) => (mode !== "small" ? "8.5px" : "2px")}; + stroke-dashoffset: ${({ determined, mode, value }) => + determined ? (mode !== "small" ? determinateValue(value, 409) : determinateValue(value, 38)) : ""}; `; -const LabelsContainer = styled.div` +const Labels = styled.div<{ mode: SpinnerPropsType["mode"] }>` position: absolute; - margin: 0 auto; - width: 110px; - text-align: center; + display: grid; + gap: var(--spacing-gap-none, 0px); + place-items: center; + width: 116px; + color: ${({ mode }) => (mode === "overlay" ? "var(--color-fg-neutral-bright)" : "var(--color-fg-neutral-dark)")}; + font-family: var(--typography-font-family); + font-size: var(--typography-helper-text-m); + font-weight: var(--typography-helper-text-regular); + + > span { + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + > strong { + font-weight: var(--typography-helper-text-semibold); + } `; -const SpinnerLabel = styled.p<{ mode: SpinnerPropsType["mode"] }>` - margin: 0; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-family: ${(props) => - props.mode === "overlay" ? props.theme.overlayLabelFontFamily : props.theme.labelFontFamily}; - font-weight: ${(props) => - props.mode === "overlay" ? props.theme.overlayLabelFontWeight : props.theme.labelFontWeight}; - font-size: ${(props) => (props.mode === "overlay" ? props.theme.overlayLabelFontSize : props.theme.labelFontSize)}; - font-style: ${(props) => (props.mode === "overlay" ? props.theme.overlayLabelFontStyle : props.theme.labelFontStyle)}; - color: ${(props) => (props.mode === "overlay" ? props.theme.overlayLabelFontColor : props.theme.labelFontColor)}; - text-align: ${(props) => (props.mode === "overlay" ? props.theme.overlayLabelTextAlign : props.theme.labelTextAlign)}; - letter-spacing: ${(props) => - props.mode === "overlay" ? props.theme.overlayLabelLetterSpacing : props.theme.labelLetterSpacing}; -`; +const DxcSpinner = ({ ariaLabel = "Spinner", label, margin, mode = "large", showValue, value }: SpinnerPropsType) => { + const labelId = useId(); + const determined = useMemo(() => value != null && value >= 0 && value <= 100, [value]); + const [hasTooltip, setHasTooltip] = useState(false); -const SpinnerProgress = styled.p<{ - value: SpinnerPropsType["value"]; - showValue: SpinnerPropsType["showValue"]; - mode: SpinnerPropsType["mode"]; -}>` - margin: 0; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: ${(props) => (props.value && props.showValue === true && "block") || "none"}; - font-family: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueFontFamily : props.theme.progressValueFontFamily}; - font-weight: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueFontWeight : props.theme.progressValueFontWeight}; - font-size: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueFontSize : props.theme.progressValueFontSize}; - font-style: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueFontStyle : props.theme.progressValueFontStyle}; - color: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueFontColor : props.theme.progressValueFontColor}; - text-align: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueTextAlign : props.theme.progressValueTextAlign}; - letter-spacing: ${(props) => - props.mode === "overlay" ? props.theme.overlayProgressValueLetterSpacing : props.theme.progressValueLetterSpacing}; -`; + const handleLabelOnMouseEnter = (event: MouseEvent<HTMLSpanElement>) => { + const text = event.currentTarget; + setHasTooltip(text.scrollWidth > text.clientWidth); + }; + + return ( + <SpinnerContainer margin={margin} mode={mode}> + <MainContainer mode={mode}> + {mode === "overlay" && <Overlay />} + <SVGTotalTrack viewBox={mode === "small" ? "0 0 16 16" : "0 0 140 140"}> + <TotalTrack + cx={mode === "small" ? "8" : "70"} + cy={mode === "small" ? "8" : "70"} + mode={mode} + r={mode === "small" ? "6" : "65"} + /> + </SVGTotalTrack> + <Spinner + aria-label={!label || mode === "small" ? ariaLabel : undefined} + aria-labelledby={label && mode !== "small" ? labelId : undefined} + aria-valuemax={determined ? 100 : undefined} + aria-valuemin={determined ? 0 : undefined} + aria-valuenow={determined && showValue ? value : undefined} + role={determined ? "progressbar" : "status"} + > + <SVGSpinner determined={determined} viewBox={mode === "small" ? "0 0 16 16" : "0 0 140 140"}> + <CircleSpinner + cx={mode === "small" ? "8" : "70"} + cy={mode === "small" ? "8" : "70"} + determined={determined} + mode={mode} + r={mode === "small" ? "6" : "65"} + value={value} + /> + </SVGSpinner> + </Spinner> + {mode !== "small" && ( + <TooltipWrapper condition={hasTooltip} label={label}> + <Labels mode={mode}> + {label && ( + <span id={labelId} onMouseEnter={handleLabelOnMouseEnter}> + {label} + </span> + )} + {(value || value === 0) && showValue && <strong>{value}%</strong>} + </Labels> + </TooltipWrapper> + )} + </MainContainer> + </SpinnerContainer> + ); +}; export default DxcSpinner;