feat: Update reply box UI 👀 (#1623)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Nithin David Thomas
2021-01-13 18:06:25 +05:30
committed by GitHub
parent 2d5aa9d3bd
commit fd181f18a1
9 changed files with 443 additions and 217 deletions

View File

@@ -96,9 +96,9 @@
} }
.conversation-wrap { .conversation-wrap {
@include background-gray;
@include margin(0); @include margin(0);
@include border-normal-left; @include border-normal-left;
background: var(--color-background-light);
.current-chat { .current-chat {
@include flex; @include flex;
@@ -140,8 +140,8 @@
flex-direction: column; flex-direction: column;
// Firefox flexbox fix // Firefox flexbox fix
height: 100%; height: 100%;
margin-bottom: $space-small;
overflow-y: auto; overflow-y: auto;
padding-bottom: var(--space-normal);
position: relative; position: relative;
} }

View File

@@ -1,28 +1,13 @@
.reply-box { .reply-box {
@include light-shadow;
border-bottom: 0;
border-radius: $space-small;
margin: $space-normal;
margin-top: 0;
max-height: $space-mega * 3;
transition: box-shadow .35s $swift-ease-out-function, transition: box-shadow .35s $swift-ease-out-function,
height 2s $swift-ease-out-function; height 2s $swift-ease-out-function;
&.is-focused { &.is-focused {
@include shadow; box-shadow: var(--shadow);
} }
.reply-box__top { .reply-box__top {
@include flex;
@include flex-align($x: left, $y: middle);
@include padding($space-one $space-normal);
@include background-white;
@include margin(0);
border-top-left-radius: $space-small;
border-top-right-radius: $space-small;
position: relative;
.canned { .canned {
@include elegant-card; @include elegant-card;
background: $color-white; background: $color-white;
@@ -41,19 +26,6 @@
} }
} }
&.is-active {
border-bottom-left-radius: $space-small;
border-bottom-right-radius: $space-small;
}
&.is-private {
background: lighten($warning-color, 38%);
>input {
background: lighten($warning-color, 38%);
}
}
.icon { .icon {
color: $medium-gray; color: $medium-gray;
cursor: pointer; cursor: pointer;
@@ -65,9 +37,6 @@
} }
} }
.file-uploads>label {
cursor: pointer;
}
.attachment { .attachment {
cursor: pointer; cursor: pointer;
@@ -82,87 +51,45 @@
// Override min-height : 50px in foundation // Override min-height : 50px in foundation
// //
max-height: $space-mega * 2.4; max-height: $space-mega * 2.4;
min-height: 4rem; min-height: 4.8rem;
padding: var(--space-normal) 0 0;
resize: none; resize: none;
} }
} }
.reply-box__bottom { &.is-private {
@include background-light; background: var(--y-50);
@include flex;
@include flex-align($x: justify, $y: middle);
@include border-light-top;
border-bottom-left-radius: $space-small;
border-bottom-right-radius: $space-small;
.tabs { .reply-box__top {
border: 0; background: var(--y-50);
flex: 1;
padding: 0;
.tabs-title { >input {
margin: 0; background: var(--y-50);
transition: all .2s $swift-ease-out-function;
transition-property: color, background;
a {
font-weight: $font-weight-medium;
padding: $space-one $space-two;
}
&.is-private.is-active {
background: lighten($warning-color, 38%);
a {
border-bottom-color: darken($warning-color, 15%);
color: darken($warning-color, 15%);
}
}
}
.tabs-title:first-child {
border-bottom-left-radius: $space-small;
&.is-active {
@include border-light-right;
border-left: 0;
}
a {
border-bottom-left-radius: $space-small;
}
}
.is-active {
@include background-white;
@include border-light-left;
@include border-light-right;
margin-top: -1px;
}
.message-length {
float: right;
a {
font-size: $font-size-mini;
}
}
.message-error {
color: $input-error-color;
}
}
.send-button {
border-bottom-right-radius: $space-small;
height: 3.6rem;
padding-left: $space-two;
padding-right: $space-two;
padding-top: $space-small;
.icon {
margin-left: $space-small;
} }
} }
} }
.file-uploads>label {
cursor: pointer;
&:hover .button--emoji {
background: var(--b-200);
}
}
.bottom-box .button--emoji.button--upload {
height: var(--space-large);
padding: 0;
width: var(--space-large);
.file-uploads {
height: 100%;
line-height: var(--space-large);
width: 100%;
}
label {
padding: var(--space-small);
}
}
} }

