From bd1a3ac65b90445fd5b1fc3744ede21e42eeaa03 Mon Sep 17 00:00:00 2001 From: "jiye.kim" Date: Mon, 18 May 2026 14:03:25 +0900 Subject: [PATCH 1/2] NXT-13760: DotSpinner PoC Enact-DCO-1.0-Signed-off-by: Jiye Kim (jiye.kim@lge.com) --- DotSpinner/DotSpinner.js | 146 +++++++++++++ DotSpinner/DotSpinner.module.less | 203 ++++++++++++++++++ DotSpinner/package.json | 3 + samples/sampler/stories/default/DotSpinner.js | 46 ++++ 4 files changed, 398 insertions(+) create mode 100644 DotSpinner/DotSpinner.js create mode 100644 DotSpinner/DotSpinner.module.less create mode 100644 DotSpinner/package.json create mode 100644 samples/sampler/stories/default/DotSpinner.js diff --git a/DotSpinner/DotSpinner.js b/DotSpinner/DotSpinner.js new file mode 100644 index 000000000..615adab35 --- /dev/null +++ b/DotSpinner/DotSpinner.js @@ -0,0 +1,146 @@ +/** + * Provides a Limestone-themed three-dot indeterminate progress indicator. + * + * @example + * Loading... + * + * @module limestone/DotSpinner + * @exports DotSpinner + * @exports DotSpinnerBase + * @exports DotSpinnerDecorator + */ +import kind from '@enact/core/kind'; +import Pure from '@enact/ui/internal/Pure'; +import PropTypes from 'prop-types'; +import compose from 'ramda/src/compose'; + +import Marquee from '../Marquee'; +import Skinnable from '../Skinnable'; + +import componentCss from './DotSpinner.module.less'; + +/** + * A component that renders three animated dots. + * + * @class DotSpinnerBase + * @memberof limestone/DotSpinner + * @ui + * @public + */ +const DotSpinnerBase = kind({ + name: 'DotSpinner', + + propTypes: /** @lends limestone/DotSpinner.DotSpinnerBase.prototype */ { + /** + * Customizes the component by mapping the supplied collection of CSS class names to the + * corresponding internal elements and states of this component. + * + * The following classes are supported: + * + * * `dotSpinner` - The root component class + * + * @type {Object} + * @public + */ + css: PropTypes.object, + + /** + * Pauses the animation. + * + * @type {Boolean} + * @default false + * @public + */ + paused: PropTypes.bool, + + /** + * The size of the spinner. + * + * @type {('medium'|'small')} + * @default 'medium' + * @public + */ + size: PropTypes.oneOf(['medium', 'small']), + + /** + * Removes the background color (making it transparent). + * + * @type {Boolean} + * @default false + * @public + */ + transparent: PropTypes.bool + }, + + defaultProps: { + paused: false, + size: 'medium', + transparent: false + }, + + styles: { + css: componentCss, + className: 'dotSpinner', + publicClassNames: ['dotSpinner'] + }, + + computed: { + 'aria-label': ({['aria-label']: aria, children}) => aria || (!children ? 'Loading' : undefined), + className: ({children, paused, size, transparent, styler}) => + styler.append(size, {content: !!children, paused, transparent}) + }, + + render: ({children, css, ...rest}) => { + delete rest.paused; + delete rest.size; + delete rest.transparent; + + return ( +
+
+
+
+
+
+ {children ? + + {children} + : + null + } +
+ ); + } +}); + +/** + * Limestone-specific DotSpinner behaviors. + * + * @hoc + * @memberof limestone/DotSpinner + * @mixes limestone/Skinnable.Skinnable + * @public + */ +const DotSpinnerDecorator = compose( + Pure, + Skinnable +); + +/** + * A Limestone-styled three-dot Spinner. + * + * @class DotSpinner + * @memberof limestone/DotSpinner + * @extends limestone/DotSpinner.DotSpinnerBase + * @mixes limestone/DotSpinner.DotSpinnerDecorator + * @ui + * @public + */ +const DotSpinner = DotSpinnerDecorator(DotSpinnerBase); + +export default DotSpinner; +export { + DotSpinner, + DotSpinnerBase, + DotSpinnerDecorator +}; diff --git a/DotSpinner/DotSpinner.module.less b/DotSpinner/DotSpinner.module.less new file mode 100644 index 000000000..cbb272080 --- /dev/null +++ b/DotSpinner/DotSpinner.module.less @@ -0,0 +1,203 @@ +// DotSpinner.module.less +// +@import "../styles/mixins.less"; +@import "../styles/variables.less"; +@import "../styles/skin.less"; + +@dot-spinner-duration: 4s; +@dot-spinner-dot-size: 20px; +@dot-spinner-gap: 12px; + +@keyframes dot-show-second { + 0% { opacity: 0; } + 9% { opacity: 0; } + 17% { opacity: 1; } + 91% { opacity: 1; } + 99% { opacity: 0; } + 100% { opacity: 0; } +} + +@keyframes dot-show-third { + 0% { opacity: 0; } + 17% { opacity: 0; } + 25% { opacity: 1; } + 75% { opacity: 1; } + 83% { opacity: 0; } + 100% { opacity: 0; } +} + +@keyframes dot-show-first { + 0% { opacity: 0; } + 1% { opacity: 0; } + 9% { opacity: 1; } + 83% { opacity: 1; } + 91% { opacity: 0; } + 100% { opacity: 0; } +} + +@keyframes dot-rotate-first { + 0% { + transform: rotate(0deg) translateX(0); + transform-origin: 26px center; + } + 25% { + transform: rotate(0deg) translateX(0); + transform-origin: 26px center; + } + 37.5% { + transform: rotate(180deg) translateX(0); + transform-origin: 26px center; + } + 37.51% { + transform: rotate(0deg) translateX(32px); + transform-origin: 58px center; + } + 50% { + transform: rotate(-180deg) translate(32px); + transform-origin: 58px center; + } + 62.5% { + transform: rotate(-180deg) translate(32px); + transform-origin: 58px center; + } + 75% { + transform: rotate(-360deg) translateX(32px); + transform-origin: 58px center; + } + 100% { + transform: rotate(-360deg) translate(32px); + transform-origin: 58px center; + } +} + +@keyframes dot-rotate-second { + 0% { + transform: rotate(0turn) translateX(0); + transform-origin: -6px center; + } + 25% { + transform: rotate(0turn) translateX(0); + transform-origin: -6px center; + } + 37.5% { + transform: rotate(180deg) translateX(0); + transform-origin: -6px center; + } + 50% { + transform: rotate(180deg) translateX(0); + transform-origin: -6px center; + } + 62.5% { + transform: rotate(360deg) translateX(0); + transform-origin: -6px center; + } + 62.51% { + transform: rotate(0deg) translateX(0); + transform-origin: 26px center; + } + 75% { + transform: rotate(-180deg) translateX(0); + transform-origin: 26px center; + } + 100% { + transform: rotate(-180deg) translate(0); + transform-origin: 26px center; + } +} + +@keyframes dot-rotate-third { + 0% { + transform: rotate(0turn) translateX(0); + transform-origin: -6px center; + } + 37.5% { + transform: rotate(0turn) translateX(0); + transform-origin: -6px center; + } + 50% { + transform: rotate(-180deg) translateX(0); + transform-origin: -6px center; + } + 50.01% { + transform: rotate(0deg) translateX(-32px); + transform-origin: -38px center; + } + 62.5% { + transform: rotate(180deg) translateX(-32px); + transform-origin: -38px center; + } + 100% { + transform: rotate(180deg) translateX(-32px); + transform-origin: -38px center; + } +} + +.dotSpinner { + display: inline-flex; + flex-direction: column; + align-items: center; + vertical-align: middle; + + .dots { + display: flex; + flex-direction: row; + align-items: center; + gap: @dot-spinner-gap; + } + + .dot { + width: @dot-spinner-dot-size; + height: @dot-spinner-dot-size; + border-radius: 50%; + background-color: var(--semantic-color-on-background-main, #ffffff); + + &:nth-child(1) { + animation: + dot-show-first @dot-spinner-duration ease-in-out infinite, + dot-rotate-first @dot-spinner-duration ease-in-out infinite; + // dot-hide-first @dot-spinner-duration linear infinite; + animation-play-state: running; + } + &:nth-child(2) { + // transform-origin: -6px center; + animation: + dot-show-second @dot-spinner-duration linear infinite, + dot-rotate-second @dot-spinner-duration ease-in-out infinite; + animation-play-state: running; + } + &:nth-child(3) { + // transform-origin: -6px center; + animation: + dot-show-third @dot-spinner-duration linear infinite, + dot-rotate-third @dot-spinner-duration ease-in-out infinite; + animation-play-state: running; + } + } + + &.paused .dot { + animation-play-state: paused; + } + + .client { + .lime-body-text(); + align-content: center; + height: @lime-spinner-client-height; + font-weight: @lime-spinner-font-weight; + line-height: @lime-spinner-client-line-height; + max-width: 696px; + margin-top: 16px; + } + + // Skin colors + .applySkins({ + color: @lime-spinner-label-color; + + .dot { + background-color: @lime-spinner-color; + } + + &.transparent .dot { + background-color: @lime-spinner-color; + } + }); +} \ No newline at end of file diff --git a/DotSpinner/package.json b/DotSpinner/package.json new file mode 100644 index 000000000..f5f38b65e --- /dev/null +++ b/DotSpinner/package.json @@ -0,0 +1,3 @@ +{ + "main": "DotSpinner.js" +} diff --git a/samples/sampler/stories/default/DotSpinner.js b/samples/sampler/stories/default/DotSpinner.js new file mode 100644 index 000000000..a36ca5409 --- /dev/null +++ b/samples/sampler/stories/default/DotSpinner.js @@ -0,0 +1,46 @@ +import DotSpinner, {DotSpinnerBase} from '@enact/limestone/DotSpinner'; +import {mergeComponentMetadata} from '@enact/storybook-utils'; +import {boolean, select, text} from '@enact/storybook-utils/addons/controls'; +import ri from '@enact/ui/resolution'; + +DotSpinner.displayName = 'DotSpinner'; +const Config = mergeComponentMetadata('DotSpinner', DotSpinnerBase, DotSpinner); + +export default { + title: 'Limestone/DotSpinner', + component: 'DotSpinner' +}; + +export const _DotSpinner = (args) => ( +
+ + {args['content']} + +
+); + +boolean('paused', _DotSpinner, Config); +select('size', _DotSpinner, [null, 'medium', 'small'], Config); +boolean('transparent', _DotSpinner, Config); +text('content', _DotSpinner, Config, ''); + +_DotSpinner.storyName = 'DotSpinner'; +_DotSpinner.parameters = { + info: { + text: 'Three-dot bouncing spinner for loading states' + } +}; From dc3cf18bf8c32777b5514fda3d67bbc098147500 Mon Sep 17 00:00:00 2001 From: "jiye.kim" Date: Mon, 18 May 2026 14:09:18 +0900 Subject: [PATCH 2/2] fixed lint error Enact-DCO-1.0-Signed-off-by: Jiye Kim (jiye.kim@lge.com) --- DotSpinner/DotSpinner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DotSpinner/DotSpinner.js b/DotSpinner/DotSpinner.js index 615adab35..f406ff5fa 100644 --- a/DotSpinner/DotSpinner.js +++ b/DotSpinner/DotSpinner.js @@ -85,7 +85,7 @@ const DotSpinnerBase = kind({ }, computed: { - 'aria-label': ({['aria-label']: aria, children}) => aria || (!children ? 'Loading' : undefined), + 'aria-label': ({['aria-label']: aria, children}) => aria || (!children ? 'Loading' : null), className: ({children, paused, size, transparent, styler}) => styler.append(size, {content: !!children, paused, transparent}) },