Skip to content

Commit 4c02604

Browse files
committed
feat(snack-bar): new component
1 parent 4657941 commit 4c02604

File tree

6 files changed

+254
-73
lines changed

6 files changed

+254
-73
lines changed

demo/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,8 @@
2727
"unplugin-fonts": "^1.1.1",
2828
"vite": "^5.2.4",
2929
"vite-tsconfig-paths": "^4.3.2"
30+
},
31+
"dependencies": {
32+
"@gecut/utilities": "^5.4.0"
3033
}
3134
}

demo/snack-bar/scripts.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,64 @@
1-
import {SnackBarManager} from '@gecut/components';
1+
import {SnackBarManager, gecutButton} from '@gecut/components';
22
import {render, html} from 'lit/html.js';
33

4-
const sbm = new SnackBarManager({gapBottom: '0'});
5-
let index = 0;
4+
const sbm = new SnackBarManager({
5+
position: {
6+
bottom: '0px',
7+
left: '0px',
8+
right: '0px',
9+
},
10+
});
611

7-
const x = setInterval(() => {
8-
sbm.open('s-' + index++, {
9-
message: 'Hello ' + index,
10-
});
12+
const id1 = 'id1';
13+
const id2 = 'id2';
1114

12-
if (x > 5) clearInterval(x);
13-
}, 5000);
15+
sbm.connect(id1, {
16+
message: 'Hello ' + id1,
17+
close: true,
18+
});
1419

15-
render(html` <div class="mx-auto max-w-sm flex flex-col gap-4">${sbm.html}</div> `, document.body);
20+
sbm.connect(id2, {
21+
message:
22+
// eslint-disable-next-line max-len
23+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ' +
24+
id2,
25+
action: {
26+
label: 'Longer Action',
27+
},
28+
});
29+
30+
render(
31+
html`
32+
<div class="mx-auto max-w-sm flex flex-col gap-4 relative h-full w-full">
33+
${sbm.html}
34+
${gecutButton({
35+
type: 'filled',
36+
label: 'Push 1',
37+
events: {
38+
click: () => {
39+
sbm.open(id1);
40+
},
41+
},
42+
})}
43+
${gecutButton({
44+
type: 'filled',
45+
label: 'Push 2',
46+
events: {
47+
click: () => {
48+
sbm.open(id2);
49+
},
50+
},
51+
})}
52+
${gecutButton({
53+
type: 'filled',
54+
label: 'Remove 1',
55+
events: {
56+
click: () => {
57+
sbm.disconnect(id1);
58+
},
59+
},
60+
})}
61+
</div>
62+
`,
63+
document.body,
64+
);
Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,86 @@
11
import {gecutContext} from '@gecut/lit-helper/directives/context.js';
2+
import {GecutLogger} from '@gecut/logger';
23
import {ContextSignal} from '@gecut/signal';
4+
import {map} from 'lit/directives/map.js';
5+
import {styleMap} from 'lit/directives/style-map.js';
6+
import {html} from 'lit/html.js';
37

48
import {gecutSnackBar, type SnackBarContent} from './snack-bar.js';
59

6-
import {repeat} from 'lit/directives/repeat.js';
7-
810
export interface SnackBarManagerContent {
9-
gapBottom: string;
11+
position?: {
12+
top?: string;
13+
bottom?: string;
14+
left?: string;
15+
right?: string;
16+
};
1017
}
1118

