Skip to content

Commit 90d61ff

Browse files
authored
feat(snackbar): add new package (#19)
* 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
1 parent d9014f8 commit 90d61ff

File tree

10 files changed

+1360
-1
lines changed

10 files changed

+1360
-1
lines changed

packages/snackbar/LICENSE

+661
Large diffs are not rendered by default.

packages/snackbar/README.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# @nexim/snackbar
2+
3+
This package provides a customizable snackbar component for displaying brief messages to users. It includes utilities for managing the snackbar's state and animations.
4+
5+
![NPM Version](https://img.shields.io/npm/v/%40nexim%2Fsnackbar)
6+
![npm bundle size](https://img.shields.io/bundlephobia/min/%40nexim%2Fsnackbar)
7+
![Build & Lint & Test](https://github.com/the-nexim/nanolib/actions/workflows/build-lint-test.yaml/badge.svg)
8+
![NPM Downloads](https://img.shields.io/npm/dm/%40nexim%2Fsnackbar)
9+
![NPM License](https://img.shields.io/npm/l/%40nexim%2Fsnackbar)
10+
11+
## Overview
12+
13+
`@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.
14+
15+
## Installation
16+
17+
Install the package using npm or yarn:
18+
19+
```sh
20+
npm install @nexim/snackbar
21+
22+
# Or using yarn
23+
yarn add @nexim/snackbar
24+
```
25+
26+
## API
27+
28+
### snackbarSignal
29+
30+
To display a snackbar, emit the snackbarSignal with the desired options:
31+
32+
```ts
33+
import {snackbarSignal} from '@nexim/snackbar';
34+
35+
snackbarSignal.notify({
36+
content: 'This is a snackbar message',
37+
// The following properties are optional.
38+
action: {
39+
label: 'Undo',
40+
handler: () => {
41+
console.log('Action button clicked');
42+
},
43+
},
44+
duration: '4s',
45+
addCloseButton: true,
46+
});
47+
```

packages/snackbar/package.json

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"name": "@nexim/snackbar",
3+
"version": "0.0.0",
4+
"description": "A customizable snackbar component for displaying brief messages to users, with state management and animation utilities.",
5+
"keywords": [
6+
"snackbar",
7+
"notification",
8+
"web-component",
9+
"typescript",
10+
"nexim"
11+
],
12+
"homepage": "https://github.com/the-nexim/nanolib/tree/next/packages/snackbar#readme",
13+
"bugs": {
14+
"url": "https://github.com/the-nexim/nanolib/issues"
15+
},
16+
"repository": {
17+
"type": "git",
18+
"url": "https://github.com/the-nexim/nanolib",
19+
"directory": "packages/snackbar"
20+
},
21+
"license": "AGPL-3.0-only",
22+
"author": "S. Amir Mohammad Najafi <[email protected]> (www.njfamirm.ir)",
23+
"contributors": [
24+
"Arash Ghardashpoor <[email protected]> (https://www.agpagp.ir)"
25+
],
26+
"type": "module",
27+
"exports": {
28+
".": {
29+
"types": "./dist/main.d.ts",
30+
"import": "./dist/main.mjs",
31+
"require": "./dist/main.cjs"
32+
}
33+
},
34+
"main": "./dist/main.cjs",
35+
"module": "./dist/main.mjs",
36+
"types": "./dist/main.d.ts",
37+
"files": [
38+
"**/*.{js,mjs,cjs,map,d.ts,html,md,LEGAL.txt}",
39+
"LICENSE",
40+
"!**/*.test.js",
41+
"!demo/**/*"
42+
],
43+
"scripts": {
44+
"b": "yarn run build",
45+
"build": "yarn run build:ts && yarn run build:es",
46+
"build:es": "nano-build --preset=module",
47+
"build:ts": "tsc --build",
48+
"c": "yarn run clean",
49+
"cb": "yarn run clean && yarn run build",
50+
"clean": "rm -rfv dist *.tsbuildinfo",
51+
"d": "yarn run build:es && yarn node --enable-source-maps --trace-warnings",
52+
"t": "yarn run test",
53+
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --enable-source-maps --experimental-vm-modules\" ava",
54+
"w": "yarn run watch",
55+
"watch": "yarn run watch:ts & yarn run watch:es",
56+
"watch:es": "yarn run build:es --watch",
57+
"watch:ts": "yarn run build:ts --watch --preserveWatchOutput"
58+
},
59+
"dependencies": {
60+
"@alwatr/flux": "^4.0.2",
61+
"@alwatr/logger": "^5.0.0",
62+
"@alwatr/package-tracer": "^5.0.0",
63+
"@alwatr/parse-duration": "^5.0.0",
64+
"@alwatr/wait": "^1.1.16",
65+
"@nexim/element": "workspace:^",
66+
"lit": "^3.2.1"
67+
},
68+
"devDependencies": {
69+
"@alwatr/nano-build": "^5.0.0",
70+
"@alwatr/type-helper": "^5.0.0",
71+
"@nexim/typescript-config": "workspace:^",
72+
"ava": "^6.2.0",
73+
"typescript": "^5.6.3"
74+
},
75+
"publishConfig": {
76+
"access": "public"
77+
}
78+
}

packages/snackbar/src/lib/element.ts

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {waitForTimeout} from '@alwatr/wait';
2+
import {LightDomMixin, LoggerMixin} from '@nexim/element';
3+
import {html, LitElement, nothing, type PropertyValues, type TemplateResult} from 'lit';
4+
import {customElement, property} from 'lit/decorators.js';
5+
6+
import {snackbarActionButtonClickedSignal} from './handler.js';
7+
import {waitForNextFrame} from './utils.js';
8+
9+
declare global {
10+
interface HTMLElementTagNameMap {
11+
'snack-bar': SnackbarComponent;
12+
}
13+
}
14+
15+
@customElement('snack-bar')
16+
export class SnackbarComponent extends LightDomMixin(LoggerMixin(LitElement)) {
17+
/**
18+
* The content to be displayed inside the snackbar.
19+
*/
20+
@property({type: String}) content = '';
21+
22+
/**
23+
* The label for the action button. If null, the action button will not be rendered.
24+
*/
25+
@property({type: String, attribute: 'action-button-label'}) actionButtonLabel: string | null = null;
26+
27+
/**
28+
* Whether to add a close button to the snackbar.
29+
*/
30+
@property({type: Boolean, attribute: 'add-close-button'}) addCloseButton = false;
31+
32+
/**
33+
* Duration for the open and close animation in milliseconds.
34+
*/
35+
private static openAndCloseAnimationDuration__ = 200; // ms
36+
37+
protected override firstUpdated(changedProperties: PropertyValues): void {
38+
super.firstUpdated(changedProperties);
39+
40+
// wait for render complete, then open the snackbar to start the opening animation
41+
waitForNextFrame().then(() => {
42+
this.setAttribute('open', '');
43+
});
44+
}
45+
46+
/**
47+
* Close the snackbar and remove it from the DOM.
48+
* Waits for the closing animation to end before removing the element.
49+
*/
50+
async close(): Promise<void> {
51+
this.logger_.logMethod?.('close');
52+
53+
this.removeAttribute('open');
54+
55+
await waitForTimeout(SnackbarComponent.openAndCloseAnimationDuration__);
56+
this.remove();
57+
}
58+
59+
/**
60+
* Handle the action button click event.
61+
* Sends a signal when the action button is clicked.
62+
*/
63+
private actionButtonClickHandler__(): void {
64+
this.logger_.logMethod?.('actionButtonClickHandler__');
65+
66+
snackbarActionButtonClickedSignal.notify();
67+
}
68+
69+
/**
70+
* Render the snackbar component.
71+
*/
72+
protected override render(): unknown {
73+
super.render();
74+
75+
const actionButtonHtml = this.renderActionButton__();
76+
const closeButtonHtml = this.renderCloseButton__();
77+
78+
let actionButtonHandler: TemplateResult | typeof nothing = nothing;
79+
if (actionButtonHtml != nothing || closeButtonHtml != nothing) {
80+
actionButtonHandler = html`<div>${actionButtonHtml} ${closeButtonHtml}</div>`;
81+
}
82+
83+
return [html`<span>${this.content}</span>`, actionButtonHandler];
84+
}
85+
86+
/**
87+
* Render the action button.
88+
*/
89+
private renderActionButton__(): TemplateResult | typeof nothing {
90+
if (this.actionButtonLabel == null) return nothing;
91+
this.logger_.logMethodArgs?.('renderActionButton__', {actionLabel: this.actionButtonLabel});
92+
93+
return html` <button class="action-button" @click=${this.actionButtonClickHandler__.bind(this)}>${this.actionButtonLabel}</button> `;
94+
}
95+
96+
/**
97+
* Render the close button.
98+
*/
99+
private renderCloseButton__(): TemplateResult | typeof nothing {
100+
if (this.addCloseButton === false) return nothing;
101+
this.logger_.logMethod?.('renderCloseButton__');
102+
103+
return html`
104+
<button class="close-button" @click=${this.close.bind(this)}>
105+
<span class="alwatr-icon-font">close</span>
106+
</button>
107+
`;
108+
}
109+
}

packages/snackbar/src/lib/handler.ts

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {AlwatrSignal, AlwatrTrigger} from '@alwatr/flux';
2+
import {createLogger} from '@alwatr/logger';
3+
import {parseDuration, type Duration} from '@alwatr/parse-duration';
4+
import {waitForTimeout} from '@alwatr/wait';
5+
6+
import type {SnackbarComponent} from './element.js';
7+
8+
const logger = createLogger(`${__package_name__}/handler`);
9+
10+
/**
11+
* @property content - Content to be displayed in the snackbar.
12+
* @property {action} - The action button configuration.
13+
* @property action.label - The label for the action button.
14+
* @property action.handler - The handler function for the action button.
15+
* @property duration - Duration for which the snackbar is displayed. `-1` for infinite duration.
16+
* Duration for which the snackbar is displayed.
17+
* `-1` for infinite duration.
18+
* @property addCloseButton - Whether to add a close button to the snackbar.
19+
*/
20+
export type SnackbarOptions = {
21+
content: string;
22+
action?: {
23+
label: string;
24+
handler: () => void;
25+
};
26+
duration?: Duration;
27+
addCloseButton?: boolean;
28+
};
29+
30+
/**
31+
* Signal for when the snackbar action button is clicked.
32+
*/
33+
export const snackbarActionButtonClickedSignal = new AlwatrTrigger({
34+
name: 'snackbar-action-button-clicked',
35+
});
36+
37+
/**
38+
* Signal for displaying the snackbar.
39+
*
40+
* @example
41+
* import {snackbarSignal} from '@nexim/snackbar';
42+
*
43+
* snackbarSignal.notify({
44+
* content: 'This is a snackbar message',
45+
* // The following properties are optional.
46+
* action: {
47+
* label: 'Undo',
48+
* handler: () => {
49+
* console.log('Action button clicked');
50+
* },
51+
* },
52+
* duration: '4s',
53+
* addCloseButton: true,
54+
* });
55+
*/
56+
export const snackbarSignal = new AlwatrSignal<SnackbarOptions>({name: 'snackbar'});
57+
58+
// Subscribe to the snackbar signal to show the snackbar when the signal is emitted.
59+
snackbarSignal.subscribe((options) => {
60+
showSnackbar(options);
61+
});
62+
63+
let closeLastSnackbar: (() => Promise<void>) | null = null;
64+
let unsubscribeActionButtonHandler: (() => void) | null = null;
65+
66+
/**
67+
* Displays the snackbar with the given options.
68+
* @param options - Options for configuring the snackbar.
69+
*/
70+
async function showSnackbar(options: SnackbarOptions): Promise<void> {
71+
logger.logMethodArgs?.('showSnackbar', {options});
72+
73+
// Parse the duration
74+
if (options.duration != null) options.duration = parseDuration(options.duration);
75+
76+
// Set default duration if not provided
77+
options.duration = parseDuration('4s');
78+
79+
const element = document.createElement('snack-bar') as SnackbarComponent;
80+
81+
element.setAttribute('content', options.content);
82+
83+
if (options.addCloseButton === true) {
84+
element.setAttribute('add-close-button', '');
85+
}
86+
87+
if (options.action != null) {
88+
element.setAttribute('action-button-label', options.action.label);
89+
90+
// Subscribe to the action button click
91+
unsubscribeActionButtonHandler = snackbarActionButtonClickedSignal.subscribe(() => {
92+
options.action!.handler();
93+
94+
return closeSnackbar_();
95+
}).unsubscribe;
96+
}
97+
98+
let closed = false;
99+
const closeSnackbar_ = async () => {
100+
if (closed === true) return;
101+
logger.logMethodArgs?.('closeSnackbar', {options});
102+
103+
await element.close();
104+
unsubscribeActionButtonHandler?.();
105+
closed = true;
106+
};
107+
108+
// Close the last snackbar if it exists
109+
await closeLastSnackbar?.();
110+
closeLastSnackbar = closeSnackbar_;
111+
document.body.appendChild(element);
112+
113+
// Set a timeout to close the snackbar if duration is not infinite
114+
if (options.duration !== -1) {
115+
waitForTimeout(parseDuration(options.duration)).then(closeSnackbar_);
116+
}
117+
}

packages/snackbar/src/lib/utils.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {waitForAnimationFrame, waitForTimeout} from '@alwatr/wait';
2+
3+
/**
4+
* Waits for the next frame to ensure the DOM has been fully calculated.
5+
* This minimizes the chance that querying the DOM will cause a costly reflow.
6+
*
7+
* This function uses `requestAnimationFrame` to schedule code to run immediately before the repaint,
8+
* followed by `setTimeout` with a delay of 0 to execute code as soon as possible after the repaint.
9+
*
10+
* @see https://stackoverflow.com/a/47184426
11+
*/
12+
export function waitForNextFrame(): Promise<void> {
13+
return new Promise((resolve) => {
14+
waitForAnimationFrame().then(() => {
15+
waitForTimeout(0).then(resolve);
16+
});
17+
});
18+
}

packages/snackbar/src/main.test.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import test from 'ava';
2+
3+
// empty test
4+
test('empty test', (test) => {
5+
test.pass();
6+
});

packages/snackbar/src/main.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {packageTracer} from '@alwatr/package-tracer';
2+
3+
__dev_mode__: packageTracer.add(__package_name__, __package_version__);
4+
5+
export * from './lib/element.js';
6+
export * from './lib/handler.js';

0 commit comments

Comments
 (0)