Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* 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 `<span>` 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);
}
}
Original file line number Diff line number Diff line change
@@ -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%;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 => `
<div style="display:flex; justify-content:center; padding:100px 0">
${story}
</div>
`
)
],
parameters: {
chromatic: { disableSnapshot: true }
}
} as Meta;

type Story = StoryObj;

export const MatRaisedButton: Story = {
render: () => ({ template: `<button mat-raised-button sfBubbleButton color="primary">Mat raised button</button>` })
};

export const MatFlatButton: Story = {
render: () => ({ template: `<button mat-flat-button sfBubbleButton color="primary">Mat flat button</button>` })
};

export const MatStrokedButton: Story = {
render: () => ({ template: `<button mat-stroked-button sfBubbleButton color="primary">Mat stroked button</button>` })
};

export const MatButton: Story = {
render: () => ({ template: `<button mat-button sfBubbleButton color="primary">Mat button</button>` })
};

export const VanillaButton: Story = {
render: () => ({ template: `<button sfBubbleButton color="primary">Vanilla button</button>` })
};

export const LongTextButton: Story = {
render: () => ({
template: `<button mat-flat-button sfBubbleButton color="primary">This mat flat button has long text</button>`
})
};

export const ShortTextButton: Story = {
render: () => ({ template: `<button mat-flat-button sfBubbleButton color="primary">Ok</button>` })
};
Loading