View File

@@ -66,7 +66,9 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.preview-item { .preview-item {
display: flex; display: flex;
padding: 0 var(--space-small) var(--space-smaller); padding: var(--space-slab) 0 0;
background: var(--color-background-light);
background: transparent;
} }
.thumb-wrap { .thumb-wrap {
@@ -89,13 +91,16 @@ export default {
height: var(--space-medium); height: var(--space-medium);
font-size: var(--font-size-medium); font-size: var(--font-size-medium);
text-align: center; text-align: center;
position: relative;
top: -1px;
text-align: left;
} }
.file-name-wrap, .file-name-wrap,
.file-size-wrap { .file-size-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 var(--space-one); padding: 0 var(--space-smaller);
> .item { > .item {
margin: 0; margin: 0;
@@ -109,12 +114,20 @@ export default {
} }
.file-name-wrap { .file-name-wrap {
width: 100%; max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
.item {
height: var(--space-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.file-size-wrap { .file-size-wrap {
width: 20%; width: 20%;
justify-content: flex-end; justify-content: center;
} }
.remove-file-wrap { .remove-file-wrap {

View File

@@ -0,0 +1,178 @@
<template>
<div class="bottom-box" :class="wrapClass">
<div class="left-wrap">
<button class="button clear button--emoji" @click="toggleEmojiPicker">
<emoji-or-icon icon="ion-happy-outline" emoji="😊" />
</button>
<button
v-if="showAttachButton"
class="button clear button--emoji button--upload"
>
<file-upload
:size="4096 * 4096"
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
@input-file="onFileUpload"
>
<emoji-or-icon icon="ion-android-attach" emoji="📎" />
</file-upload>
</button>
</div>
<div class="right-wrap">
<button
class="button nice primary button--send"
:class="buttonClass"
@click="onSend"
>
{{ sendButtonText }}
</button>
</div>
</div>
</template>
<script>
import FileUpload from 'vue-upload-component';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
import { REPLY_EDITOR_MODES } from './constants';
export default {
name: 'ReplyTopPanel',
components: { EmojiOrIcon, FileUpload },
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
onSend: {
type: Function,
default: () => {},
},
sendButtonText: {
type: String,
default: '',
},
showFileUpload: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
},
showEmojiPicker: {
type: Boolean,
default: false,
},
toggleEmojiPicker: {
type: Function,
default: () => {},
},
isSendDisabled: {
type: Boolean,
default: false,
},
},
computed: {
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
wrapClass() {
return {
'is-note-mode': this.isNote,
};
},
buttonClass() {
return {
'button--note': this.isNote,
'button--disabled': this.isSendDisabled,
};
},
showAttachButton() {
return this.showFileUpload || this.isNote;
},
},
};
</script>
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss';
.bottom-box {
display: flex;
justify-content: space-between;
padding: var(--space-slab) var(--space-normal);
&.is-note-mode {
background: var(--y-50);
}
}
.button {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
padding: var(--space-one) var(--space-slab);
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background: var(--w-300);
}
&.is-active {
background: white;
}
&.button--emoji {
font-size: var(--font-size-small);
padding: var(--space-small);
border-radius: 9px;
background: var(--b-50);
border: 1px solid var(--color-border-light);
margin-right: var(--space-small);
&:hover {
background: var(--b-200);
}
}
&.button--note {
background: var(--y-800);
color: white;
&:hover {
background: var(--y-700);
}
}
&.button--disabled {
background: var(--b-100);
color: var(--b-400);
cursor: default;
&:hover {
background: var(--b-100);
}
}
}
.bottom-box.is-note-mode {
.button--emoji {
background: white;
}
}
.left-wrap {
display: flex;
align-items: center;
}
.button--reply {
border-right: 1px solid var(--color-border-light);
}
.icon--font {
color: var(--s-600);
font-size: var(--font-size-default);
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div class="top-box">
<div class="mode-wrap button-group">
<button
class="button clear button--reply"
:class="replyButtonClass"
@click="handleReplyClick"
>
<emoji-or-icon icon="" emoji="💬" />
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
</button>
<button
class="button clear button--note"
:class="noteButtonClass"
@click="handleNoteClick"
>
<emoji-or-icon icon="" emoji="📝" />
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</button>
</div>
<div class="action-wrap"></div>
</div>
</template>
<script>
import { REPLY_EDITOR_MODES } from './constants';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
export default {
name: 'ReplyTopPanel',
components: {
EmojiOrIcon,
},
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
setReplyMode: {
type: Function,
default: () => {},
},
},
computed: {
replyButtonClass() {
return {
'is-active': this.mode === REPLY_EDITOR_MODES.REPLY,
};
},
noteButtonClass() {
return {
'is-active': this.mode === REPLY_EDITOR_MODES.NOTE,
};
},
},
methods: {
handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
},
handleNoteClick() {
this.setReplyMode(REPLY_EDITOR_MODES.NOTE);
},
},
};
</script>
<style lang="scss" scoped>
.top-box {
display: flex;
justify-content: space-between;
background: var(--b-100);
}
.button-group {
border: 0;
padding: 0;
margin: 0;
.button {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
padding: var(--space-one) var(--space-normal);
margin: 0;
position: relative;
z-index: 1;
&.is-active {
background: white;
}
}
.button--reply {
border-right: 1px solid var(--color-border);
&:hover {
border-right: 1px solid var(--color-border);
}
}
.button--note {
&.is-active {
border-right: 1px solid var(--color-border);
background: var(--y-50);
}
}
}
.button--note {
color: var(--y-900);
}
.action-wrap {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,4 @@
export const REPLY_EDITOR_MODES = {
REPLY: 'REPLY',
NOTE: 'NOTE',
};

View File

@@ -1,102 +1,61 @@
<template> <template>
<div> <div class="reply-box" :class="replyBoxClass">
<reply-top-panel :mode="replyType" :set-reply-mode="setReplyMode" />
<div class="reply-box__top">
<canned-response
v-if="showCannedResponsesList"
v-on-clickaway="hideCannedResponse"
data-dropdown-menu
:on-keyenter="replaceText"
:on-click="replaceText"
/>
<emoji-input
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick"
/>
<resizable-text-area
ref="messageInput"
v-model="message"
class="input"
:placeholder="messagePlaceHolder"
:min-height="4"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
</div>
<div v-if="hasAttachments" class="attachment-preview-box"> <div v-if="hasAttachments" class="attachment-preview-box">
<attachment-preview <attachment-preview
:attachments="attachedFiles" :attachments="attachedFiles"
:remove-attachment="removeAttachment" :remove-attachment="removeAttachment"
/> />
</div> </div>
<div class="reply-box" :class="replyBoxClass"> <reply-bottom-panel
<div class="reply-box__top" :class="{ 'is-private': isPrivate }"> :mode="replyType"
<canned-response :send-button-text="replyButtonLabel"
v-if="showCannedResponsesList" :on-file-upload="onFileUpload"
v-on-clickaway="hideCannedResponse" :show-file-upload="showFileUpload"
data-dropdown-menu :toggle-emoji-picker="toggleEmojiPicker"
:on-keyenter="replaceText" :show-emoji-picker="showEmojiPicker"
:on-click="replaceText" :on-send="sendMessage"
/> :is-send-disabled="isReplyButtonDisabled"
<emoji-input />
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick"
/>
<resizable-text-area
ref="messageInput"
v-model="message"
class="input"
:placeholder="messagePlaceHolder"
:min-height="4"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<file-upload
v-if="showFileUpload"
:size="4096 * 4096"
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
@input-file="onFileUpload"
>
<i class="icon ion-android-attach attachment" />
</file-upload>
<i
class="icon ion-happy-outline"
:class="{ active: showEmojiPicker }"
@click="toggleEmojiPicker"
/>
</div>
<div class="reply-box__bottom">
<ul class="tabs">
<li class="tabs-title" :class="{ 'is-active': !isPrivate }">
<a href="#" @click="setReplyMode">{{
$t('CONVERSATION.REPLYBOX.REPLY')
}}</a>
</li>
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
<a href="#" @click="setPrivateReplyMode">
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</a>
</li>
<li v-if="message.length" class="tabs-title message-length">
<a :class="{ 'message-error': isMessageLengthReachingThreshold }">
{{ characterCountIndicator }}
</a>
</li>
</ul>
<button
type="button"
class="button send-button"
:disabled="isReplyButtonDisabled"
:class="{
disabled: isReplyButtonDisabled,
warning: isPrivate,
}"
@click="sendMessage"
>
{{ replyButtonLabel }}
<i
class="icon"
:class="{
'ion-android-send': !isPrivate,
'ion-android-lock': isPrivate,
}"
/>
</button>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import FileUpload from 'vue-upload-component';
import EmojiInput from 'shared/components/emoji/EmojiInput'; import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse'; import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea'; import ResizableTextArea from 'shared/components/ResizableTextArea';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { import {
isEscape, isEscape,
isEnter, isEnter,
@@ -109,9 +68,10 @@ export default {
components: { components: {
EmojiInput, EmojiInput,
CannedResponse, CannedResponse,
FileUpload,
ResizableTextArea, ResizableTextArea,
AttachmentPreview, AttachmentPreview,
ReplyTopPanel,
ReplyBottomPanel,
}, },
mixins: [clickaway, inboxMixin], mixins: [clickaway, inboxMixin],
props: { props: {
@@ -123,18 +83,19 @@ export default {
data() { data() {
return { return {
message: '', message: '',
isPrivateTabActive: false,
isFocused: false, isFocused: false,
showEmojiPicker: false, showEmojiPicker: false,
showCannedResponsesList: false, showCannedResponsesList: false,
attachedFiles: [], attachedFiles: [],
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
}; };
}, },
computed: { computed: {
...mapGetters({ currentChat: 'getSelectedChat' }), ...mapGetters({ currentChat: 'getSelectedChat' }),
isPrivate() { isPrivate() {
if (this.currentChat.can_reply) { if (this.currentChat.can_reply) {
return this.isPrivateTabActive; return this.replyType === REPLY_EDITOR_MODES.NOTE;
} }
return true; return true;
}, },
@@ -207,6 +168,7 @@ export default {
}, },
replyBoxClass() { replyBoxClass() {
return { return {
'is-private': this.isPrivate,
'is-focused': this.isFocused || this.hasAttachments, 'is-focused': this.isFocused || this.hasAttachments,
}; };
}, },
@@ -216,10 +178,11 @@ export default {
}, },
watch: { watch: {
currentChat(conversation) { currentChat(conversation) {
if (conversation.can_reply) { const { can_reply: canReply } = conversation;
this.isPrivateTabActive = false; if (canReply) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else { } else {
this.isPrivateTabActive = true; this.replyType = REPLY_EDITOR_MODES.NOTE;
} }
}, },
message(updatedMessage) { message(updatedMessage) {
@@ -282,12 +245,10 @@ export default {
this.message = message; this.message = message;
}, 100); }, 100);
}, },
setPrivateReplyMode() { setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
this.isPrivateTabActive = true; const { can_reply: canReply } = this.currentChat;
this.$refs.messageInput.focus();
}, if (canReply) this.replyType = mode;
setReplyMode() {
this.isPrivateTabActive = false;
this.$refs.messageInput.focus(); this.$refs.messageInput.focus();
}, },
emojiOnClick(emoji) { emojiOnClick(emoji) {
@@ -373,20 +334,45 @@ export default {
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import '~widget/assets/scss/mixins';
.send-button { .send-button {
margin-bottom: 0; margin-bottom: 0;
} }
.attachment-preview-box { .attachment-preview-box {
margin: 0 var(--space-normal); padding: 0 var(--space-normal);
background: var(--white); background: transparent;
margin-bottom: var(--space-minus-slab); }
padding-top: var(--space-small);
padding-bottom: var(--space-normal); .reply-box {
border-top-left-radius: var(--border-radius-medium); border-top: 1px solid var(--color-border);
border-top-right-radius: var(--border-radius-medium); background: white;
@include shadow;
&.is-private {
background: var(--y-50);
}
}
.send-button {
margin-bottom: 0;
}
.reply-box__top {
padding: 0 var(--space-normal);
border-top: 1px solid var(--color-border);
margin-top: -1px;
}
.emoji-dialog {
top: unset;
bottom: 12px;
left: -320px;
right: unset;
&::before {
right: -16px;
bottom: 10px;
transform: rotate(270deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
}
} }
</style> </style>

View File

@@ -23,7 +23,7 @@
--g-800: #009000; --g-800: #009000;
--g-900: #007000; --g-900: #007000;
--y-50: #FFFEE8; --y-50: #FFFCF4;
--y-100: #FFFAC5; --y-100: #FFFAC5;
--y-200: #FFF69E; --y-200: #FFF69E;
--y-300: #FEF176; --y-300: #FEF176;

View File

@@ -83,6 +83,7 @@ $font-size-medium: 18px;
right: 0; right: 0;
top: -22 * $space-one; top: -22 * $space-one;
width: 32 * $space-one; width: 32 * $space-one;
z-index: 1;
&::before { &::before {
@include arrow(bottom, $color-white, $space-slab); @include arrow(bottom, $color-white, $space-slab);