1219
export class SnackBarManager {
1320
constructor(content: SnackBarManagerContent) {
1421
this.content = content;
15-
this.snackBars.value = [];
22+
this.snackBars = {};
23+
this._$updaterContext.value = 'update';
24+
25+
this.html = html`
26+
<div class="flex flex-col absolute inset-x-0" style=${styleMap(this.content?.position ?? {})}>
27+
${gecutContext(this._$updaterContext, () =>
28+
map(Object.keys(this.snackBars), (k) => gecutSnackBar(this.snackBars[k])),
29+
)}
30+
</div>
31+
`;
32+
}
33+
34+
content: SnackBarManagerContent = {};
35+
snackBars: Record<string, ContextSignal<SnackBarContent>> = {};
36+
html;
37+
38+
protected _$log = new GecutLogger('gecut-snackbar-manager');
39+
protected _$updaterContext = new ContextSignal<'update'>('gecut-snackbar-updater', 'AnimationFrame');
40+
41+
connect(id: string, content: SnackBarContent) {
42+
this._$log.methodArgs?.('connect', {id, content});
43+
44+
const context = new ContextSignal<SnackBarContent>(id, 'AnimationFrame');
45+
context.value = {...content, open: false};
1646

17-
// this.html = html`${gecutContext<'open' | 'close'>(this.controller, (status) => {
18-
// const dialogContent: DialogContent = {...this.content, controller: this.controller, provider: this.provider};
47+
this.snackBars[id] = context;
48+
this.update();
49+
}
50+
disconnect(id: string) {
51+
this._$log.methodArgs?.('disconnect', {id});
1952

20-
// return gecutDialog(dialogContent, status === 'open');
21-
// })}`;
53+
this.close(id);
2254

23-
// this.controller.value = 'close';
55+
setTimeout(() => {
56+
delete this.snackBars[id];
57+
this.update();
58+
}, 500);
2459
}
2560

26-
content: SnackBarManagerContent;
27-
snackBars = new ContextSignal<[string, SnackBarContent, {open: true}][]>('snack-bars');
28-
html = gecutContext(this.snackBars, (snackBars) =>
29-
repeat(
30-
snackBars,
31-
(snackBar) => snackBar[0],
32-
(snackBar) => gecutSnackBar(snackBar[1]),
33-
),
34-
);
35-
36-
open(id: string, content: SnackBarContent) {
37-
this.snackBars.functionalValue((old) => [[id, content, {open: true}], ...(old ?? [])]);
61+
open(id: string) {
62+
this._$log.methodArgs?.('open', {id});
63+
64+
if (!this.snackBars[id]) return this._$log.warning('open', 'id_not_found', `'${id}' not found`);
65+
66+
this.snackBars[id].functionalValue((old) => {
67+
return {...(old ?? {message: ''}), open: true};
68+
});
69+
this.update();
3870
}
71+
close(id: string) {
72+
this._$log.methodArgs?.('close', {id});
73+
74+
if (!this.snackBars[id]) return this._$log.warning('close', 'id_not_found', `'${id}' not found`);
3975

40-
// onAfterClose() {
41-
// return new Promise<string>((resolve) => {
42-
// this.provider.subscribe(resolve, {
43-
// receivePrevious: false,
44-
// once: true,
45-
// priority: 1000,
46-
// });
47-
// });
48-
// }
76+
this.snackBars[id].functionalValue((old) => {
77+
return {...(old ?? {message: ''}), open: false};
78+
});
79+
this.update();
80+
}
81+
82+
protected update() {
83+
this._$log.methodArgs?.('update', {snackBars: this.snackBars});
84+
this._$updaterContext.renotify();
85+
}
4986
}

packages/components/src/snack-bar/snack-bar.ts

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,133 @@
1-
import {GecutDirective} from '@gecut/lit-helper/directives/directive.js';
2-
import {noChange, nothing, html} from 'lit/html.js';
3-
import {type PartInfo, directive} from 'lit/directive.js';
4-
import {gecutButton, type ButtonContent} from '../button/button.js';
5-
import {gecutIconButton, type IconButtonContent} from '../components.js';
6-
import {classMap, type ClassInfo} from 'lit/directives/class-map.js';
1+
import {GecutAsyncDirective} from '@gecut/lit-helper/directives/async-directive.js';
2+
import {directive} from 'lit/directive.js';
3+
import {classMap} from 'lit/directives/class-map.js';
4+
import {nothing, html, noChange} from 'lit/html.js';
5+
6+
import {gecutButton} from '../button/button.js';
7+
import {gecutIconButton} from '../components.js';
8+
9+
import type {ButtonContent} from '../button/button.js';
10+
import type {IconButtonContent} from '../components.js';
11+
import type {ContextSignal} from '@gecut/signal';
12+
import type {PartInfo} from 'lit/directive.js';
13+
import type {ClassInfo} from 'lit/directives/class-map.js';
714

815
export interface SnackBarContent {
916
message: string;
1017

18+
open?: boolean;
1119
action?: Omit<ButtonContent, 'type'>;
1220
close?: boolean | Omit<IconButtonContent, 'type'>;
1321
}
1422

15-
export class GecutSnackBarDirective extends GecutDirective {
23+
export class GecutSnackBarDirective extends GecutAsyncDirective {
1624
constructor(partInfo: PartInfo) {
1725
super(partInfo, 'gecut-snack-bar');
1826
}
1927

20-
protected content?: SnackBarContent;
28+
protected _$signalContext?: ContextSignal<SnackBarContent>;
29+
protected _$unsubscribe?: () => void;
30+
31+
render(signalContext: ContextSignal<SnackBarContent>): unknown {
32+
this.log.methodArgs?.('render', signalContext);
33+
34+
if (this._$signalContext !== signalContext) {
35+
// When the observable changes, unsubscribe to the old one and subscribe to the new one
36+
this._$unsubscribe?.();
37+
this._$signalContext = signalContext;
38+
39+
if (this.isConnected) {
40+
this.subscribe();
41+
}
42+
}
43+
44+
return noChange;
45+
}
46+
47+
// When the directive is disconnected from the DOM, unsubscribe to ensure
48+
// the directive instance can be garbage collected
49+
override disconnected(): void {
50+
super.disconnected();
2151

22-
render(content?: SnackBarContent): unknown {
23-
this.log.methodArgs?.('render', content);
52+
this._$unsubscribe!();
53+
}
54+
// If the subtree the directive is in was disconnected and subsequently
55+
// re-connected, re-subscribe to make the directive operable again
56+
override reconnected(): void {
57+
super.reconnected();
2458

25-
if (content === undefined) return noChange;
59+
this.subscribe();
60+
}
2661

27-
this.content = content;
62+
close() {
63+
if (this._$signalContext?.value?.open) {
64+
this._$signalContext.value.open = false;
2865

29-
return this.renderSnackBar();
66+
this._$signalContext?.renotify();
67+
}
3068
}
3169

32-
protected renderSnackBar() {
33-
if (!this.content) return nothing;
70+
protected subscribe() {
71+
this.log.method?.('subscribe');
72+
73+
this._$unsubscribe = this._$signalContext?.subscribe(
74+
(content) => {
75+
this.setValue(this.renderSnackBar(content));
76+
},
77+
{receivePrevious: true},
78+
).unsubscribe;
79+
}
3480

81+
protected renderSnackBar(content: SnackBarContent) {
3582
this.log.method?.('renderSnackBar');
3683

3784
return html`
3885
<div class=${classMap(this.getRenderClasses())}>
39-
<span class="gecut-snack-bar-message">${this.content.message}</span>
40-
${this.renderAction()} ${this.renderClose()}
86+
<span class="gecut-snack-bar-message">${content.message}</span>
87+
<div @click=${this.close.bind(this)}>
88+
${this.renderAction(content.action)} ${this.renderClose(content.close)}
89+
</div>
4190
</div>
4291
`;
4392
}
44-
protected renderAction(): unknown {
45-
if (!this.content?.action) return nothing;
93+
protected renderAction(content: SnackBarContent['action']): unknown {
94+
if (!content) return nothing;
4695

4796
this.log.method?.('renderAction');
4897

49-
return gecutButton({...this.content.action, type: 'text'});
98+
return gecutButton({
99+
...content,
100+
type: 'text',
101+
});
50102
}
51-
protected renderClose(): unknown {
52-
if (!this.content?.close) return nothing;
103+
protected renderClose(content: SnackBarContent['close']): unknown {
104+
if (!content) return nothing;
53105

54106
this.log.method?.('renderClose');
55107

56-
return gecutIconButton({
57-
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-dasharray="12" stroke-dashoffset="12" stroke-linecap="round" stroke-width="2" d="M12 12L19 19M12 12L5 5M12 12L5 19M12 12L19 5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="1.2s" values="12;0"/></path></svg>',
108+
const _content: Omit<IconButtonContent, 'type'> =
109+
typeof content !== 'boolean'
110+
? content
111+
: {
112+
svg:
113+
// eslint-disable-next-line max-len
114+
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-dasharray="12" stroke-dashoffset="12" stroke-linecap="round" stroke-width="2" d="M12 12L19 19M12 12L5 5M12 12L5 19M12 12L19 5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="1.2s" values="12;0"/></path></svg>',
115+
};
58116

59-
...(typeof this.content.close !== 'boolean' ? this.content.close : {}),
60-
});
117+
return gecutIconButton(_content);
61118
}
62119

63120
protected override getRenderClasses(): ClassInfo {
121+
const content = this._$signalContext?.value;
122+
123+
if (!content) return super.getRenderClasses();
124+
64125
return {
65126
...super.getRenderClasses(),
66127

67-
'longer-action': (this.content?.action?.label?.length ?? 0) > 10,
128+
'longer-action': (content.action?.label?.length ?? 0) > 10,
129+
open: content.open ?? false,
130+
close: !(content.open ?? false),
68131
};
69132
}
70133
}

0 commit comments

Comments
 (0)