vertical tab switcher initial implementation

This commit is contained in:
Henry Jameson 2025-11-20 02:07:00 +02:00
commit a96f533777
10 changed files with 254 additions and 18 deletions

View file

@ -1,4 +1,4 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import VerticalTabSwitcher from 'src/components/tab_switcher/vertical_tab_switcher.jsx'
import InstanceTab from './admin_tabs/instance_tab.vue' import InstanceTab from './admin_tabs/instance_tab.vue'
import LimitsTab from './admin_tabs/limits_tab.vue' import LimitsTab from './admin_tabs/limits_tab.vue'
@ -31,7 +31,7 @@ library.add(
const SettingsModalAdminContent = { const SettingsModalAdminContent = {
components: { components: {
TabSwitcher, VerticalTabSwitcher,
InstanceTab, InstanceTab,
LimitsTab, LimitsTab,

View file

@ -1,5 +1,5 @@
<template> <template>
<tab-switcher <vertical-tab-switcher
v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)" v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)"
ref="tabSwitcher" ref="tabSwitcher"
class="settings_tab-switcher" class="settings_tab-switcher"
@ -71,7 +71,7 @@
> >
<EmojiTab /> <EmojiTab />
</div> </div>
</tab-switcher> </vertical-tab-switcher>
</template> </template>
<script src="./settings_modal_admin_content.js"></script> <script src="./settings_modal_admin_content.js"></script>

View file

@ -1,4 +1,4 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import VerticalTabSwitcher from 'src/components/tab_switcher/vertical_tab_switcher.jsx'
import DataImportExportTab from './tabs/data_import_export_tab.vue' import DataImportExportTab from './tabs/data_import_export_tab.vue'
import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue' import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
@ -42,7 +42,7 @@ library.add(
const SettingsModalContent = { const SettingsModalContent = {
components: { components: {
TabSwitcher, VerticalTabSwitcher,
DataImportExportTab, DataImportExportTab,
MutesAndBlocksTab, MutesAndBlocksTab,

View file

@ -1,5 +1,5 @@
<template> <template>
<tab-switcher <vertical-tab-switcher
ref="tabSwitcher" ref="tabSwitcher"
class="settings_tab-switcher" class="settings_tab-switcher"
:side-tab-bar="true" :side-tab-bar="true"
@ -94,7 +94,7 @@
> >
<VersionTab /> <VersionTab />
</div> </div>
</tab-switcher> </vertical-tab-switcher>
</template> </template>
<script src="./settings_modal_user_content.js"></script> <script src="./settings_modal_user_content.js"></script>

View file

@ -1,4 +1,4 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import VerticalTabSwitcher from 'src/components/tab_switcher/vertical_tab_switcher.jsx'
import BooleanSetting from '../helpers/boolean_setting.vue' import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue'
@ -97,7 +97,7 @@ const AppearanceTab = {
FontControl, FontControl,
Preview, Preview,
PaletteEditor, PaletteEditor,
TabSwitcher VerticalTabSwitcher
}, },
mounted () { mounted () {
useInterfaceStore().getThemeData() useInterfaceStore().getThemeData()

View file

@ -1,5 +1,5 @@
<template> <template>
<tab-switcher <vertical-tab-switcher
class="appearance-tab" class="appearance-tab"
:label="$t('settings.appearance')" :label="$t('settings.appearance')"
ref="tabSwitcher" ref="tabSwitcher"
@ -446,7 +446,7 @@
</ul> </ul>
</div> </div>
</div> </div>
</tab-switcher> </vertical-tab-switcher>
</template> </template>
<script src="./appearance_tab.js"></script> <script src="./appearance_tab.js"></script>

View file

@ -1,6 +1,6 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import VerticalTabSwitcher from 'src/components/tab_switcher/vertical_tab_switcher.jsx'
import BooleanSetting from '../helpers/boolean_setting.vue' import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue'
@ -92,7 +92,7 @@ const GeneralTab = {
ProfileSettingIndicator, ProfileSettingIndicator,
ScopeSelector, ScopeSelector,
Select, Select,
TabSwitcher, VerticalTabSwitcher,
FontControl FontControl
}, },
computed: { computed: {

View file

@ -1,10 +1,8 @@
<template> <template>
<tab-switcher <vertical-tab-switcher
:label="$t('settings.general')" :label="$t('settings.general')"
ref="tabSwitcher" ref="tabSwitcher"
class="settings_tab-switcher" class="settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
> >
<div <div
:label="$t('settings.behavior')" :label="$t('settings.behavior')"
@ -551,7 +549,7 @@
</li> </li>
</ul> </ul>
</div> </div>
</tab-switcher> </vertical-tab-switcher>
</template> </template>
<script src="./general_tab.js"></script> <script src="./general_tab.js"></script>

View file

@ -0,0 +1,166 @@
// eslint-disable-next-line no-unused
import { h, Fragment } from 'vue'
import { mapState } from 'pinia'
import { throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './vertical_tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface'
const findFirstUsable = (slots) => slots.findIndex(_ => _.props)
export default {
name: 'VerticalTabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false
},
onSwitch: {
required: false,
type: Function,
default: undefined
},
activeTab: {
required: false,
type: String,
default: undefined
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false
}
},
data () {
return {
active: findFirstUsable(this.slots()),
resizeHandler: null
}
},
computed: {
activeIndex () {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(slot => slot && slot.props && this.activeTab === slot.props.key)
} else {
return this.active
}
},
isActive () {
return tabName => {
const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: store => store.layoutType === 'mobile'
}),
},
beforeUpdate () {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab (index) {
return (e) => {
e.preventDefault()
this.setTab(index)
console.log(index)
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots () {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
setTab (index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
this.$refs.contents.scrollTop = 0
}
},
render () {
const tabs = this.slots()
.map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['vertical-tab menu-item']
if (this.activeIndex === index) {
classesTab.push('-active')
}
return (
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
{!props.icon ? '' : (<FAIcon class="tab-icon" size="1x" fixed-width icon={props.icon}/>)}
<span class="text">
{props.label}
</span>
</button>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
const classes = [ active ? 'active' : 'hidden' ]
if (props.fullHeight) {
classes.push('full-height')
}
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot = (!delayRender && (!this.renderOnlyFocused || active))
? slot
: ''
const header = (
<h1 class="tab-content-label">
{props.label}
</h1>
)
return (
<div ref="contents" class={classes}>
{header}
{renderSlot}
</div>
)
})
return (
<div ref="root" class="vertical-tab-switcher">
<div
class="tabs -navigation-mode -tabs"
role="tablist"
ref="nav"
>
{tabs}
</div>
<div
role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
>
{contents}
</div>
</div>
)
}
}

View file

@ -0,0 +1,72 @@
.vertical-tab-switcher {
display: flex;
flex-direction: row;
> .tabs {
flex: 0 0 auto;
overflow: hidden auto;
flex-direction: column;
border-right: 1px solid;
border-right-color: var(--border);
min-width: 10em;
> .menu-item {
padding: 0.5em 1em;
.tab-icon {
vertical-align: middle;
margin-right: 0.75em;
}
}
}
> .contents {
flex: 1 0 auto;
.hidden {
display: none;
}
.full-height:not(.hidden) {
height: 100%;
display: flex;
flex-direction: column;
> *:not(.tab-content-label) {
flex: 1;
}
}
.tab-content-label {
font-size: 1.5em;
padding: 0.5em 1em;
margin: 0;
border-bottom: 1px solid var(--border);
}
}
> .tabs,
> .content {
transition: width 2s ease-in;
}
&.-tabs {
> .tabs {
width: 100%;
}
> .content {
width: 0%;
}
}
&.-content {
> .tabs {
width: 0%;
}
> .content {
width: 100%;
}
}
}