feat: Support Dark mode for the widget (#4137)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese
2022-04-01 20:59:03 +05:30
committed by GitHub
parent 3813b3b372
commit caee9535f1
36 changed files with 411 additions and 113 deletions

View File

@@ -42,7 +42,6 @@
}
.agent-name {
color: $color-body;
font-size: $font-size-small;
font-weight: $font-weight-medium;
margin: $space-small 0;
@@ -210,7 +209,6 @@
.chat-bubble {
@include light-shadow;
background: $color-woot;
border-radius: $space-two;
color: $color-white;
display: inline-block;
@@ -242,7 +240,6 @@
}
&.agent {
background: $color-white;
border-bottom-left-radius: $space-smaller;
color: $color-body;

View File

@@ -24,7 +24,7 @@
<div
v-if="hasAttachments"
class="chat-bubble has-attachment agent"
:class="wrapClass"
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-50'))"
>
<div v-for="attachment in message.attachments" :key="attachment.id">
<image-bubble
@@ -40,7 +40,11 @@
<file-bubble v-else :url="attachment.data_url" />
</div>
</div>
<p v-if="message.showAvatar || hasRecordedResponse" class="agent-name">
<p
v-if="message.showAvatar || hasRecordedResponse"
class="agent-name"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ agentName }}
</p>
</div>
@@ -68,6 +72,8 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'AgentMessage',
components: {
@@ -77,7 +83,7 @@ export default {
UserMessage,
FileBubble,
},
mixins: [timeMixin, configMixin, messageMixin],
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
props: {
message: {
type: Object,

View File

@@ -5,8 +5,13 @@
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
"
class="chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<div class="message-content" v-html="formatMessage(message, false)"></div>
<div
class="message-content"
:class="$dm('text-black-900', 'dark:text-slate-50')"
v-html="formatMessage(message, false)"
></div>
<email-input
v-if="isTemplateEmail"
:message-id="messageId"
@@ -60,6 +65,7 @@ import ChatOptions from 'shared/components/ChatOptions';
import ChatArticle from './template/Article';
import EmailInput from './template/EmailInput';
import CustomerSatisfaction from 'shared/components/CustomerSatisfaction';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'AgentMessageBubble',
@@ -71,7 +77,7 @@ export default {
EmailInput,
CustomerSatisfaction,
},
mixins: [messageFormatterMixin],
mixins: [messageFormatterMixin, darkModeMixin],
props: {
message: { type: String, default: null },
contentType: { type: String, default: null },

View File

@@ -3,7 +3,10 @@
<div class="agent-message">
<div class="avatar-wrap"></div>
<div class="message-wrap">
<div class="typing-bubble chat-bubble agent">
<div
class="typing-bubble chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-50')"
>
<img
src="~widget/assets/images/typing.gif"
alt="Agent is typing a message"
@@ -15,8 +18,10 @@
</template>
<script>
import darkModeMixing from 'widget/mixins/darkModeMixin.js';
export default {
name: 'AgentTypingBubble',
mixins: [darkModeMixing],
};
</script>

View File

@@ -1,8 +1,15 @@
<template>
<header class="flex justify-between p-5 w-full">
<header
class="flex justify-between p-5 w-full"
:class="$dm('bg-white', 'dark:bg-slate-900')"
>
<div class="flex items-center">
<button v-if="showBackButton" @click="onBackButtonClick">
<fluent-icon icon="chevron-left" size="24" />
<fluent-icon
icon="chevron-left"
size="24"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<img
v-if="avatarUrl"
@@ -11,7 +18,10 @@
alt="avatar"
/>
<div>
<div class="text-black-900 font-medium text-base flex items-center">
<div
class="font-medium text-base flex items-center"
:class="$dm('text-black-900', 'dark:text-slate-50')"
>
<span class="mr-1" v-html="title" />
<div
:class="
@@ -20,7 +30,10 @@
"
/>
</div>
<div class="text-xs mt-1 text-black-700">
<div
class="text-xs mt-1"
:class="$dm('text-black-700', 'dark:text-slate-400')"
>
{{ replyWaitMessage }}
</div>
</div>
@@ -36,6 +49,7 @@ import availabilityMixin from 'widget/mixins/availability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions';
import routerMixin from 'widget/mixins/routerMixin';
import darkMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatHeader',
@@ -43,7 +57,7 @@ export default {
FluentIcon,
HeaderActions,
},
mixins: [availabilityMixin, routerMixin],
mixins: [availabilityMixin, routerMixin, darkMixin],
props: {
avatarUrl: {
type: String,
@@ -67,7 +81,9 @@ export default {
},
},
computed: {
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;

View File

@@ -1,5 +1,8 @@
<template>
<header class="header-expanded bg-white py-6 px-5 relative box-border w-full">
<header
class="header-expanded py-6 px-5 relative box-border w-full"
:class="$dm('bg-white', 'dark:bg-slate-900')"
>
<div
class="flex items-start"
:class="[avatarUrl ? 'justify-between' : 'justify-end']"
@@ -8,21 +11,29 @@
<header-actions :show-popout-button="showPopoutButton" />
</div>
<h2
class="text-slate-900 mt-5 text-3xl mb-3 font-normal"
class=" mt-5 text-3xl mb-3 font-normal"
:class="$dm('text-slate-900', 'dark:text-slate-50')"
v-html="introHeading"
/>
<p class="text-lg text-black-700 leading-normal" v-html="introBody" />
<p
class="text-lg leading-normal"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
v-html="introBody"
/>
</header>
</template>
<script>
import { mapGetters } from 'vuex';
import HeaderActions from './HeaderActions';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatHeaderExpanded',
components: {
HeaderActions,
},
mixins: [darkModeMixin],
props: {
avatarUrl: {
type: String,

View File

@@ -1,7 +1,7 @@
<template>
<div
class="chat-message--input"
:class="{ 'is-focused': isFocused }"
class="chat-message--input is-focused"
:class="$dm('bg-white ', 'dark:bg-slate-600')"
@keydown.esc="hideEmojiPicker"
>
<resizable-text-area
@@ -10,7 +10,8 @@
v-model="userInput"
:aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input"
class="form-input user-message-input is-focused"
:class="inputColor"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -19,6 +20,7 @@
<div class="button-wrap">
<chat-attachment-button
v-if="showAttachment"
:class="$dm('text-black-900', 'dark:text-slate-100')"
:on-attach="onSendAttachment"
/>
<button
@@ -27,10 +29,7 @@
aria-label="Emoji picker"
@click="toggleEmojiPicker"
>
<fluent-icon
icon="emoji"
:class="{ 'text-woot-500': showEmojiPicker }"
/>
<fluent-icon icon="emoji" :class="emojiIconColor" />
</button>
<emoji-input
v-if="showEmojiPicker"
@@ -57,6 +56,7 @@ import configMixin from '../mixins/configMixin';
import EmojiInput from 'shared/components/emoji/EmojiInput';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatInputWrap',
@@ -67,7 +67,7 @@ export default {
FluentIcon,
ResizableTextArea,
},
mixins: [clickaway, configMixin],
mixins: [clickaway, configMixin, darkModeMixin],
props: {
onSendMessage: {
type: Function,
@@ -98,6 +98,15 @@ export default {
showSendButton() {
return this.userInput.length > 0;
},
inputColor() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
emojiIconColor() {
return this.showEmojiPicker
? `text-woot-500 ${this.$dm('text-black-900', 'dark:text-slate-100')}`
: `${this.$dm('text-black-900', 'dark:text-slate-100')}`;
},
},
watch: {
isWidgetOpen(isWidgetOpen) {

View File

@@ -1,22 +1,12 @@
<template>
<label class="block">
<div
v-if="label"
class="mb-2 text-xs font-medium"
:class="{
'text-black-800': !error,
'text-red-400': error,
}"
>
<div v-if="label" class="mb-2 text-xs font-medium" :class="labelClass">
{{ label }}
</div>
<input
:type="type"
class="border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none"
:class="{
'border-black-200 hover:border-black-300 focus:border-black-300': !error,
'border-red-200 hover:border-red-300 focus:border-red-300': error,
}"
class="border rounded w-full py-2 px-3 leading-tight outline-none"
:class="inputHasError"
:placeholder="placeholder"
:value="value"
@change="onChange"
@@ -27,7 +17,9 @@
</label>
</template>
<script>
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
label: {
type: String,
@@ -50,6 +42,27 @@ export default {
default: '',
},
},
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isInputDarkOrLightMode() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
inputBorderColor() {
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.error
? `border-red-200 hover:border-red-300 focus:border-red-300 ${this.isInputDarkOrLightMode}`
: `hover:border-black-300 focus:border-black-300 ${this.isInputDarkOrLightMode} ${this.inputBorderColor}`;
},
},
methods: {
onChange(event) {
this.$emit('input', event.target.value);

View File

@@ -1,21 +1,11 @@
<template>
<label class="block">
<div
v-if="label"
class="mb-2 text-xs font-medium"
:class="{
'text-black-800': !error,
'text-red-400': error,
}"
>
<div v-if="label" class="mb-2 text-xs font-medium" :class="labelClass">
{{ label }}
</div>
<textarea
class="resize-none border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none"
:class="{
'border-black-200 hover:border-black-300 focus:border-black-300': !error,
'border-red-200 hover:border-red-300 focus:border-red-300': error,
}"
:class="isTextAreaHasError"
:placeholder="placeholder"
:value="value"
@change="onChange"
@@ -26,7 +16,9 @@
</label>
</template>
<script>
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
label: {
type: String,
@@ -49,6 +41,27 @@ export default {
default: '',
},
},
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isTextAreaDarkOrLightMode() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
textAreaBorderColor() {
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
isTextAreaHasError() {
return this.error
? `border-red-200 hover:border-red-300 focus:border-red-300 ${this.isTextAreaDarkOrLightMode}`
: `hover:border-black-300 focus:border-black-300 ${this.isTextAreaDarkOrLightMode} ${this.textAreaBorderColor}`;
},
},
methods: {
onChange(event) {
this.$emit('input', event.target.value);

View File

@@ -6,14 +6,22 @@
:title="$t('END_CONVERSATION')"
@click="resolveConversation"
>
<fluent-icon icon="sign-out" size="22" class="text-black-900" />
<fluent-icon
icon="sign-out"
size="22"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<button
v-if="showPopoutButton"
class="button transparent compact new-window--button "
@click="popoutWindow"
>
<fluent-icon icon="open" size="22" class="text-black-900" />
<fluent-icon
icon="open"
size="22"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<button
class="button transparent compact close-button"
@@ -22,7 +30,11 @@
}"
@click="closeWindow"
>
<fluent-icon icon="dismiss" size="24" class="text-black-900" />
<fluent-icon
icon="dismiss"
size="24"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
</div>
</template>
@@ -31,10 +43,12 @@ import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { popoutChatWindow } from '../helpers/popoutHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'HeaderActions',
components: { FluentIcon },
mixins: [darkModeMixin],
props: {
showPopoutButton: {
type: Boolean,

View File

@@ -5,7 +5,8 @@
>
<div
v-if="shouldShowHeaderMessage"
class="text-black-800 text-sm leading-5"
class="text-sm leading-5"
:class="$dm('text-black-800', 'dark:text-slate-50')"
>
{{ headerMessage }}
</div>
@@ -64,6 +65,7 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import { required, minLength, email } from 'vuelidate/lib/validators';
import { isEmptyObject } from 'widget/helpers/utils';
import routerMixin from 'widget/mixins/routerMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
components: {
FormInput,
@@ -71,7 +73,7 @@ export default {
CustomButton,
Spinner,
},
mixins: [routerMixin],
mixins: [routerMixin, darkModeMixin],
props: {
options: {
type: Object,

View File

@@ -1,7 +1,10 @@
<template>
<div class="px-5">
<div class="flex items-center justify-between mb-4">
<div class="text-black-700 max-w-xs">
<div
class="max-w-xs"
:class="$dm('text-black-700', 'dark:text-slate-50')"
>
<div class="text-base leading-5 font-medium mb-1">
{{
isOnline
@@ -36,6 +39,7 @@ import AvailableAgents from 'widget/components/AvailableAgents.vue';
import CustomButton from 'shared/components/Button';
import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
import darkMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'TeamAvailability',
@@ -43,7 +47,7 @@ export default {
AvailableAgents,
CustomButton,
},
mixins: [configMixin, availabilityMixin],
mixins: [configMixin, availabilityMixin, darkMixin],
props: {
availableAgents: {
type: Array,
@@ -55,7 +59,9 @@ export default {
},
},
computed: {
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
textColor() {
return getContrastingTextColor(this.widgetColor);
},

View File

@@ -1,6 +1,10 @@
<template>
<div class="chat-bubble-wrap">
<button class="chat-bubble agent" @click="onClickMessage">
<button
class="chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-50')"
@click="onClickMessage"
>
<div v-if="showSender" class="row--agent-block">
<thumbnail
:src="avatarUrl"
@@ -25,10 +29,11 @@ import {
ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK,
} from '../constants/widgetBusEvents';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
name: 'UnreadMessage',
components: { Thumbnail },
mixins: [messageFormatterMixin, configMixin],
mixins: [messageFormatterMixin, configMixin, darkModeMixin],
props: {
message: {
type: String,

View File

@@ -1,11 +1,15 @@
<template>
<div
class="w-full h-full bg-slate-50 flex flex-col"
class="w-full h-full flex flex-col"
:class="$dm('bg-slate-50', 'dark:bg-slate-800')"
@keydown.esc="closeWindow"
>
<div
class="header-wrap bg-white"
:class="{ expanded: !isHeaderCollapsed, collapsed: isHeaderCollapsed }"
class="header-wrap"
:class="{
expanded: !isHeaderCollapsed,
collapsed: isHeaderCollapsed,
}"
>
<transition
enter-active-class="transition-all delay-200 duration-300 ease-in"
@@ -51,6 +55,7 @@ import Branding from 'shared/components/Branding.vue';
import ChatHeader from '../ChatHeader.vue';
import ChatHeaderExpanded from '../ChatHeaderExpanded.vue';
import configMixin from '../../mixins/configMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { mapGetters } from 'vuex';
import { IFrameHelper } from 'widget/helpers/utils';
@@ -61,7 +66,7 @@ export default {
ChatHeader,
ChatHeaderExpanded,
},
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
data() {
return {
showPopoutButton: false,

View File

@@ -1,12 +1,25 @@
<template>
<div v-if="!!items.length" class="chat-bubble agent">
<div
v-if="!!items.length"
class="chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<div v-for="item in items" :key="item.link" class="article-item">
<a :href="item.link" target="_blank" rel="noopener noreferrer nofollow">
<span class="title flex items-center text-black-900 font-medium">
<fluent-icon icon="link" class="mr-1" />
<span>{{ item.title }}</span>
<fluent-icon
icon="link"
class="mr-1"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
<span :class="$dm('text-slate-900', 'dark:text-slate-50')">{{
item.title
}}</span>
</span>
<span class="description">
<span
class="description"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ truncateMessage(item.description) }}
</span>
</a>
@@ -17,12 +30,13 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
FluentIcon,
},
mixins: [messageFormatterMixin],
mixins: [messageFormatterMixin, darkModeMixin],
props: {
items: {
type: Array,

View File

@@ -9,7 +9,7 @@
v-model.trim="email"
class="form-input"
:placeholder="$t('EMAIL_PLACEHOLDER')"
:class="{ error: $v.email.$error }"
:class="inputHasError"
@input="$v.email.$touch"
@keydown.enter="onSubmit"
/>
@@ -31,12 +31,14 @@ import { required, email } from 'vuelidate/lib/validators';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import Spinner from 'shared/components/Spinner';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
FluentIcon,
Spinner,
},
mixins: [darkModeMixin],
props: {
messageId: {
type: Number,
@@ -63,6 +65,15 @@ export default {
this.messageContentAttributes.submitted_email
);
},
inputColor() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
inputHasError() {
return this.$v.email.$error
? `${this.inputColor} error`
: `${this.inputColor}`;
},
},
validations: {
email: {
@@ -105,6 +116,10 @@ export default {
padding: $space-one;
width: 100%;
&::placeholder {
color: $color-light-gray;
}
&.error {
border-color: $color-error;
}

View File

@@ -0,0 +1,15 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ darkMode: 'appConfig/darkMode' }),
},
methods: {
$dm(light, dark) {
if (this.darkMode === 'light') {
return light;
}
return light + ' ' + dark;
},
},
};

View File

@@ -0,0 +1,41 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import darkModeMixin from '../darkModeMixin';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
const darkModeValues = ['light', 'auto'];
describe('darkModeMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
'appConfig/darkMode': () => darkModeValues[0],
};
store = new Vuex.Store({ getters });
});
it('if light theme', () => {
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100');
});
it('if auto theme', () => {
getters = {
'appConfig/darkMode': () => darkModeValues[2],
};
store = new Vuex.Store({ getters });
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100 bg-600');
});
});

View File

@@ -15,6 +15,7 @@ const state = {
showPopoutButton: false,
widgetColor: '',
widgetStyle: 'standard',
darkMode: 'light',
};
export const getters = {
@@ -25,18 +26,26 @@ export const getters = {
getWidgetColor: $state => $state.widgetColor,
getReferrerHost: $state => $state.referrerHost,
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
darkMode: $state => $state.darkMode,
};
export const actions = {
setAppConfig(
{ commit },
{ showPopoutButton, position, hideMessageBubble, widgetStyle = 'rounded' }
{
showPopoutButton,
position,
hideMessageBubble,
widgetStyle = 'rounded',
darkMode = 'light',
}
) {
commit(SET_WIDGET_APP_CONFIG, {
hideMessageBubble: !!hideMessageBubble,
position: position || 'right',
showPopoutButton: !!showPopoutButton,
widgetStyle,
darkMode,
});
},
toggleWidgetOpen({ commit }, isWidgetOpen) {
@@ -56,6 +65,7 @@ export const mutations = {
$state.position = data.position;
$state.hideMessageBubble = data.hideMessageBubble;
$state.widgetStyle = data.widgetStyle;
$state.darkMode = data.darkMode;
},
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen;