feat: Add a documentation layout design for public help center portal (#14403)
https://github.com/user-attachments/assets/fc4d15f9-2b54-4627-940f-94772ec739b1 --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
co-authored by
Muhsin Keloth
Sivin Varghese
parent
e05008e0a4
commit
64585faff0
@@ -5,9 +5,18 @@
|
||||
|
||||
@import 'widget/assets/scss/reset';
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'dashboard/assets/scss/next-colors';
|
||||
|
||||
html,
|
||||
body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.font-default {
|
||||
font-family:
|
||||
'InterDisplay',
|
||||
-apple-system,
|
||||
@@ -23,10 +32,6 @@ body {
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Noto Color Emoji';
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
// Taking these utils from tailwind 3.x.x, need to remove once we upgrade
|
||||
@@ -49,3 +54,7 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.turbolinks-progress-bar {
|
||||
background-color: var(--dynamic-portal-color);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ export default {
|
||||
PublicSearchInput,
|
||||
SearchSuggestions,
|
||||
},
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: value => ['small', 'default'].includes(value),
|
||||
},
|
||||
showKbd: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['input', 'blur'],
|
||||
data() {
|
||||
return {
|
||||
@@ -36,6 +47,13 @@ export default {
|
||||
const { searchTranslations = {} } = window.portalConfig;
|
||||
return searchTranslations;
|
||||
},
|
||||
kbdLabel() {
|
||||
if (!this.showKbd) return '';
|
||||
const isMac = /Mac|iPhone|iPad|iPod/i.test(
|
||||
navigator.platform || navigator.userAgent
|
||||
);
|
||||
return isMac ? '⌘ K' : 'Ctrl K';
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -44,7 +62,12 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.showKbd) document.addEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
if (this.showKbd) document.removeEventListener('keydown', this.onKeydown);
|
||||
clearTimeout(this.typingTimer);
|
||||
},
|
||||
|
||||
@@ -83,6 +106,16 @@ export default {
|
||||
clearSearchTerm() {
|
||||
this.searchTerm = '';
|
||||
},
|
||||
onKeydown(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
if (this.$refs.searchInput) this.$refs.searchInput.focusInput();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
this.closeSearch();
|
||||
if (this.$refs.searchInput) this.$refs.searchInput.blurInput();
|
||||
}
|
||||
},
|
||||
async fetchArticlesByQuery() {
|
||||
const query = this.normalizedSearchTerm;
|
||||
if (!query) {
|
||||
@@ -112,8 +145,11 @@ export default {
|
||||
<template>
|
||||
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
|
||||
<PublicSearchInput
|
||||
ref="searchInput"
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
:size="size"
|
||||
:kbd="kbdLabel"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
searchTerm: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
@@ -11,10 +11,33 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default', // 'small' | 'default'
|
||||
validator: value => ['small', 'default'].includes(value),
|
||||
},
|
||||
kbd: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:searchTerm', 'focus', 'blur']);
|
||||
const isFocused = ref(false);
|
||||
const inputRef = ref(null);
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return {
|
||||
wrapper: 'h-9 px-3 py-1',
|
||||
input: 'text-sm py-0',
|
||||
};
|
||||
}
|
||||
return {
|
||||
wrapper: 'h-12 px-5 py-2',
|
||||
input: 'text-base py-2',
|
||||
};
|
||||
});
|
||||
|
||||
const onChange = e => {
|
||||
emit('update:searchTerm', e.target.value);
|
||||
@@ -29,26 +52,46 @@ const onBlur = e => {
|
||||
isFocused.value = false;
|
||||
emit('blur', e.target.value);
|
||||
};
|
||||
|
||||
const focusInput = () => {
|
||||
if (inputRef.value) inputRef.value.focus();
|
||||
};
|
||||
|
||||
const blurInput = () => {
|
||||
if (inputRef.value) inputRef.value.blur();
|
||||
};
|
||||
|
||||
defineExpose({ focusInput, blurInput });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full flex items-center rounded-lg border-solid border h-12 bg-white dark:bg-slate-900 px-5 py-2 text-slate-600 dark:text-slate-200"
|
||||
:class="{
|
||||
'shadow border-woot-100 dark:border-woot-700': isFocused,
|
||||
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,
|
||||
}"
|
||||
class="w-full flex items-center gap-2 rounded-lg border-solid border bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 transition-all duration-500 ease-out"
|
||||
:class="[
|
||||
sizeClasses.wrapper,
|
||||
isFocused
|
||||
? 'shadow border-n-portal ring-4 ring-n-portal-soft'
|
||||
: 'border-slate-50 dark:border-slate-800 shadow-sm ring-0 ring-transparent',
|
||||
]"
|
||||
>
|
||||
<FluentIcon icon="search" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
:value="searchTerm"
|
||||
type="text"
|
||||
class="w-full focus:outline-none text-base h-full bg-white dark:bg-slate-900 px-2 py-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
|
||||
class="flex-1 min-w-0 focus:outline-none h-full bg-transparent px-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
|
||||
:class="sizeClasses.input"
|
||||
:placeholder="searchPlaceholder"
|
||||
role="search"
|
||||
@input="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<kbd
|
||||
v-if="kbd"
|
||||
class="shrink-0 inline-flex items-center text-xs font-medium text-n-slate-11 bg-n-alpha-2 border border-solid border-n-weak rounded px-1.5 py-0.5 font-inter"
|
||||
>
|
||||
{{ kbd }}
|
||||
</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
setup(props) {
|
||||
const selectedIndex = ref(-1);
|
||||
const portalSearchSuggestionsRef = ref(null);
|
||||
const { highlightContent } = useMessageFormatter();
|
||||
const { highlightContent, getPlainText } = useMessageFormatter();
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
|
||||
@@ -37,9 +37,7 @@ export default {
|
||||
};
|
||||
|
||||
const isSearchItemActive = index => {
|
||||
return index === selectedIndex.value
|
||||
? 'bg-slate-25 dark:bg-slate-800'
|
||||
: 'bg-white dark:bg-slate-900';
|
||||
return index === selectedIndex.value ? 'bg-n-portal-soft' : '';
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
@@ -53,6 +51,7 @@ export default {
|
||||
portalSearchSuggestionsRef,
|
||||
isSearchItemActive,
|
||||
highlightContent,
|
||||
getPlainText,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -70,7 +69,7 @@ export default {
|
||||
return this.highlightContent(
|
||||
content,
|
||||
this.searchTerm,
|
||||
'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
|
||||
'bg-n-portal-soft text-n-portal font-semibold rounded-sm px-1'
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -80,46 +79,46 @@ export default {
|
||||
<template>
|
||||
<div
|
||||
ref="portalSearchSuggestionsRef"
|
||||
class="p-5 mt-2 overflow-y-auto text-sm bg-white border border-solid rounded-lg shadow-xl hover:shadow-lg dark:bg-slate-900 max-h-96 scroll-py-2 text-slate-700 dark:text-slate-100 border-slate-50 dark:border-slate-800"
|
||||
class="mt-2 overflow-y-auto bg-white dark:bg-n-slate-2 border border-solid border-n-weak rounded-xl shadow-2xl max-h-96 p-2"
|
||||
>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="text-sm font-medium text-slate-400 dark:text-slate-700"
|
||||
>
|
||||
<div v-if="isLoading" class="px-3 py-6 text-sm text-n-slate-11 text-center">
|
||||
{{ loadingPlaceholder }}
|
||||
</div>
|
||||
<ul
|
||||
v-if="shouldShowResults"
|
||||
class="flex flex-col gap-4 text-sm bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100"
|
||||
role="listbox"
|
||||
>
|
||||
<ul v-if="shouldShowResults" class="flex flex-col gap-0.5" role="listbox">
|
||||
<li
|
||||
v-for="(article, index) in items"
|
||||
:id="article.id"
|
||||
:key="article.id"
|
||||
class="flex items-center p-4 border border-solid rounded-lg cursor-pointer select-none group hover:bg-slate-25 dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800"
|
||||
class="rounded-md cursor-pointer select-none group transition-colors hover:bg-n-alpha-2"
|
||||
:class="isSearchItemActive(index)"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
@mouse-enter="onHover(index)"
|
||||
@mouse-leave="onHover(-1)"
|
||||
>
|
||||
<a class="flex flex-col gap-1 overflow-y-hidden" :href="article.link">
|
||||
<a
|
||||
class="flex items-start gap-3 px-3 py-2.5 overflow-hidden"
|
||||
:href="article.link"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="prepareContent(article.title)"
|
||||
class="flex-auto w-full overflow-hidden text-base font-semibold leading-6 truncate text-ellipsis whitespace-nowrap"
|
||||
/>
|
||||
<div
|
||||
v-dompurify-html="prepareContent(article.content)"
|
||||
class="overflow-hidden text-sm line-clamp-2 text-ellipsis whitespace-nowrap text-slate-600 dark:text-slate-300"
|
||||
class="i-lucide-file-text size-4 mt-0.5 flex-shrink-0 text-n-slate-10 group-hover:text-n-slate-11"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 flex flex-col gap-1">
|
||||
<span
|
||||
v-dompurify-html="prepareContent(getPlainText(article.title))"
|
||||
class="block text-base font-520 text-n-slate-12 truncate"
|
||||
/>
|
||||
<span
|
||||
v-dompurify-html="prepareContent(article.content)"
|
||||
class="block text-sm text-n-slate-11 line-clamp-1"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="showEmptyResults"
|
||||
class="text-sm font-medium text-slate-400 dark:text-slate-700"
|
||||
class="px-3 py-6 text-sm text-n-slate-11 text-center"
|
||||
>
|
||||
{{ emptyPlaceholder }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
|
||||
const OPTION_DEFS = [
|
||||
{ value: 'system', icon: 'i-lucide-monitor', fallback: 'System' },
|
||||
{ value: 'light', icon: 'i-lucide-sun', fallback: 'Light' },
|
||||
{ value: 'dark', icon: 'i-lucide-moon', fallback: 'Dark' },
|
||||
];
|
||||
|
||||
const readLabels = () => {
|
||||
const el = document.querySelector('#sidebar-theme-toggle');
|
||||
const data = (el && el.dataset) || {};
|
||||
return {
|
||||
trigger: data.labelTrigger || 'Appearance',
|
||||
options: OPTION_DEFS.map(opt => ({
|
||||
...opt,
|
||||
label:
|
||||
data[
|
||||
`label${opt.value.charAt(0).toUpperCase()}${opt.value.slice(1)}`
|
||||
] || opt.fallback,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
directives: { onClickaway },
|
||||
data() {
|
||||
const labels = readLabels();
|
||||
return {
|
||||
mode: 'system',
|
||||
systemPrefersDark: false,
|
||||
open: false,
|
||||
options: labels.options,
|
||||
triggerLabel: labels.trigger,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
triggerIcon() {
|
||||
const opt = this.options.find(o => o.value === this.mode);
|
||||
return opt ? opt.icon : 'i-lucide-monitor';
|
||||
},
|
||||
isDarkActive() {
|
||||
if (this.mode === 'dark') return true;
|
||||
if (this.mode === 'light') return false;
|
||||
return this.systemPrefersDark;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const stored = localStorage.theme;
|
||||
this.mode = stored === 'dark' || stored === 'light' ? stored : 'system';
|
||||
} catch (e) {
|
||||
this.mode = 'system';
|
||||
}
|
||||
if (window.matchMedia) {
|
||||
this.media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.systemPrefersDark = this.media.matches;
|
||||
this.media.addEventListener('change', this.onSystemChange);
|
||||
}
|
||||
this.applyTheme();
|
||||
},
|
||||
unmounted() {
|
||||
if (this.media)
|
||||
this.media.removeEventListener('change', this.onSystemChange);
|
||||
},
|
||||
methods: {
|
||||
onSystemChange(e) {
|
||||
this.systemPrefersDark = e.matches;
|
||||
if (this.mode === 'system') this.applyTheme();
|
||||
},
|
||||
applyTheme() {
|
||||
const dark = this.isDarkActive;
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
document.documentElement.classList.add(dark ? 'dark' : 'light');
|
||||
},
|
||||
select(value) {
|
||||
this.mode = value;
|
||||
try {
|
||||
if (value === 'system') localStorage.removeItem('theme');
|
||||
else localStorage.theme = value;
|
||||
} catch (e) {
|
||||
/* no-op */
|
||||
}
|
||||
this.applyTheme();
|
||||
this.open = false;
|
||||
},
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
},
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="close" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 rounded-lg transition"
|
||||
:aria-label="triggerLabel"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="size-4" :class="[triggerIcon]" aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute end-0 top-full mt-2 bg-n-slate-1 border border-solid border-n-weak rounded-lg shadow-lg p-1 min-w-36 z-50 flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between gap-2.5 px-2.5 py-2 text-sm rounded-md transition"
|
||||
:class="[
|
||||
mode === opt.value
|
||||
? 'bg-n-portal-soft text-n-portal'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12',
|
||||
]"
|
||||
@click="select(opt.value)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="size-4" :class="[opt.icon]" aria-hidden="true" />
|
||||
{{ opt.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="mode === opt.value"
|
||||
class="i-lucide-check size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -79,13 +79,13 @@ export default {
|
||||
},
|
||||
elementBorderStyles(el) {
|
||||
if (this.isElementActive(el)) {
|
||||
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
|
||||
return 'border-n-portal transition-colors duration-200';
|
||||
}
|
||||
return 'border-slate-100 dark:border-slate-800';
|
||||
},
|
||||
elementTextStyles(el) {
|
||||
if (this.isElementActive(el)) {
|
||||
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
|
||||
return 'text-n-portal transition-colors duration-200';
|
||||
}
|
||||
return 'text-slate-700 dark:text-slate-100';
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export default {
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbolinks="false"
|
||||
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
|
||||
class="font-medium text-sm cursor-pointer"
|
||||
:class="elementTextStyles(element)"
|
||||
>
|
||||
{{ element.title }}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isSameHost } from '@chatwoot/utils';
|
||||
import slugifyWithCounter from '@sindresorhus/slugify';
|
||||
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
||||
import TableOfContents from './components/TableOfContents.vue';
|
||||
import SidebarThemeToggle from './components/SidebarThemeToggle.vue';
|
||||
import { initializeTheme } from './portalThemeHelper.js';
|
||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
@@ -78,18 +79,23 @@ export const InitializationHelpers = {
|
||||
},
|
||||
|
||||
initializeSearch: () => {
|
||||
const isSearchContainerAvailable = document.querySelector('#search-wrap');
|
||||
if (isSearchContainerAvailable) {
|
||||
['#search-wrap', '#search-wrap-hero'].forEach(selector => {
|
||||
const mountPoint = document.querySelector(selector);
|
||||
if (!mountPoint) return;
|
||||
const size = mountPoint.dataset.size || 'default';
|
||||
const showKbd = !!mountPoint.dataset.kbd;
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
const app = createApp({
|
||||
components: { PublicArticleSearch },
|
||||
template: '<PublicArticleSearch />',
|
||||
data() {
|
||||
return { size, showKbd };
|
||||
},
|
||||
template: '<PublicArticleSearch :size="size" :show-kbd="showKbd" />',
|
||||
});
|
||||
|
||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
app.directive('on-clickaway', onClickaway);
|
||||
app.mount('#search-wrap');
|
||||
}
|
||||
app.mount(selector);
|
||||
});
|
||||
},
|
||||
|
||||
initializeTableOfContents: () => {
|
||||
@@ -109,6 +115,31 @@ export const InitializationHelpers = {
|
||||
}
|
||||
},
|
||||
|
||||
initializeSidebarThemeToggle: () => {
|
||||
const mountPoint = document.querySelector('#sidebar-theme-toggle');
|
||||
if (mountPoint) {
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
const app = createApp({
|
||||
components: { SidebarThemeToggle },
|
||||
template: '<sidebar-theme-toggle />',
|
||||
});
|
||||
app.directive('on-clickaway', onClickaway);
|
||||
app.mount('#sidebar-theme-toggle');
|
||||
}
|
||||
},
|
||||
|
||||
initializeDetailsClickAway: () => {
|
||||
document.addEventListener('click', event => {
|
||||
document
|
||||
.querySelectorAll('details[data-close-on-clickaway][open]')
|
||||
.forEach(details => {
|
||||
if (!details.contains(event.target)) {
|
||||
details.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
appendPlainParamToURLs: () => {
|
||||
[...document.getElementsByTagName('a')].forEach(aTagElement => {
|
||||
if (aTagElement.href && aTagElement.href.includes('/hc/')) {
|
||||
@@ -143,6 +174,8 @@ export const InitializationHelpers = {
|
||||
InitializationHelpers.navigateToLocalePage();
|
||||
InitializationHelpers.initializeSearch();
|
||||
InitializationHelpers.initializeTableOfContents();
|
||||
InitializationHelpers.initializeSidebarThemeToggle();
|
||||
InitializationHelpers.initializeDetailsClickAway();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
|
||||
vi.mock('shared/composables/useMessageFormatter', () => ({
|
||||
useMessageFormatter: () => ({
|
||||
highlightContent: content => content,
|
||||
getPlainText: content => content,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user