diff --git a/src/SIL.XForge.Scripture/ClientApp/package-lock.json b/src/SIL.XForge.Scripture/ClientApp/package-lock.json index 4898961b70..2e0e51bfd5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package-lock.json +++ b/src/SIL.XForge.Scripture/ClientApp/package-lock.json @@ -99,6 +99,7 @@ "babel-loader": "^8.3.0", "chromatic": "^6.17.3", "codecov": "^3.8.3", + "css-loader": "^6.8.1", "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-deprecation": "^1.3.3", @@ -120,7 +121,9 @@ "prettier": "^2.6.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "sass-loader": "^13.3.2", "storybook": "^7.0.18", + "style-loader": "^3.3.3", "ts-mockito": "^2.6.1", "ts-node": "^10.7.0", "typescript": "~4.8.4", @@ -7187,6 +7190,32 @@ "balanced-match": "^1.0.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/css-loader": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", + "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.7", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/glob": { "version": "8.0.3", "dev": true, @@ -7221,6 +7250,44 @@ "node": ">=10" } }, + "node_modules/@angular-devkit/build-angular/node_modules/sass-loader": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.0.2.tgz", + "integrity": "sha512-BbiqbVmbfJaWVeOOAu2o7DhYWtcNmTfvroVgFXa6k2hHheMxNAeDHLNoDy/Q5aoaVlz0LH+MbMktKwm9vN/j8Q==", + "dev": true, + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-angular/node_modules/semver": { "version": "7.3.7", "dev": true, @@ -22657,18 +22724,19 @@ } }, "node_modules/css-loader": { - "version": "6.7.1", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", + "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", "dev": true, - "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.7", + "postcss": "^8.4.21", "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-local-by-default": "^4.0.3", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.3.5" + "semver": "^7.3.8" }, "engines": { "node": ">= 12.13.0" @@ -22681,6 +22749,34 @@ "webpack": "^5.0.0" } }, + "node_modules/css-loader/node_modules/postcss": { + "version": "8.4.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.28.tgz", + "integrity": "sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/css-prefers-color-scheme": { "version": "6.0.3", "dev": true, @@ -33459,9 +33555,16 @@ "license": "ISC" }, "node_modules/nanoid": { - "version": "3.3.4", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -38032,9 +38135,10 @@ } }, "node_modules/postcss-modules-local-by-default": { - "version": "4.0.0", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", + "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", "dev": true, - "license": "MIT", "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^6.0.2", @@ -39727,11 +39831,11 @@ } }, "node_modules/sass-loader": { - "version": "13.0.2", + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.2.tgz", + "integrity": "sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==", "dev": true, - "license": "MIT", "dependencies": { - "klona": "^2.0.4", "neo-async": "^2.6.2" }, "engines": { @@ -39743,7 +39847,7 @@ }, "peerDependencies": { "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "sass": "^1.3.0", "sass-embedded": "*", "webpack": "^5.0.0" @@ -43482,6 +43586,22 @@ "balanced-match": "^1.0.0" } }, + "css-loader": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", + "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.7", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.5" + } + }, "glob": { "version": "8.0.3", "dev": true, @@ -43504,6 +43624,16 @@ "brace-expansion": "^2.0.1" } }, + "sass-loader": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.0.2.tgz", + "integrity": "sha512-BbiqbVmbfJaWVeOOAu2o7DhYWtcNmTfvroVgFXa6k2hHheMxNAeDHLNoDy/Q5aoaVlz0LH+MbMktKwm9vN/j8Q==", + "dev": true, + "requires": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + } + }, "semver": { "version": "7.3.7", "dev": true, @@ -54376,17 +54506,32 @@ } }, "css-loader": { - "version": "6.7.1", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", + "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", "dev": true, "requires": { "icss-utils": "^5.1.0", - "postcss": "^8.4.7", + "postcss": "^8.4.21", "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-local-by-default": "^4.0.3", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.3.5" + "semver": "^7.3.8" + }, + "dependencies": { + "postcss": { + "version": "8.4.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.28.tgz", + "integrity": "sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==", + "dev": true, + "requires": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + } } }, "css-prefers-color-scheme": { @@ -61955,7 +62100,9 @@ "dev": true }, "nanoid": { - "version": "3.3.4", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true }, "natural-compare": { @@ -65017,7 +65164,9 @@ "dev": true }, "postcss-modules-local-by-default": { - "version": "4.0.0", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", + "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", "dev": true, "requires": { "icss-utils": "^5.0.0", @@ -70889,10 +71038,11 @@ } }, "sass-loader": { - "version": "13.0.2", + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.2.tgz", + "integrity": "sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==", "dev": true, "requires": { - "klona": "^2.0.4", "neo-async": "^2.6.2" } }, diff --git a/src/SIL.XForge.Scripture/ClientApp/package.json b/src/SIL.XForge.Scripture/ClientApp/package.json index 0becb34e84..b0298d2441 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package.json +++ b/src/SIL.XForge.Scripture/ClientApp/package.json @@ -119,6 +119,7 @@ "babel-loader": "^8.3.0", "chromatic": "^6.17.3", "codecov": "^3.8.3", + "css-loader": "^6.8.1", "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-deprecation": "^1.3.3", @@ -140,7 +141,9 @@ "prettier": "^2.6.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "sass-loader": "^13.3.2", "storybook": "^7.0.18", + "style-loader": "^3.3.3", "ts-mockito": "^2.6.1", "ts-node": "^10.7.0", "typescript": "~4.8.4", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.directive.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.directive.ts new file mode 100644 index 0000000000..cb76a35fab --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.directive.ts @@ -0,0 +1,45 @@ +/* eslint-disable @angular-eslint/directive-selector */ +import { Directive, ElementRef, OnInit, Renderer2 } from '@angular/core'; + +/** + * Directive to add bubble animation to a button. Inspired by https://codepen.io/nourabusoud/pen/ypZzMM, + * but modified to use an inner `` to attach the pseudo elements to so as not to conflict + * with other styles or components that use `::before` or `::after` such as Angular Material components. + */ +@Directive({ + selector: '[sfBubbleButton]' +}) +export class BubbleButtonDirective implements OnInit { + cssInnerSpanStyleClass = 'sf-bubble-button-elements'; + cssButtonStyleClass = 'sf-bubble-button'; + cssButtonAnimationClass = 'sf-bubble-animate'; + + constructor(private readonly el: ElementRef, private readonly renderer: Renderer2) {} + + ngOnInit(): void { + const hostElement = this.el.nativeElement; + const innerSpan = this.renderer.createElement('span'); + + // Add inner span to host element + this.renderer.addClass(innerSpan, this.cssInnerSpanStyleClass); + this.renderer.appendChild(hostElement, innerSpan); + + // Add class and click listener to host element + this.renderer.addClass(hostElement, this.cssButtonStyleClass); + this.renderer.listen(hostElement, 'click', () => { + // Add animation class to inner span + this.addAnimationClass(innerSpan); + }); + } + + // Adds animation class to the element and removes it after animation is complete + addAnimationClass(el: any): void { + // Reset animation + el.classList.remove(this.cssButtonAnimationClass); + + // Timeout needed to restart animation + setTimeout(() => { + el.classList.add(this.cssButtonAnimationClass); + }, 10); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.scss b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.scss new file mode 100644 index 0000000000..9725a9749b --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.scss @@ -0,0 +1,171 @@ +@use 'sass:map'; +@use 'sass:list'; +@use 'src/variables' as vars; + +/// Gets the min/max css function to constrain the size within a min and max size in px. +/// @param {number} $sizePercentage - The size of the bubble as a percentage of the button size. +@function sizeCalc($sizePercentage) { + @return min(max(#{$minBubbleSize}, #{$sizePercentage}), #{$maxBubbleSize}); +} + +$minBubbleSize: 30px; +$maxBubbleSize: 50px; +$bubbleColor: vars.$blueMedium; +$animationDuration: 0.7s; + +// Bubble types +// 1: Large solid bubble +// 2: Large outlined bubble +// 3: Small solid bubble +$bubbleTypes: ( + 1: radial-gradient(circle, $bubbleColor 20%, transparent 20%), + 2: radial-gradient(circle, transparent 20%, $bubbleColor 20%, transparent 30%), + 3: radial-gradient(circle, transparent 10%, $bubbleColor 15%, transparent 20%) +); + +// prettier-ignore +$bubbles: ( + // Top bubbles + (location: 'top', type: 1, size: 10%, pos0: ( 5% 90%), pos50: ( 0% 80%), pos100: ( 0% 70%)), + (location: 'top', type: 2, size: 20%, pos0: (10% 90%), pos50: ( 0% 20%), pos100: ( 0% 10%)), + (location: 'top', type: 1, size: 15%, pos0: (10% 90%), pos50: (10% 40%), pos100: (10% 30%)), + (location: 'top', type: 1, size: 20%, pos0: (15% 90%), pos50: (20% 0%), pos100: (20% -10%)), + (location: 'top', type: 3, size: 18%, pos0: (25% 90%), pos50: (30% 30%), pos100: (30% 20%)), + (location: 'top', type: 1, size: 10%, pos0: (25% 90%), pos50: (22% 50%), pos100: (22% 40%)), + (location: 'top', type: 1, size: 15%, pos0: (40% 90%), pos50: (50% 50%), pos100: (50% 40%)), + (location: 'top', type: 1, size: 10%, pos0: (55% 90%), pos50: (65% 20%), pos100: (65% 10%)), + (location: 'top', type: 1, size: 18%, pos0: (70% 90%), pos50: (90% 30%), pos100: (90% 20%)), + // Bottom bubbles + (location: 'bottom', type: 1, size: 15%, pos0: (10% -10%), pos50: ( 0% 80%), pos100: ( 0% 90%)), + (location: 'bottom', type: 1, size: 20%, pos0: (30% 10%), pos50: ( 20% 80%), pos100: ( 20% 90%)), + (location: 'bottom', type: 3, size: 18%, pos0: (55% -10%), pos50: ( 45% 60%), pos100: ( 45% 70%)), + (location: 'bottom', type: 1, size: 20%, pos0: (70% -10%), pos50: ( 60% 100%), pos100: ( 60% 110%)), + (location: 'bottom', type: 1, size: 15%, pos0: (85% -10%), pos50: ( 75% 70%), pos100: ( 75% 80%)), + (location: 'bottom', type: 1, size: 10%, pos0: (70% -10%), pos50: ( 95% 60%), pos100: ( 95% 70%)), + (location: 'bottom', type: 1, size: 20%, pos0: (70% 0%), pos50: (105% 0%), pos100: (110% 10%)) +); + +// Initialize empty lists +$topBubblesImages: (); +$topBubblesSizes: (); +$topBubblesPos0s: (); +$topBubblesPos50s: (); +$topBubblesPos100s: (); +$bottomBubblesImages: (); +$bottomBubblesSizes: (); +$bottomBubblesPos0s: (); +$bottomBubblesPos50s: (); +$bottomBubblesPos100s: (); + +@each $bubble in $bubbles { + $location: map.get($bubble, location); + $type: map.get($bubble, type); + $size: sizeCalc(map.get($bubble, size)); + $pos0: map.get($bubble, pos0); + $pos50: map.get($bubble, pos50); + $pos100: map.get($bubble, pos100); + + @if $location == 'top' { + $topBubblesImages: list.append($topBubblesImages, map.get($bubbleTypes, $type), comma); + $topBubblesSizes: list.append($topBubblesSizes, ($size $size), comma); + $topBubblesPos0s: list.append($topBubblesPos0s, $pos0, comma); + $topBubblesPos50s: list.append($topBubblesPos50s, $pos50, comma); + $topBubblesPos100s: list.append($topBubblesPos100s, $pos100, comma); + } @else if $location == 'bottom' { + $bottomBubblesImages: list.append($bottomBubblesImages, map.get($bubbleTypes, $type), comma); + $bottomBubblesSizes: list.append($bottomBubblesSizes, ($size $size), comma); + $bottomBubblesPos0s: list.append($bottomBubblesPos0s, $pos0, comma); + $bottomBubblesPos50s: list.append($bottomBubblesPos50s, $pos50, comma); + $bottomBubblesPos100s: list.append($bottomBubblesPos100s, $pos100, comma); + } +} + +.sf-bubble-button { + position: relative; + + // Transition the scale down effect of button press + transition: transform linear 50ms; + + &:active { + transform: scale(0.95); + box-shadow: 0 2px 12px -5px $bubbleColor; + } + + // Inner span + .sf-bubble-button-elements { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + &:before, + &:after { + position: absolute; + content: ''; + display: block; + height: 100%; + left: -20%; + right: -20%; + + // Needed to make the padding increase the height as the width increases. + // Long buttons need more height for the animation so the circles don't get cut off. + box-sizing: content-box; + padding-top: 30%; // Percentage of width of containing element + + z-index: -1; + background-repeat: no-repeat; + } + + &:before { + display: none; + bottom: 65%; + background-image: $topBubblesImages; + background-size: $topBubblesSizes; + } + + &:after { + display: none; + top: 65%; + background-image: $bottomBubblesImages; + background-size: $bottomBubblesSizes; + } + + &.sf-bubble-animate { + &:before { + display: block; + animation: emit-bubbles-top ease-out $animationDuration forwards; + } + &:after { + display: block; + animation: emit-bubbles-bottom ease-out $animationDuration forwards; + } + } + } +} + +@keyframes emit-bubbles-top { + 0% { + background-position: $topBubblesPos0s; + } + 50% { + background-position: $topBubblesPos50s; + } + 100% { + background-position: $topBubblesPos100s; + background-size: 0% 0%; + } +} + +@keyframes emit-bubbles-bottom { + 0% { + background-position: $bottomBubblesPos0s; + } + 50% { + background-position: $bottomBubblesPos50s; + } + 100% { + background-position: $bottomBubblesPos100s; + background-size: 0% 0%; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.stories.ts new file mode 100644 index 0000000000..239a9e65e4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/bubble-button/bubble-button.stories.ts @@ -0,0 +1,66 @@ +import '!style-loader!css-loader!sass-loader!./bubble-button.scss'; +import { MatButtonModule } from '@angular/material/button'; +import { componentWrapperDecorator, Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { BubbleButtonDirective } from './bubble-button.directive'; + +export default { + title: 'Misc/Bubble Button', + + decorators: [ + moduleMetadata({ + imports: [MatButtonModule], + declarations: [BubbleButtonDirective] + }), + componentWrapperDecorator( + story => ` +
+ ${story} +
+ ` + ) + ] +} as Meta; + +type Story = StoryObj; + +export const MatRaisedButton: Story = { + render: () => ({ + template: `` + }) +}; + +export const MatFlatButton: Story = { + render: () => ({ + template: `` + }) +}; + +export const MatStrokedButton: Story = { + render: () => ({ + template: `` + }) +}; + +export const MatButton: Story = { + render: () => ({ + template: `` + }) +}; + +export const VanillaButton: Story = { + render: () => ({ + template: `` + }) +}; + +export const LongTextButton: Story = { + render: () => ({ + template: `` + }) +}; + +export const ShortTextButton: Story = { + render: () => ({ + template: `` + }) +};