Skip to content

Commit d9a561e

Browse files
committed
feat(components): snack-bar
1 parent 9cffdc5 commit d9a561e

File tree

2 files changed

+142
-111
lines changed

2 files changed

+142
-111
lines changed
Lines changed: 121 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,158 @@
1-
import {gecutContext} from '@gecut/lit-helper/directives/context.js';
1+
import {GecutState, mapObject} from '@gecut/lit-helper';
22
import {GecutLogger} from '@gecut/logger';
3-
import {ContextSignal} from '@gecut/signal';
4-
import {map} from 'lit/directives/map.js';
5-
import {styleMap} from 'lit/directives/style-map.js';
3+
import {uid} from '@gecut/utilities/uid.js';
4+
import {untilEvent, untilNextFrame} from '@gecut/utilities/wait/wait.js';
5+
import {createRef, type Ref} from 'lit/directives/ref.js';
6+
import {styleMap, type StyleInfo} from 'lit/directives/style-map.js';
67
import {html} from 'lit/html.js';
78

8-
import {gecutSnackBar, type SnackBarContent} from './snack-bar.js';
9+
import {gecutSnackBar, type SnackBarContent} from './snack-bar';
910

1011
export interface SnackBarManagerContent {
11-
position?: {
12-
top?: string;
13-
bottom?: string;
14-
left?: string;
15-
right?: string;
16-
};
12+
style?: Readonly<StyleInfo>;
1713
}
1814

