diff --git a/.changeset/clever-pets-arrive.md b/.changeset/clever-pets-arrive.md new file mode 100644 index 0000000000..eaef3778a6 --- /dev/null +++ b/.changeset/clever-pets-arrive.md @@ -0,0 +1,6 @@ +--- +"@heroui/spinner": patch +"@heroui/theme": patch +--- + +Adding variants to the Spinner Component. diff --git a/apps/docs/content/components/spinner/index.ts b/apps/docs/content/components/spinner/index.ts index dfeed2504a..40b774df96 100644 --- a/apps/docs/content/components/spinner/index.ts +++ b/apps/docs/content/components/spinner/index.ts @@ -3,6 +3,7 @@ import sizes from "./sizes"; import colors from "./colors"; import label from "./label"; import labelColors from "./label-colors"; +import variants from "./variants"; export const spinnerContent = { usage, @@ -10,4 +11,5 @@ export const spinnerContent = { colors, label, labelColors, + variants, }; diff --git a/apps/docs/content/components/spinner/variants.raw.jsx b/apps/docs/content/components/spinner/variants.raw.jsx new file mode 100644 index 0000000000..a14df3ea0f --- /dev/null +++ b/apps/docs/content/components/spinner/variants.raw.jsx @@ -0,0 +1,13 @@ +import {Spinner} from "@heroui/react"; + +export default function App() { + return ( +
+ + + + + +
+ ); +} diff --git a/apps/docs/content/components/spinner/variants.ts b/apps/docs/content/components/spinner/variants.ts new file mode 100644 index 0000000000..ddea95fb2e --- /dev/null +++ b/apps/docs/content/components/spinner/variants.ts @@ -0,0 +1,9 @@ +import App from "./variants.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/api-references/heroui-provider.mdx b/apps/docs/content/docs/api-references/heroui-provider.mdx index 50eb7f1c41..dcb6449928 100644 --- a/apps/docs/content/docs/api-references/heroui-provider.mdx +++ b/apps/docs/content/docs/api-references/heroui-provider.mdx @@ -197,6 +197,15 @@ The available options are: - **Type**: `"user" | "always" | "never"` - **Default**: `"never"` +`spinnerVariant` + +- **Description**: The default variant of the spinner. +- **Type**: `string` | `undefined` +- **Possible Values**: `default` | `gradient` | `wave` | `dots` | `spinner` +- **Default**: `default` + + + --- ## Types diff --git a/apps/docs/content/docs/components/spinner.mdx b/apps/docs/content/docs/components/spinner.mdx index f836c4187a..9e3f0189d1 100644 --- a/apps/docs/content/docs/components/spinner.mdx +++ b/apps/docs/content/docs/components/spinner.mdx @@ -60,12 +60,18 @@ Spinner express an unspecified wait time or display the length of a process. +### Variants + + + ## Slots - **base**: The base slot of the spinner, it wraps the circles and the label. - **wrapper**: The wrapper of the circles. -- **circle1**: The first circle of the spinner. -- **circle2**: The second circle of the spinner. +- **circle1**: The first circle of the spinner component. (Effective only when variant is `default` or `gradient`) +- **circle2**: The second circle of the spinner component. (Effective only when variant is `default` or `gradient`) +- **dots**: Dots of the spinner component. (Effective only when variant is `wave` or `dots`) +- **spinnerBars**: Bars of the spinner component. (Effective only when variant is `spinner`) - **label**: The label content. @@ -94,6 +100,12 @@ Spinner express an unspecified wait time or display the length of a process. description: "The color of the spinner circles.", default: "primary" }, + { + attribute: "variant", + type: "default | gradient | wave | dots | spinner", + description: "The variant of the spinner", + default: "default" + }, { attribute: "labelColor", type: "default | primary | secondary | success | warning | danger", @@ -102,7 +114,7 @@ Spinner express an unspecified wait time or display the length of a process. }, { attribute: "classNames", - type: "Partial>", + type: "Partial>", description: "Allows to set custom class names for the spinner slots.", default: "-" } diff --git a/packages/components/spinner/package.json b/packages/components/spinner/package.json index a67ee25b02..429e4b4b7f 100644 --- a/packages/components/spinner/package.json +++ b/packages/components/spinner/package.json @@ -38,12 +38,13 @@ "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0", - "@heroui/theme": ">=2.4.0" + "@heroui/theme": ">=2.4.7" }, "dependencies": { "@heroui/system-rsc": "workspace:*", "@heroui/shared-utils": "workspace:*", - "@heroui/react-utils": "workspace:*" + "@heroui/react-utils": "workspace:*", + "@heroui/system": "workspace:*" }, "devDependencies": { "@heroui/theme": "workspace:*", diff --git a/packages/components/spinner/src/spinner.tsx b/packages/components/spinner/src/spinner.tsx index 55e6955665..9facf7f5a4 100644 --- a/packages/components/spinner/src/spinner.tsx +++ b/packages/components/spinner/src/spinner.tsx @@ -5,7 +5,49 @@ import {UseSpinnerProps, useSpinner} from "./use-spinner"; export interface SpinnerProps extends UseSpinnerProps {} const Spinner = forwardRef<"div", SpinnerProps>((props, ref) => { - const {slots, classNames, label, getSpinnerProps} = useSpinner({...props}); + const {slots, classNames, label, variant, getSpinnerProps} = useSpinner({...props}); + + if (variant === "wave" || variant === "dots") { + return ( +
+
+ {[...new Array(3)].map((_, index) => ( + + ))} +
+ {label && {label}} +
+ ); + } + + if (variant === "spinner") { + return ( +
+
+ {[...new Array(12)].map((_, index) => ( + + ))} +
+ {label && {label}} +
+ ); + } return (
diff --git a/packages/components/spinner/src/use-spinner.ts b/packages/components/spinner/src/use-spinner.ts index 6283e95d7a..4eed495876 100644 --- a/packages/components/spinner/src/use-spinner.ts +++ b/packages/components/spinner/src/use-spinner.ts @@ -5,6 +5,7 @@ import {mapPropsVariants} from "@heroui/system-rsc"; import {spinner} from "@heroui/theme"; import {clsx, objectToDeps} from "@heroui/shared-utils"; import {useMemo, useCallback, Ref} from "react"; +import {useProviderContext} from "@heroui/system"; interface Props extends HTMLHeroUIProps<"div"> { /** @@ -38,6 +39,9 @@ export type UseSpinnerProps = Props & SpinnerVariantProps; export function useSpinner(originalProps: UseSpinnerProps) { const [props, variantProps] = mapPropsVariants(originalProps, spinner.variantKeys); + const globalContext = useProviderContext(); + const variant = originalProps?.variant ?? globalContext?.spinnerVariant ?? "default"; + const {children, className, classNames, label: labelProp, ...otherProps} = props; const slots = useMemo(() => spinner({...variantProps}), [objectToDeps(variantProps)]); @@ -65,7 +69,7 @@ export function useSpinner(originalProps: UseSpinnerProps) { [ariaLabel, slots, baseStyles, otherProps], ); - return {label, slots, classNames, getSpinnerProps}; + return {label, slots, classNames, variant, getSpinnerProps}; } export type UseSpinnerReturn = ReturnType; diff --git a/packages/components/spinner/stories/spinner.stories.tsx b/packages/components/spinner/stories/spinner.stories.tsx index 323698f250..de9c704632 100644 --- a/packages/components/spinner/stories/spinner.stories.tsx +++ b/packages/components/spinner/stories/spinner.stories.tsx @@ -2,7 +2,7 @@ import React from "react"; import {Meta} from "@storybook/react"; import {spinner} from "@heroui/theme"; -import {Spinner} from "../src"; +import {Spinner, SpinnerProps} from "../src"; export default { title: "Components/Spinner", @@ -26,6 +26,12 @@ export default { }, options: ["sm", "md", "lg"], }, + variant: { + control: { + type: "select", + }, + options: ["default", "gradient", "spinner", "wave", "dots"], + }, }, decorators: [ (Story) => ( @@ -40,6 +46,18 @@ const defaultProps = { ...spinner.defaultVariants, }; +const VariantsTemplate = (args: SpinnerProps) => { + return ( +
+ + + + + +
+ ); +}; + export const Default = { args: { ...defaultProps, @@ -52,3 +70,14 @@ export const WithLabel = { label: "Loading...", }, }; + +export const Variants = { + args: { + ...defaultProps, + classNames: { + label: "text-primary-400 mt-4", + }, + }, + + render: VariantsTemplate, +}; diff --git a/packages/core/system/src/provider-context.ts b/packages/core/system/src/provider-context.ts index 45e8e2e1fd..bffdbd3ec5 100644 --- a/packages/core/system/src/provider-context.ts +++ b/packages/core/system/src/provider-context.ts @@ -1,4 +1,4 @@ -import type {SupportedCalendars} from "./types"; +import type {SpinnerVariants, SupportedCalendars} from "./types"; import type {Calendar} from "@internationalized/date"; import type {DateValue} from "@react-types/datepicker"; @@ -87,6 +87,11 @@ export type ProviderContextProps = { * @default all calendars */ createCalendar?: (calendar: SupportedCalendars) => Calendar | null; + /** + * The default variant of the spinner. + * @default default + */ + spinnerVariant?: SpinnerVariants; }; export const [ProviderContext, useProviderContext] = createContext({ diff --git a/packages/core/system/src/provider.tsx b/packages/core/system/src/provider.tsx index ac70161702..84726cddff 100644 --- a/packages/core/system/src/provider.tsx +++ b/packages/core/system/src/provider.tsx @@ -63,6 +63,7 @@ export const HeroUIProvider: React.FC = ({ // then they will be set in `use-date-input.ts` or `use-calendar-base.ts` defaultDates, createCalendar, + spinnerVariant, ...otherProps }) => { let contents = children; @@ -87,6 +88,7 @@ export const HeroUIProvider: React.FC = ({ disableRipple, validationBehavior, labelPlacement, + spinnerVariant, }; }, [ createCalendar, @@ -96,6 +98,7 @@ export const HeroUIProvider: React.FC = ({ disableRipple, validationBehavior, labelPlacement, + spinnerVariant, ]); return ( diff --git a/packages/core/system/src/types.ts b/packages/core/system/src/types.ts index a8bdae8bd1..66fcfa5ee1 100644 --- a/packages/core/system/src/types.ts +++ b/packages/core/system/src/types.ts @@ -15,3 +15,8 @@ export type SupportedCalendars = | "persian" | "roc" | "gregory"; + +/** + * Spinner Variants + */ +export type SpinnerVariants = "default" | "gradient" | "wave" | "dots" | "spinner"; diff --git a/packages/core/theme/src/animations/index.ts b/packages/core/theme/src/animations/index.ts index 9b80f09f01..a47304d4b5 100644 --- a/packages/core/theme/src/animations/index.ts +++ b/packages/core/theme/src/animations/index.ts @@ -3,6 +3,9 @@ export const animations = { "drip-expand": "drip-expand 420ms linear", "spinner-ease-spin": "spinner-spin 0.8s ease infinite", "spinner-linear-spin": "spinner-spin 0.8s linear infinite", + sway: "sway 750ms ease infinite", + blink: "blink 1.4s infinite both", + "fade-out": "fade-out 1.2s linear 0s infinite normal none running", "appearance-in": "appearance-in 250ms ease-out normal both", "appearance-out": "appearance-out 60ms ease-in normal both", "indeterminate-bar": @@ -67,5 +70,35 @@ export const animations = { transform: "translateX(100%) scaleX(1)", }, }, + sway: { + "0%": { + transform: "translate(0px, 0px)", + }, + "50%": { + transform: "translate(0px, -150%)", + }, + "100%": { + transform: "translate(0px, 0px)", + }, + }, + blink: { + "0%": { + opacity: "0.2", + }, + "20%": { + opacity: "1", + }, + "100%": { + opacity: "0.2", + }, + }, + "fade-out": { + "0%": { + opacity: "1", + }, + "100%": { + opacity: "0.15", + }, + }, }, }; diff --git a/packages/core/theme/src/components/spinner.ts b/packages/core/theme/src/components/spinner.ts index 8173804b29..83a1a4f7ec 100644 --- a/packages/core/theme/src/components/spinner.ts +++ b/packages/core/theme/src/components/spinner.ts @@ -18,32 +18,20 @@ const spinner = tv({ slots: { base: "relative inline-flex flex-col gap-2 items-center justify-center", wrapper: "relative flex", - circle1: [ - "absolute", - "w-full", - "h-full", - "rounded-full", - "animate-spinner-ease-spin", - "border-2", - "border-solid", - "border-t-transparent", - "border-l-transparent", - "border-r-transparent", - ], - circle2: [ + label: "text-foreground dark:text-foreground-dark font-regular", + circle1: "absolute w-full h-full rounded-full", + circle2: "absolute w-full h-full rounded-full", + dots: "relative rounded-full mx-auto", + spinnerBars: [ "absolute", - "w-full", - "h-full", + "animate-fade-out", "rounded-full", - "opacity-75", - "animate-spinner-linear-spin", - "border-2", - "border-dotted", - "border-t-transparent", - "border-l-transparent", - "border-r-transparent", + "w-[25%]", + "h-[8%]", + "left-[calc(37.5%)]", + "top-[calc(46%)]", + "spinner-bar-animation", ], - label: "text-foreground dark:text-foreground-dark font-regular", }, variants: { size: { @@ -51,18 +39,21 @@ const spinner = tv({ wrapper: "w-5 h-5", circle1: "border-2", circle2: "border-2", + dots: "size-1", label: "text-small", }, md: { wrapper: "w-8 h-8", circle1: "border-3", circle2: "border-3", + dots: "size-1.5", label: "text-medium", }, lg: { wrapper: "w-10 h-10", circle1: "border-3", circle2: "border-3", + dots: "size-2", label: "text-large", }, }, @@ -70,34 +61,50 @@ const spinner = tv({ current: { circle1: "border-b-current", circle2: "border-b-current", + dots: "bg-current", + spinnerBars: "bg-current", }, white: { circle1: "border-b-white", circle2: "border-b-white", + dots: "bg-white", + spinnerBars: "bg-white", }, default: { circle1: "border-b-default", circle2: "border-b-default", + dots: "bg-default", + spinnerBars: "bg-default", }, primary: { circle1: "border-b-primary", circle2: "border-b-primary", + dots: "bg-primary", + spinnerBars: "bg-primary", }, secondary: { circle1: "border-b-secondary", circle2: "border-b-secondary", + dots: "bg-secondary", + spinnerBars: "bg-secondary", }, success: { circle1: "border-b-success", circle2: "border-b-success", + dots: "bg-success", + spinnerBars: "bg-success", }, warning: { circle1: "border-b-warning", circle2: "border-b-warning", + dots: "bg-warning", + spinnerBars: "bg-warning", }, danger: { circle1: "border-b-danger", circle2: "border-b-danger", + dots: "bg-danger", + spinnerBars: "bg-danger", }, }, labelColor: { @@ -120,12 +127,106 @@ const spinner = tv({ label: "text-danger", }, }, + variant: { + default: { + circle1: [ + "animate-spinner-ease-spin", + "border-solid", + "border-t-transparent", + "border-l-transparent", + "border-r-transparent", + ], + circle2: [ + "opacity-75", + "animate-spinner-linear-spin", + "border-dotted", + "border-t-transparent", + "border-l-transparent", + "border-r-transparent", + ], + }, + gradient: { + circle1: [ + "border-0", + "bg-gradient-to-b", + "from-transparent", + "via-transparent", + "to-primary", + "animate-spinner-linear-spin", + "[animation-duration:1s]", + "[-webkit-mask:radial-gradient(closest-side,rgba(0,0,0,0.0)calc(100%-3px),rgba(0,0,0,1)calc(100%-3px))]", + ], + circle2: ["hidden"], + }, + wave: { + wrapper: "translate-y-3/4", + dots: ["animate-sway", "spinner-dot-animation"], + }, + dots: { + wrapper: "translate-y-2/4", + dots: ["animate-blink", "spinner-dot-blink-animation"], + }, + spinner: {}, + }, }, defaultVariants: { size: "md", color: "primary", labelColor: "foreground", + variant: "default", }, + compoundVariants: [ + {variant: "gradient", color: "current", class: {circle1: "to-current"}}, + {variant: "gradient", color: "white", class: {circle1: "to-white"}}, + {variant: "gradient", color: "default", class: {circle1: "to-default"}}, + {variant: "gradient", color: "primary", class: {circle1: "to-primary"}}, + {variant: "gradient", color: "secondary", class: {circle1: "to-secondary"}}, + {variant: "gradient", color: "success", class: {circle1: "to-success"}}, + {variant: "gradient", color: "warning", class: {circle1: "to-warning"}}, + {variant: "gradient", color: "danger", class: {circle1: "to-danger"}}, + { + variant: "wave", + size: "sm", + class: { + wrapper: "w-5 h-5", + }, + }, + { + variant: "wave", + size: "md", + class: { + wrapper: "w-8 h-8", + }, + }, + { + variant: "wave", + size: "lg", + class: { + wrapper: "w-12 h-12", + }, + }, + { + variant: "dots", + size: "sm", + class: { + wrapper: "w-5 h-5", + }, + }, + { + variant: "dots", + size: "md", + class: { + wrapper: "w-8 h-8", + }, + }, + { + variant: "dots", + size: "lg", + class: { + wrapper: "w-12 h-12", + }, + }, + ], }); export type SpinnerVariantProps = VariantProps; diff --git a/packages/core/theme/src/utilities/animation.ts b/packages/core/theme/src/utilities/animation.ts new file mode 100644 index 0000000000..2437f43290 --- /dev/null +++ b/packages/core/theme/src/utilities/animation.ts @@ -0,0 +1,13 @@ +export default { + /** Animation Utilities */ + ".spinner-bar-animation": { + "animation-delay": "calc(-1.2s + (0.1s * var(--bar-index)))", + transform: "rotate(calc(30deg * var(--bar-index)))translate(140%)", + }, + ".spinner-dot-animation": { + "animation-delay": "calc(250ms * var(--dot-index))", + }, + ".spinner-dot-blink-animation": { + "animation-delay": "calc(200ms * var(--dot-index))", + }, +}; diff --git a/packages/core/theme/src/utilities/index.ts b/packages/core/theme/src/utilities/index.ts index 0c0c2fd28c..7248844bc2 100644 --- a/packages/core/theme/src/utilities/index.ts +++ b/packages/core/theme/src/utilities/index.ts @@ -1,9 +1,11 @@ import transition from "./transition"; import custom from "./custom"; import scrollbarHide from "./scrollbar-hide"; +import animation from "./animation"; export const utilities = { ...custom, ...transition, ...scrollbarHide, + ...animation, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a59bc0532..a184cf9fc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2785,6 +2785,9 @@ importers: '@heroui/shared-utils': specifier: workspace:* version: link:../../utilities/shared-utils + '@heroui/system': + specifier: workspace:* + version: link:../../core/system '@heroui/system-rsc': specifier: workspace:* version: link:../../core/system-rsc