Skip to content

Commit

Permalink
feat(snackbar): add new package (#19)
Browse files Browse the repository at this point in the history
* feat(snackbar): add initial implementation with TypeScript configuration, tests, and documentation

* feat(snackbar): implement Snackbar component with action button and close functionality

* refactor(snackbar): enhance SnackbarComponent with detailed JSDoc comments for properties and methods

* docs(snackbar): update README and package.json to reflect new snackbar component features and description

* refactor(snackbar): consolidate utility functions and enhance logging with package name prefixes

* refactor(snackbar): replace BaseElement with LightDomMixin and LoggerMixin for SnackbarComponent

* refactor(snackbar): integrate logger instance into main module and utilize it in utility functions

* refactor(snackbar): remove CHANGELOG.md and update package.json dependencies

* refactor(snackbar): remove package tracer and update logger implementation in main module and utilities

* chore(snackbar): change utils location

* refactor(snackbar): remove logger from utils

* refactor(snackbar): remove __package_name__ from logger

* refactor(snackbar): rename snackbar to handler for createLogger

* chore(snackbar): remove types from jsDoc

* refactor(snackbar): rename signal to handler

* chore(snackbar): import @nexim/element from workspace

* chore(snackbar): change location of property comment's

* refactor(snackbar): parse Duration for snackbar duration

* doc(snackbar): remove unnecessary README Doc's

* doc(snackbar): update duration format from milliseconds to seconds

* doc(snackbar): write a example for snackbarSignal Method
  • Loading branch information
arashagp authored Dec 16, 2024
1 parent e16d69f commit 2f8d49f
Show file tree
Hide file tree
Showing 10 changed files with 1,360 additions and 1 deletion.
661 changes: 661 additions & 0 deletions packages/snackbar/LICENSE

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions packages/snackbar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# @nexim/snackbar

This package provides a customizable snackbar component for displaying brief messages to users. It includes utilities for managing the snackbar's state and animations.

![NPM Version](https://img.shields.io/npm/v/%40nexim%2Fsnackbar)
![npm bundle size](https://img.shields.io/bundlephobia/min/%40nexim%2Fsnackbar)
![Build & Lint & Test](https://github.com/the-nexim/nanolib/actions/workflows/build-lint-test.yaml/badge.svg)
![NPM Downloads](https://img.shields.io/npm/dm/%40nexim%2Fsnackbar)
![NPM License](https://img.shields.io/npm/l/%40nexim%2Fsnackbar)

## Overview

`@nexim/snackbar` is a versatile library designed to provide a customizable snackbar component for displaying brief messages to users. It includes utilities for managing the snackbar's state and animations, ensuring efficiency and scalability in high-performance projects.

## Installation

Install the package using npm or yarn:

```sh
npm install @nexim/snackbar

# Or using yarn
yarn add @nexim/snackbar
```

## API

### snackbarSignal

To display a snackbar, emit the snackbarSignal with the desired options:

```ts
import {snackbarSignal} from '@nexim/snackbar';

snackbarSignal.notify({
content: 'This is a snackbar message',
// The following properties are optional.
action: {
label: 'Undo',
handler: () => {
console.log('Action button clicked');
},
},
duration: '4s',
addCloseButton: true,
});
```
78 changes: 78 additions & 0 deletions packages/snackbar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"name": "@nexim/snackbar",
"version": "0.0.0",
"description": "A customizable snackbar component for displaying brief messages to users, with state management and animation utilities.",
"keywords": [
"snackbar",
"notification",
"web-component",
"typescript",
"nexim"
],
"homepage": "https://github.com/the-nexim/nanolib/tree/next/packages/snackbar#readme",
"bugs": {
"url": "https://github.com/the-nexim/nanolib/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/the-nexim/nanolib",
"directory": "packages/snackbar"
},
"license": "AGPL-3.0-only",
"author": "S. Amir Mohammad Najafi <[email protected]> (www.njfamirm.ir)",
"contributors": [
"Arash Ghardashpoor <[email protected]> (https://www.agpagp.ir)"
],
"type": "module",
"exports": {
".": {
"types": "./dist/main.d.ts",
"import": "./dist/main.mjs",
"require": "./dist/main.cjs"
}
},
"main": "./dist/main.cjs",
"module": "./dist/main.mjs",
"types": "./dist/main.d.ts",
"files": [
"**/*.{js,mjs,cjs,map,d.ts,html,md,LEGAL.txt}",
"LICENSE",
"!**/*.test.js",
"!demo/**/*"
],
"scripts": {
"b": "yarn run build",
"build": "yarn run build:ts && yarn run build:es",
"build:es": "nano-build --preset=module",
"build:ts": "tsc --build",
"c": "yarn run clean",
"cb": "yarn run clean && yarn run build",
"clean": "rm -rfv dist *.tsbuildinfo",
"d": "yarn run build:es && yarn node --enable-source-maps --trace-warnings",
"t": "yarn run test",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --enable-source-maps --experimental-vm-modules\" ava",
"w": "yarn run watch",
"watch": "yarn run watch:ts & yarn run watch:es",
"watch:es": "yarn run build:es --watch",
"watch:ts": "yarn run build:ts --watch --preserveWatchOutput"
},
"dependencies": {
"@alwatr/flux": "^4.0.2",
"@alwatr/logger": "^5.0.0",
"@alwatr/package-tracer": "^5.0.0",
"@alwatr/parse-duration": "^5.0.0",
"@alwatr/wait": "^1.1.16",
"@nexim/element": "workspace:^",
"lit": "^3.2.1"
},
"devDependencies": {
"@alwatr/nano-build": "^5.0.0",
"@alwatr/type-helper": "^5.0.0",
"@nexim/typescript-config": "workspace:^",
"ava": "^6.2.0",
"typescript": "^5.6.3"
},
"publishConfig": {
"access": "public"
}
}
109 changes: 109 additions & 0 deletions packages/snackbar/src/lib/element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {waitForTimeout} from '@alwatr/wait';
import {LightDomMixin, LoggerMixin} from '@nexim/element';
import {html, LitElement, nothing, type PropertyValues, type TemplateResult} from 'lit';
import {customElement, property} from 'lit/decorators.js';

import {snackbarActionButtonClickedSignal} from './handler.js';
import {waitForNextFrame} from './utils.js';

declare global {
interface HTMLElementTagNameMap {
'snack-bar': SnackbarComponent;
}
}

@customElement('snack-bar')
export class SnackbarComponent extends LightDomMixin(LoggerMixin(LitElement)) {
/**
* The content to be displayed inside the snackbar.
*/
@property({type: String}) content = '';

/**
* The label for the action button. If null, the action button will not be rendered.
*/
@property({type: String, attribute: 'action-button-label'}) actionButtonLabel: string | null = null;

/**
* Whether to add a close button to the snackbar.
*/
@property({type: Boolean, attribute: 'add-close-button'}) addCloseButton = false;

/**
* Duration for the open and close animation in milliseconds.
*/
private static openAndCloseAnimationDuration__ = 200; // ms

protected override firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);

// wait for render complete, then open the snackbar to start the opening animation
waitForNextFrame().then(() => {
this.setAttribute('open', '');
});
}

/**
* Close the snackbar and remove it from the DOM.
* Waits for the closing animation to end before removing the element.
*/
async close(): Promise<void> {
this.logger_.logMethod?.('close');

this.removeAttribute('open');

await waitForTimeout(SnackbarComponent.openAndCloseAnimationDuration__);
this.remove();
}

/**
* Handle the action button click event.
* Sends a signal when the action button is clicked.
*/
private actionButtonClickHandler__(): void {
this.logger_.logMethod?.('actionButtonClickHandler__');

snackbarActionButtonClickedSignal.notify();
}

/**
* Render the snackbar component.
*/
protected override render(): unknown {
super.render();

const actionButtonHtml = this.renderActionButton__();
const closeButtonHtml = this.renderCloseButton__();

let actionButtonHandler: TemplateResult | typeof nothing = nothing;
if (actionButtonHtml != nothing || closeButtonHtml != nothing) {
actionButtonHandler = html`<div>${actionButtonHtml} ${closeButtonHtml}</div>`;
}

return [html`<span>${this.content}</span>`, actionButtonHandler];
}

/**
* Render the action button.
*/
private renderActionButton__(): TemplateResult | typeof nothing {
if (this.actionButtonLabel == null) return nothing;
this.logger_.logMethodArgs?.('renderActionButton__', {actionLabel: this.actionButtonLabel});

return html` <button class="action-button" @click=${this.actionButtonClickHandler__.bind(this)}>${this.actionButtonLabel}</button> `;
}

/**
* Render the close button.
*/
private renderCloseButton__(): TemplateResult | typeof nothing {
if (this.addCloseButton === false) return nothing;
this.logger_.logMethod?.('renderCloseButton__');

return html`
<button class="close-button" @click=${this.close.bind(this)}>
<span class="alwatr-icon-font">close</span>
</button>
`;
}
}
117 changes: 117 additions & 0 deletions packages/snackbar/src/lib/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {AlwatrSignal, AlwatrTrigger} from '@alwatr/flux';
import {createLogger} from '@alwatr/logger';
import {parseDuration, type Duration} from '@alwatr/parse-duration';
import {waitForTimeout} from '@alwatr/wait';

import type {SnackbarComponent} from './element.js';

const logger = createLogger(`${__package_name__}/handler`);

/**
* @property content - Content to be displayed in the snackbar.
* @property {action} - The action button configuration.
* @property action.label - The label for the action button.
* @property action.handler - The handler function for the action button.
* @property duration - Duration for which the snackbar is displayed. `-1` for infinite duration.
* Duration for which the snackbar is displayed.
* `-1` for infinite duration.
* @property addCloseButton - Whether to add a close button to the snackbar.
*/
export type SnackbarOptions = {
content: string;
action?: {
label: string;
handler: () => void;
};
duration?: Duration;
addCloseButton?: boolean;
};

/**
* Signal for when the snackbar action button is clicked.
*/
export const snackbarActionButtonClickedSignal = new AlwatrTrigger({
name: 'snackbar-action-button-clicked',
});

/**
* Signal for displaying the snackbar.
*
* @example
* import {snackbarSignal} from '@nexim/snackbar';
*
* snackbarSignal.notify({
* content: 'This is a snackbar message',
* // The following properties are optional.
* action: {
* label: 'Undo',
* handler: () => {
* console.log('Action button clicked');
* },
* },
* duration: '4s',
* addCloseButton: true,
* });
*/
export const snackbarSignal = new AlwatrSignal<SnackbarOptions>({name: 'snackbar'});

// Subscribe to the snackbar signal to show the snackbar when the signal is emitted.
snackbarSignal.subscribe((options) => {
showSnackbar(options);
});

let closeLastSnackbar: (() => Promise<void>) | null = null;
let unsubscribeActionButtonHandler: (() => void) | null = null;

/**
* Displays the snackbar with the given options.
* @param options - Options for configuring the snackbar.
*/
async function showSnackbar(options: SnackbarOptions): Promise<void> {
logger.logMethodArgs?.('showSnackbar', {options});

// Parse the duration
if (options.duration != null) options.duration = parseDuration(options.duration);

// Set default duration if not provided
options.duration = parseDuration('4s');

const element = document.createElement('snack-bar') as SnackbarComponent;

element.setAttribute('content', options.content);

if (options.addCloseButton === true) {
element.setAttribute('add-close-button', '');
}

if (options.action != null) {
element.setAttribute('action-button-label', options.action.label);

// Subscribe to the action button click
unsubscribeActionButtonHandler = snackbarActionButtonClickedSignal.subscribe(() => {
options.action!.handler();

return closeSnackbar_();
}).unsubscribe;
}

let closed = false;
const closeSnackbar_ = async () => {
if (closed === true) return;
logger.logMethodArgs?.('closeSnackbar', {options});

await element.close();
unsubscribeActionButtonHandler?.();
closed = true;
};

// Close the last snackbar if it exists
await closeLastSnackbar?.();
closeLastSnackbar = closeSnackbar_;
document.body.appendChild(element);

// Set a timeout to close the snackbar if duration is not infinite
if (options.duration !== -1) {
waitForTimeout(parseDuration(options.duration)).then(closeSnackbar_);
}
}
18 changes: 18 additions & 0 deletions packages/snackbar/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {waitForAnimationFrame, waitForTimeout} from '@alwatr/wait';

/**
* Waits for the next frame to ensure the DOM has been fully calculated.
* This minimizes the chance that querying the DOM will cause a costly reflow.
*
* This function uses `requestAnimationFrame` to schedule code to run immediately before the repaint,
* followed by `setTimeout` with a delay of 0 to execute code as soon as possible after the repaint.
*
* @see https://stackoverflow.com/a/47184426
*/
export function waitForNextFrame(): Promise<void> {
return new Promise((resolve) => {
waitForAnimationFrame().then(() => {
waitForTimeout(0).then(resolve);
});
});
}
6 changes: 6 additions & 0 deletions packages/snackbar/src/main.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import test from 'ava';

// empty test
test('empty test', (test) => {
test.pass();
});
6 changes: 6 additions & 0 deletions packages/snackbar/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {packageTracer} from '@alwatr/package-tracer';

__dev_mode__: packageTracer.add(__package_name__, __package_version__);

export * from './lib/element.js';
export * from './lib/handler.js';
Loading

0 comments on commit 2f8d49f

Please sign in to comment.