diff --git a/docs/dev/components/[component].md b/docs/dev/components/[component].md index 75c93e38..b61b18e4 100644 --- a/docs/dev/components/[component].md +++ b/docs/dev/components/[component].md @@ -12,10 +12,10 @@ import '../../../src/banner'; import '../../../src/base-button'; import '../../../src/bottom-navigation'; import '../../../src/bottom-navigation-item'; +import '../../../src/bottom-sheet'; import '../../../src/base-button'; import '../../../src/button'; -import '../../../src/base-button'; -import '../../../src/bottom-sheet'; +import '../../../src/chat-bubble'; import '../../../src/checkbox'; import '../../../src/divider'; import '../../../src/icon-button'; diff --git a/index.html b/index.html index 4db4c5c5..7a81a897 100644 --- a/index.html +++ b/index.html @@ -6,13 +6,25 @@ name="viewport" content="width=device-width, initial-scale=1.0" /> + + - +
diff --git a/src/chat-bubble/chat-bubble-base.style.ts b/src/chat-bubble/chat-bubble-base.style.ts new file mode 100644 index 00000000..043cb974 --- /dev/null +++ b/src/chat-bubble/chat-bubble-base.style.ts @@ -0,0 +1,76 @@ +import { css } from "lit"; + +const styles = css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + .root { + display: flex; + flex-direction: column; + + gap: var(--tap-sys-spacing-2); + padding: var(--tap-sys-spacing-3) var(--tap-sys-spacing-6); + border-radius: var(--chat-bubble-base-radius); + + background-color: var(--chat-bubble-base-bg-color); + + min-width: 6rem; + max-width: 17rem; + } + + .root.fully-rounded { + --chat-bubble-base-radius: var(--tap-sys-radius-5); + } + + .root.in { + --chat-bubble-base-bg-color: var(--tap-sys-color-surface-tertiary); + --chat-bubble-base-color: var(--tap-sys-color-content-primary); + --chat-bubble-base-footer-color: var(--tap-sys-color-content-tertiary); + --chat-bubble-base-footer-flex-direction: row; + } + + .root.out { + --chat-bubble-base-bg-color: var(--tap-sys-color-surface-accent); + --chat-bubble-base-color: var(--tap-sys-color-content-on-accent); + --chat-bubble-base-footer-color: var(--chat-bubble-base-color); + --chat-bubble-base-footer-flex-direction: row-reverse; + } + + .root:not(.fully-rounded).in { + --chat-bubble-base-radius: var(--tap-sys-radius-5) var(--tap-sys-radius-1) + var(--tap-sys-radius-5) var(--tap-sys-radius-5); + } + + .root:not(.fully-rounded).out { + --chat-bubble-base-radius: var(--tap-sys-radius-1) var(--tap-sys-radius-5) + var(--tap-sys-radius-5) var(--tap-sys-radius-5); + } + + .body { + font-family: var(--tap-sys-typography-body-sm-font); + font-size: var(--tap-sys-typography-body-sm-size); + line-height: var(--tap-sys-typography-body-sm-height); + font-weight: var(--tap-sys-typography-body-sm-weight); + + color: var(--chat-bubble-base-color); + } + + .footer { + font-family: var(--tap-sys-typography-body-xs-font); + font-size: var(--tap-sys-typography-body-xs-size); + line-height: var(--tap-sys-typography-body-xs-height); + font-weight: var(--tap-sys-typography-body-xs-weight); + + color: var(--chat-bubble-base-footer-color); + + display: flex; + flex-direction: var(--chat-bubble-base-footer-flex-direction); + + gap: var(--tap-sys-spacing-3); + } +`; + +export default styles; diff --git a/src/chat-bubble/chat-bubble-base.ts b/src/chat-bubble/chat-bubble-base.ts new file mode 100644 index 00000000..b0952955 --- /dev/null +++ b/src/chat-bubble/chat-bubble-base.ts @@ -0,0 +1,79 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { logger } from "../utils"; +import styles from "./chat-bubble-base.style"; +import { AUTHORS, BaseSlots } from "./constants"; + +@customElement("tap-chat-bubble-base") +export class ChatBubbleBase extends LitElement { + public static override readonly styles = [styles]; + + @property({ type: String }) + public author!: (typeof AUTHORS)[number]; + + @property({ type: String }) + public timestamp!: string; + + @property({ type: Boolean, attribute: "fully-rounded" }) + public fullyRounded: boolean = false; + + constructor() { + super(); + } + + private _renderFooter() { + if (!this.timestamp) { + logger( + `Expected valid \`timestamp\` prop. received: \`${this.timestamp}\`.`, + "ChatBubble", + "error", + ); + + return nothing; + } + + return html` + + `; + } + + protected override render() { + if (!AUTHORS.includes(this.author)) { + logger( + `Expected valid \`author\` prop. received: \`${this.author}\`.`, + "ChatBubble", + "error", + ); + + return nothing; + } + + const rootClasses = classMap({ + "fully-rounded": this.fullyRounded, + in: this.author === "in", + out: this.author === "out", + }); + + return html` +
+
+ +
+ ${this._renderFooter()} +
+ `; + } +} diff --git a/src/chat-bubble/chat-bubble-in.style.ts b/src/chat-bubble/chat-bubble-in.style.ts new file mode 100644 index 00000000..116002e3 --- /dev/null +++ b/src/chat-bubble/chat-bubble-in.style.ts @@ -0,0 +1,53 @@ +import { css } from "lit"; + +const styles = css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + .root { + --chat-bubble-in-icon-color: currentColor; + + display: flex; + } + + .root.seen { + --chat-bubble-in-icon-color: var(--tap-sys-color-content-accent); + } + + .root:not(.failed) .base { + margin-right: var(--tap-sys-spacing-4); + } + + .failure-indicator { + display: flex; + align-items: center; + justify-content: center; + + width: 24px; + height: 24px; + + margin-right: var(--tap-sys-spacing-4); + margin-left: var(--tap-sys-spacing-4); + + fill: var(--tap-sys-color-content-negative); + } + + .status { + display: flex; + align-items: center; + + gap: var(--tap-sys-spacing-3); + } + + .status > svg { + width: 18px; + height: 18px; + + fill: var(--chat-bubble-in-icon-color); + } +`; + +export default styles; diff --git a/src/chat-bubble/chat-bubble-in.ts b/src/chat-bubble/chat-bubble-in.ts new file mode 100644 index 00000000..e93f2704 --- /dev/null +++ b/src/chat-bubble/chat-bubble-in.ts @@ -0,0 +1,93 @@ +import "./chat-bubble-base"; + +import { html, LitElement, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { + BaseSlots, + STATUS_TO_ICON_MAP, + STATUS_TO_LOCALE_MAP, + type STATES, +} from "./constants"; + +export class ChatBubbleIn extends LitElement { + /** + * The timestamp of chat element. + */ + @property({ type: String }) + public timestamp!: string; + + /** + * The status of the chat element. + * + * @default "sent" + */ + @property({ type: String }) + public status: (typeof STATES)[number] = "sent"; + + /** + * Whether or not the bubble should be fully rounded. + * + * @default false + */ + @property({ type: Boolean, attribute: "fully-rounded" }) + public fullyRounded: boolean = false; + + private _renderFailureIndicator() { + if (this.status !== "failed") return nothing; + + const icon = STATUS_TO_ICON_MAP.failed; + + return html` +
+ ${icon} +
+ `; + } + + private _renderStatus() { + if (this.status === "failed") return nothing; + + const stateMessage = STATUS_TO_LOCALE_MAP[this.status]; + const icon = STATUS_TO_ICON_MAP[this.status]; + + return html` +
+ ${icon} + ${stateMessage} +
+ `; + } + + protected override render() { + const rootClasses = classMap({ + [String(this.status)]: Boolean(this.status), + }); + + return html` +
+ ${this._renderFailureIndicator()} + + + ${this._renderStatus()} + +
+ `; + } +} diff --git a/src/chat-bubble/chat-bubble-out.style.ts b/src/chat-bubble/chat-bubble-out.style.ts new file mode 100644 index 00000000..57a937e3 --- /dev/null +++ b/src/chat-bubble/chat-bubble-out.style.ts @@ -0,0 +1,31 @@ +import { css } from "lit"; + +const styles = css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + .root { + --chat-bubble-out-leading-space: var(--tap-sys-spacing-11); + + display: flex; + flex-direction: row-reverse; + } + + .root.has-avatar { + --chat-bubble-out-leading-space: 0; + } + + .root .base { + margin-left: var(--chat-bubble-out-leading-space); + } + + .avatar { + margin-right: var(--tap-sys-spacing-4); + margin-left: var(--tap-sys-spacing-4); + } +`; + +export default styles; diff --git a/src/chat-bubble/chat-bubble-out.ts b/src/chat-bubble/chat-bubble-out.ts new file mode 100644 index 00000000..3b9a3717 --- /dev/null +++ b/src/chat-bubble/chat-bubble-out.ts @@ -0,0 +1,69 @@ +import "../avatar"; +import "./chat-bubble-base"; + +import { html, LitElement, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { BaseSlots } from "./constants"; + +export class ChatBubbleOut extends LitElement { + /** + * The timestamp of chat element. + */ + @property({ type: String }) + public timestamp!: string; + + /** + * The source of the avatar image. + */ + @property({ type: String, attribute: "avatar-src" }) + public avatarSrc?: string; + + /** + * Whether or not the bubble should be fully rounded. + * + * @default false + */ + @property({ type: Boolean, attribute: "fully-rounded" }) + public fullyRounded: boolean = false; + + private _renderAvatar() { + if (!this.avatarSrc) return nothing; + + return html` +
+ +
+ `; + } + + protected override render() { + const rootClasses = classMap({ + "has-avatar": Boolean(this.avatarSrc), + }); + + return html` +
+ ${this._renderAvatar()} + + + +
+ `; + } +} diff --git a/src/chat-bubble/constants.ts b/src/chat-bubble/constants.ts new file mode 100644 index 00000000..01ce58ec --- /dev/null +++ b/src/chat-bubble/constants.ts @@ -0,0 +1,33 @@ +import type { TemplateResult } from "lit"; +import * as Icons from "./icons"; + +export const AUTHORS = ["in", "out"] as const; +export const STATES = ["sent", "seen", "pending", "failed"] as const; + +export const BaseSlots = { + BODY: "body", + FOOTER: "footer", +}; + +export const Slots = { + DEFAULT: "", +}; + +export const STATUS_TO_LOCALE_MAP: Record< + Exclude<(typeof STATES)[number], "failed">, + string +> = { + pending: "در حال ارسال...", + seen: "خوانده شد", + sent: "فرستاده شد", +}; + +export const STATUS_TO_ICON_MAP: Record< + (typeof STATES)[number], + TemplateResult<1> +> = { + failed: Icons.failed, + pending: Icons.pending, + seen: Icons.seen, + sent: Icons.sent, +}; diff --git a/src/chat-bubble/icons.ts b/src/chat-bubble/icons.ts new file mode 100644 index 00000000..2c28a738 --- /dev/null +++ b/src/chat-bubble/icons.ts @@ -0,0 +1,53 @@ +import { html } from "lit"; + +export const seen = html` + + + +`; + +export const sent = html` + + + +`; + +export const pending = html` + + + +`; + +export const failed = html` + + + +`; diff --git a/src/chat-bubble/index.ts b/src/chat-bubble/index.ts new file mode 100644 index 00000000..53f524bb --- /dev/null +++ b/src/chat-bubble/index.ts @@ -0,0 +1,45 @@ +import { customElement } from "lit/decorators.js"; +import { ChatBubbleIn } from "./chat-bubble-in"; +import inStyles from "./chat-bubble-in.style"; +import { ChatBubbleOut } from "./chat-bubble-out"; +import outStyles from "./chat-bubble-out.style"; + +export { Slots } from "./constants"; + +/** + * @summary Display chat-bubble-in element + * + * @prop {string} timestamp - The timestamp of chat element. + * @prop {"sent" | "seen" | "pending" | "failed"} [status="sent"] - The status of the chat element. + * @prop {boolean} [fullyRounded=false] - Whether or not the bubble should be fully rounded. + * + * @csspart [root] - The root of the element. + * @csspart [base] - The base-bubble element. + */ +@customElement("tap-chat-bubble-in") +export class TapChatBubbleIn extends ChatBubbleIn { + public static override readonly styles = [inStyles]; +} + +/** + * @summary Display chat-bubble-out element + * + * @prop {string} timestamp - The timestamp of chat element. + * @prop {string} [avatarSrc] - The source of the avatar image. + * @prop {boolean} [fullyRounded=false] - Whether or not the bubble should be fully rounded. + * + * @csspart [root] - The root of the element. + * @csspart [avatar] - The avatar element. + * @csspart [base] - The base-bubble element. + */ +@customElement("tap-chat-bubble-out") +export class TapChatBubbleOut extends ChatBubbleOut { + public static override readonly styles = [outStyles]; +} + +declare global { + interface HTMLElementTagNameMap { + "tap-chat-bubble-in": TapChatBubbleIn; + "tap-chat-bubble-out": TapChatBubbleOut; + } +} diff --git a/tokens/colors.css b/tokens/colors.css index 8845a60c..942f6770 100644 --- a/tokens/colors.css +++ b/tokens/colors.css @@ -60,6 +60,11 @@ --tap-sys-color-brand: var(--tap-palette-orange-400); /* gradient */ - --tap-sys-color-gradient-brand: linear-gradient(91.39deg, #ff7733 0%, #ff5722 50.15%, #e64917 100%); + --tap-sys-color-gradient-brand: linear-gradient( + 91.39deg, + #ff7733 0%, + #ff5722 50.15%, + #e64917 100% + ); /* TODO: add other gradient tokens */ } diff --git a/tokens/typography.css b/tokens/typography.css index 235bedc0..c5a97aab 100644 --- a/tokens/typography.css +++ b/tokens/typography.css @@ -81,4 +81,4 @@ --tap-sys-typography-display-lg-size: 48px; --tap-sys-typography-display-lg-height: 72px; --tap-sys-typography-display-lg-weight: 600; -} \ No newline at end of file +}