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:
Pranav
2026-05-18 12:30:08 -07:00
committed by GitHub
co-authored by Muhsin Keloth Sivin Varghese
parent e05008e0a4
commit 64585faff0
50 changed files with 1274 additions and 215 deletions
+13 -4
View File
@@ -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 }}
+39 -6
View File
@@ -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,
}),
}));