1915
export class SnackBarManager {
2016
constructor(content: SnackBarManagerContent) {
2117
this.content = content;
22-
this.snackBars = {};
23-
this._$updaterContext.value = 'update';
24-
18+
this.state = new GecutState<Record<string, {content: SnackBarContent; ref: Ref<HTMLDivElement>}>>(
19+
'snack-bar-manager',
20+
);
2521
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])),
22+
<div style=${styleMap(this.content.style ?? {})}>
23+
${this.state.hydrate((data) =>
24+
mapObject(null, data, (snackBar) => gecutSnackBar(snackBar.content, snackBar.ref)),
2925
)}
3026
</div>
3127
`;
3228
}
3329

34-
content: SnackBarManagerContent = {};
35-
snackBars: Record<string, ContextSignal<SnackBarContent>> = {};
36-
html;
30+
readonly state: GecutState<Record<string, {content: SnackBarContent; ref: Ref<HTMLDivElement>}>>;
31+
readonly html: unknown;
32+
readonly content: SnackBarManagerContent;
3733

38-
protected _$log = new GecutLogger('gecut-snackbar-manager');
39-
protected _$updaterContext = new ContextSignal<'update'>('gecut-snackbar-updater', 'AnimationFrame');
34+
private readonly logger = new GecutLogger('<snack-bar-manager>');
35+
private timers: Record<string, NodeJS.Timeout> = {};
4036

4137
connect(id: string, content: SnackBarContent) {
42-
this._$log.methodArgs?.('connect', {id, content});
38+
this.logger.methodArgs?.('connect', {id, content});
39+
40+
this.state.value = {
41+
...(this.state.value ??= {}),
4342

44-
const context = new ContextSignal<SnackBarContent>(id, 'AnimationFrame');
45-
context.value = {...content, open: false};
43+
[id]: {
44+
content,
45+
ref: createRef(),
46+
},
47+
};
4648

47-
this.snackBars[id] = context;
48-
this.update();
49+
return this.__$waitForRender(id);
4950
}
50-
disconnect(id: string) {
51-
this._$log.methodArgs?.('disconnect', {id});
5251

53-
this.close(id);
52+
async disconnect(id: string) {
53+
this.logger.methodArgs?.('disconnect', {id});
54+
55+
await this.close(id);
56+
57+
delete this.state.value?.[id];
58+
this.state.value = this.state.value ?? {};
5459

55-
setTimeout(() => {
56-
delete this.snackBars[id];
57-
this.update();
58-
}, 500);
60+
return untilNextFrame();
5961
}
6062

6163
open(id: string) {
62-
this._$log.methodArgs?.('open', {id});
64+
this.logger.methodArgs?.('open', {id});
6365

64-
if (!this.snackBars[id]) return this._$log.warning('open', 'id_not_found', `'${id}' not found`);
66+
const state = (this.state.value ??= {})[id];
67+
const element = state.ref.value;
6568

66-
this.snackBars[id].functionalValue((old) => {
67-
return {...(old ?? {message: ''}), open: true};
68-
});
69-
this.update();
69+
if (!state) return this.logger.warning('open', 'state_not_exist', `state '${id}' not exist`);
70+
if (!element) return this.logger.warning('open', 'element_not_exist', `element of state '${id}' not exist`);
71+
72+
element.querySelector<HTMLDivElement>('.actions')?.addEventListener(
73+
'click',
74+
() => {
75+
this.close(id);
76+
},
77+
{once: true},
78+
);
79+
80+
element.classList.remove('close');
81+
element.classList.add('open');
7082
}
83+
7184
close(id: string) {
72-
this._$log.methodArgs?.('close', {id});
85+
this.logger.methodArgs?.('close', {id});
86+
87+
const state = (this.state.value ??= {})[id];
88+
const element = state.ref.value;
89+
90+
if (!state) return this.logger.warning('close', 'state_not_exist', `state '${id}' not exist`);
91+
if (!element) return this.logger.warning('close', 'element_not_exist', `element of state '${id}' not exist`);
92+
93+
element.classList.remove('open');
94+
element.classList.add('close');
95+
96+
clearTimeout(this.timers[id]);
97+
98+
return untilEvent(element, 'animationend');
99+
}
100+
101+
async notify(content: SnackBarContent, timeout?: number) {
102+
const id = 'snack-bar_' + uid();
73103

74-
if (!this.snackBars[id]) return this._$log.warning('close', 'id_not_found', `'${id}' not found`);
104+
await this.connect(id, content);
75105

76-
this.snackBars[id].functionalValue((old) => {
77-
return {...(old ?? {message: ''}), open: false};
78-
});
79-
this.update();
106+
this.open(id);
107+
108+
this.timers[id] = setTimeout(
109+
async () => this.disconnect(id),
110+
timeout ?? this.__$readTimeCalc(content.message, (content.action?.label?.length ?? 0) > 10 ? 'high' : 'low'),
111+
);
112+
}
113+
114+
private __$readTimeCalc(message: string, priority: 'low' | 'medium' | 'high'): number {
115+
const wordCount = message.split(' ').length;
116+
117+
let baseTime = 100;
118+
119+
switch (priority) {
120+
case 'low':
121+
baseTime += 100;
122+
break;
123+
case 'medium':
124+
baseTime += 200;
125+
break;
126+
case 'high':
127+
baseTime += 300;
128+
break;
129+
}
130+
131+
let readTime = Math.min(4_000 + baseTime * wordCount, 10_000);
132+
133+
if (wordCount > 20) {
134+
readTime += (wordCount - 20) * 10;
135+
}
136+
137+
this.logger.methodFull?.('__$readTimeCalc', {message, priority}, readTime);
138+
139+
return readTime;
80140
}
141+
private async __$waitForRender(id: string): Promise<void> {
142+
let element;
143+
144+
this.logger.time?.('waitForRender-' + id);
145+
146+
while (element == null) {
147+
this.logger.methodArgs?.('__$waitForRender', {id});
148+
149+
await untilNextFrame();
150+
151+
element = this.state.value?.[id].ref.value;
152+
}
153+
154+
this.logger.timeEnd?.('waitForRender-' + id);
81155

82-
protected update() {
83-
this._$log.methodArgs?.('update', {snackBars: this.snackBars});
84-
this._$updaterContext.renotify();
156+
return;
85157
}
86158
}

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

Lines changed: 21 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import {GecutAsyncDirective} from '@gecut/lit-helper/directives/async-directive.js';
1+
import {GecutDirective} from '@gecut/lit-helper/directives/directive.js';
22
import {directive} from 'lit/directive.js';
33
import {classMap} from 'lit/directives/class-map.js';
4+
import {ref, type Ref} from 'lit/directives/ref.js';
45
import {nothing, html, noChange} from 'lit/html.js';
56

67
import {gecutButton} from '../button/button.js';
78
import {gecutIconButton} from '../components.js';
89

910
import type {ButtonContent} from '../button/button.js';
1011
import type {IconButtonContent} from '../components.js';
11-
import type {ContextSignal} from '@gecut/signal';
1212
import type {PartInfo} from 'lit/directive.js';
1313
import type {ClassInfo} from 'lit/directives/class-map.js';
1414

@@ -20,73 +20,36 @@ export interface SnackBarContent {
2020
close?: boolean | Omit<IconButtonContent, 'type'>;
2121
}
2222

23-
export class GecutSnackBarDirective extends GecutAsyncDirective {
23+
export class GecutSnackBarDirective extends GecutDirective {
2424
constructor(partInfo: PartInfo) {
2525
super(partInfo, 'gecut-snack-bar');
2626
}
2727

28-
protected _$signalContext?: ContextSignal<SnackBarContent>;
29-
protected _$unsubscribe?: () => void;
28+
private static readonly closeIconSVG =
29+
// eslint-disable-next-line max-len
30+
'<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>';
3031

31-
render(signalContext: ContextSignal<SnackBarContent>): unknown {
32-
this.log.methodArgs?.('render', signalContext);
32+
content?: SnackBarContent;
3333

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;
34+
render(content: SnackBarContent, ref: Ref<HTMLDivElement>): unknown {
35+
this.log.methodArgs?.('render', content);
3836

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();
51-
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();
37+
if (content && content != this.content) {
38+
this.content = content;
5839

59-
this.subscribe();
60-
}
61-
62-
close() {
63-
if (this._$signalContext?.value?.open) {
64-
this._$signalContext.value.open = false;
65-
66-
this._$signalContext?.renotify();
40+
return this.renderSnackBar(this.content, ref);
6741
}
68-
}
6942

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;
43+
return noChange;
7944
}
8045

81-
protected renderSnackBar(content: SnackBarContent) {
46+
protected renderSnackBar(content: SnackBarContent, _ref: Ref<HTMLDivElement>) {
8247
this.log.method?.('renderSnackBar');
8348

8449
return html`
85-
<div class=${classMap(this.getRenderClasses())}>
50+
<div class=${classMap(this.getRenderClasses())} ${ref(_ref)}>
8651
<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>
52+
<div class="actions">${this.renderAction(content.action)} ${this.renderClose(content.close)}</div>
9053
</div>
9154
`;
9255
}
@@ -109,25 +72,21 @@ export class GecutSnackBarDirective extends GecutAsyncDirective {
10972
typeof content !== 'boolean'
11073
? content
11174
: {
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>',
75+
svg: GecutSnackBarDirective.closeIconSVG,
11576
};
11677

11778
return gecutIconButton(_content);
11879
}
11980

12081
protected override getRenderClasses(): ClassInfo {
121-
const content = this._$signalContext?.value;
122-
123-
if (!content) return super.getRenderClasses();
82+
if (!this.content) return super.getRenderClasses();
12483

12584
return {
12685
...super.getRenderClasses(),
12786

128-
'longer-action': (content.action?.label?.length ?? 0) > 10,
129-
open: content.open ?? false,
130-
close: !(content.open ?? false),
87+
'longer-action': (this.content.action?.label?.length ?? 0) > 10,
88+
open: this.content.open ?? false,
89+
close: !(this.content.open ?? false),
13190
};
13291
}
13392
}

0 commit comments

Comments
 (0)