Skip to content

Commit e9b6b06

Browse files
authored
feat(chat-bubble): add ChatBubble component (#217)
1 parent d3bc23d commit e9b6b06

13 files changed

+555
-6
lines changed

docs/dev/components/[component].md

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import '../../../src/banner';
1212
import '../../../src/base-button';
1313
import '../../../src/bottom-navigation';
1414
import '../../../src/bottom-navigation-item';
15+
import '../../../src/bottom-sheet';
1516
import '../../../src/base-button';
1617
import '../../../src/button';
17-
import '../../../src/base-button';
18-
import '../../../src/bottom-sheet';
18+
import '../../../src/chat-bubble';
1919
import '../../../src/checkbox';
2020
import '../../../src/divider';
2121
import '../../../src/icon-button';

index.html

+14-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,25 @@
66
name="viewport"
77
content="width=device-width, initial-scale=1.0"
88
/>
9+
<link
10+
rel="stylesheet"
11+
href="./styles/font.css"
12+
/>
13+
<link
14+
rel="stylesheet"
15+
href="./styles/theme.css"
16+
/>
917
</head>
10-
<body>
18+
<body dir="rtl">
1119
<div id="root"></div>
1220
<script type="module">
1321
import { html, render } from "lit";
1422

15-
render(html` <div></div> `, document.querySelector("#root"));
23+
const root = document.getElementById("root");
24+
25+
if (!root) throw new Error("Expects a root element.");
26+
27+
render(html``, root);
1628
</script>
1729
</body>
1830
</html>
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { css } from "lit";
2+
3+
const styles = css`
4+
*,
5+
*::before,
6+
*::after {
7+
box-sizing: border-box;
8+
}
9+
10+
.root {
11+
display: flex;
12+
flex-direction: column;
13+
14+
gap: var(--tap-sys-spacing-2);
15+
padding: var(--tap-sys-spacing-3) var(--tap-sys-spacing-6);
16+
border-radius: var(--chat-bubble-base-radius);
17+
18+
background-color: var(--chat-bubble-base-bg-color);
19+
20+
min-width: 6rem;
21+
max-width: 17rem;
22+
}
23+
24+
.root.fully-rounded {
25+
--chat-bubble-base-radius: var(--tap-sys-radius-5);
26+
}
27+
28+
.root.in {
29+
--chat-bubble-base-bg-color: var(--tap-sys-color-surface-tertiary);
30+
--chat-bubble-base-color: var(--tap-sys-color-content-primary);
31+
--chat-bubble-base-footer-color: var(--tap-sys-color-content-tertiary);
32+
--chat-bubble-base-footer-flex-direction: row;
33+
}
34+
35+
.root.out {
36+
--chat-bubble-base-bg-color: var(--tap-sys-color-surface-accent);
37+
--chat-bubble-base-color: var(--tap-sys-color-content-on-accent);
38+
--chat-bubble-base-footer-color: var(--chat-bubble-base-color);
39+
--chat-bubble-base-footer-flex-direction: row-reverse;
40+
}
41+
42+
.root:not(.fully-rounded).in {
43+
--chat-bubble-base-radius: var(--tap-sys-radius-5) var(--tap-sys-radius-1)
44+
var(--tap-sys-radius-5) var(--tap-sys-radius-5);
45+
}
46+
47+
.root:not(.fully-rounded).out {
48+
--chat-bubble-base-radius: var(--tap-sys-radius-1) var(--tap-sys-radius-5)
49+
var(--tap-sys-radius-5) var(--tap-sys-radius-5);
50+
}
51+
52+
.body {
53+
font-family: var(--tap-sys-typography-body-sm-font);
54+
font-size: var(--tap-sys-typography-body-sm-size);
55+
line-height: var(--tap-sys-typography-body-sm-height);
56+
font-weight: var(--tap-sys-typography-body-sm-weight);
57+
58+
color: var(--chat-bubble-base-color);
59+
}
60+
61+
.footer {
62+
font-family: var(--tap-sys-typography-body-xs-font);
63+
font-size: var(--tap-sys-typography-body-xs-size);
64+
line-height: var(--tap-sys-typography-body-xs-height);
65+
font-weight: var(--tap-sys-typography-body-xs-weight);
66+
67+
color: var(--chat-bubble-base-footer-color);
68+
69+
display: flex;
70+
flex-direction: var(--chat-bubble-base-footer-flex-direction);
71+
72+
gap: var(--tap-sys-spacing-3);
73+
}
74+
`;
75+
76+
export default styles;

src/chat-bubble/chat-bubble-base.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { html, LitElement, nothing } from "lit";
2+
import { customElement, property } from "lit/decorators.js";
3+
import { classMap } from "lit/directives/class-map.js";
4+
import { logger } from "../utils";
5+
import styles from "./chat-bubble-base.style";
6+
import { AUTHORS, BaseSlots } from "./constants";
7+
8+
@customElement("tap-chat-bubble-base")
9+
export class ChatBubbleBase extends LitElement {
10+
public static override readonly styles = [styles];
11+
12+
@property({ type: String })
13+
public author!: (typeof AUTHORS)[number];
14+
15+
@property({ type: String })
16+
public timestamp!: string;
17+
18+
@property({ type: Boolean, attribute: "fully-rounded" })
19+
public fullyRounded: boolean = false;
20+
21+
constructor() {
22+
super();
23+
}
24+
25+
private _renderFooter() {
26+
if (!this.timestamp) {
27+
logger(
28+
`Expected valid \`timestamp\` prop. received: \`${this.timestamp}\`.`,
29+
"ChatBubble",
30+
"error",
31+
);
32+
33+
return nothing;
34+
}
35+
36+
return html`
37+
<div
38+
class="footer"
39+
part="footer"
40+
>
41+
<slot name=${BaseSlots.FOOTER}></slot>
42+
<span>${this.timestamp}</span>
43+
</div>
44+
`;
45+
}
46+
47+
protected override render() {
48+
if (!AUTHORS.includes(this.author)) {
49+
logger(
50+
`Expected valid \`author\` prop. received: \`${this.author}\`.`,
51+
"ChatBubble",
52+
"error",
53+
);
54+
55+
return nothing;
56+
}
57+
58+
const rootClasses = classMap({
59+
"fully-rounded": this.fullyRounded,
60+
in: this.author === "in",
61+
out: this.author === "out",
62+
});
63+
64+
return html`
65+
<div
66+
class="root ${rootClasses}"
67+
part="root"
68+
>
69+
<div
70+
class="body"
71+
part="body"
72+
>
73+
<slot name=${BaseSlots.BODY}></slot>
74+
</div>
75+
${this._renderFooter()}
76+
</div>
77+
`;
78+
}
79+
}
+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { css } from "lit";
2+
3+
const styles = css`
4+
*,
5+
*::before,
6+
*::after {
7+
box-sizing: border-box;
8+
}
9+
10+
.root {
11+
--chat-bubble-in-icon-color: currentColor;
12+
13+
display: flex;
14+
}
15+
16+
.root.seen {
17+
--chat-bubble-in-icon-color: var(--tap-sys-color-content-accent);
18+
}
19+
20+
.root:not(.failed) .base {
21+
margin-right: var(--tap-sys-spacing-4);
22+
}
23+
24+
.failure-indicator {
25+
display: flex;
26+
align-items: center;
27+
justify-content: center;
28+
29+
width: 24px;
30+
height: 24px;
31+
32+
margin-right: var(--tap-sys-spacing-4);
33+
margin-left: var(--tap-sys-spacing-4);
34+
35+
fill: var(--tap-sys-color-content-negative);
36+
}
37+
38+
.status {
39+
display: flex;
40+
align-items: center;
41+
42+
gap: var(--tap-sys-spacing-3);
43+
}
44+
45+
.status > svg {
46+
width: 18px;
47+
height: 18px;
48+
49+
fill: var(--chat-bubble-in-icon-color);
50+
}
51+
`;
52+
53+
export default styles;

src/chat-bubble/chat-bubble-in.ts

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import "./chat-bubble-base";
2+
3+
import { html, LitElement, nothing } from "lit";
4+
import { property } from "lit/decorators.js";
5+
import { classMap } from "lit/directives/class-map.js";
6+
import {
7+
BaseSlots,
8+
STATUS_TO_ICON_MAP,
9+
STATUS_TO_LOCALE_MAP,
10+
type STATES,
11+
} from "./constants";
12+
13+
export class ChatBubbleIn extends LitElement {
14+
/**
15+
* The timestamp of chat element.
16+
*/
17+
@property({ type: String })
18+
public timestamp!: string;
19+
20+
/**
21+
* The status of the chat element.
22+
*
23+
* @default "sent"
24+
*/
25+
@property({ type: String })
26+
public status: (typeof STATES)[number] = "sent";
27+
28+
/**
29+
* Whether or not the bubble should be fully rounded.
30+
*
31+
* @default false
32+
*/
33+
@property({ type: Boolean, attribute: "fully-rounded" })
34+
public fullyRounded: boolean = false;
35+
36+
private _renderFailureIndicator() {
37+
if (this.status !== "failed") return nothing;
38+
39+
const icon = STATUS_TO_ICON_MAP.failed;
40+
41+
return html`
42+
<div
43+
class="failure-indicator"
44+
part="failure-indicator"
45+
>
46+
${icon}
47+
</div>
48+
`;
49+
}
50+
51+
private _renderStatus() {
52+
if (this.status === "failed") return nothing;
53+
54+
const stateMessage = STATUS_TO_LOCALE_MAP[this.status];
55+
const icon = STATUS_TO_ICON_MAP[this.status];
56+
57+
return html`
58+
<div
59+
slot=${BaseSlots.FOOTER}
60+
class="status"
61+
part="status"
62+
>
63+
${icon}
64+
<span>${stateMessage}</span>
65+
</div>
66+
`;
67+
}
68+
69+
protected override render() {
70+
const rootClasses = classMap({
71+
[String(this.status)]: Boolean(this.status),
72+
});
73+
74+
return html`
75+
<div
76+
class="root ${rootClasses}"
77+
part="root"
78+
>
79+
${this._renderFailureIndicator()}
80+
<tap-chat-bubble-base
81+
class="base"
82+
part="base"
83+
author="in"
84+
?fully-rounded=${this.fullyRounded}
85+
timestamp=${this.timestamp}
86+
>
87+
<slot slot=${BaseSlots.BODY}></slot>
88+
${this._renderStatus()}
89+
</tap-chat-bubble-base>
90+
</div>
91+
`;
92+
}
93+
}
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { css } from "lit";
2+
3+
const styles = css`
4+
*,
5+
*::before,
6+
*::after {
7+
box-sizing: border-box;
8+
}
9+
10+
.root {
11+
--chat-bubble-out-leading-space: var(--tap-sys-spacing-11);
12+
13+
display: flex;
14+
flex-direction: row-reverse;
15+
}
16+
17+
.root.has-avatar {
18+
--chat-bubble-out-leading-space: 0;
19+
}
20+
21+
.root .base {
22+
margin-left: var(--chat-bubble-out-leading-space);
23+
}
24+
25+
.avatar {
26+
margin-right: var(--tap-sys-spacing-4);
27+
margin-left: var(--tap-sys-spacing-4);
28+
}
29+
`;
30+
31+
export default styles;

0 commit comments

Comments
 (0)