feat: dynamically hide tabs in app builder (#6653)

* feat: dynamically hide tabs in app builder

* claude code comments
This commit is contained in:
Alexander Petric
2025-09-22 12:29:34 -04:00
committed by GitHub
parent 23e4128783
commit 73ad39d0c3
6 changed files with 233 additions and 84 deletions
@@ -28,6 +28,7 @@
customCss?: ComponentCustomCSS<'tabscomponent'> | undefined
render: boolean
disabledTabs: RichConfiguration[]
hiddenTabs: RichConfiguration[]
onTabChange?: string[] | undefined
}
@@ -39,6 +40,7 @@
customCss = undefined,
render,
disabledTabs,
hiddenTabs,
onTabChange = undefined
}: Props = $props()
@@ -101,18 +103,28 @@
}
},
left: () => {
const index = tabs.indexOf(selected)
if (index > 0) {
selected = tabs[index - 1]
return true
const currentIndex = tabs.indexOf(selected)
if (currentIndex > 0) {
// Find the next visible tab to the left
for (let i = currentIndex - 1; i >= 0; i--) {
if (!resolvedHiddenTabs[i]) {
selected = tabs[i]
return true
}
}
}
return false
},
right: () => {
const index = tabs.indexOf(selected)
if (index < tabs.length - 1) {
selected = tabs[index + 1]
return true
const currentIndex = tabs.indexOf(selected)
if (currentIndex < tabs.length - 1) {
// Find the next visible tab to the right
for (let i = currentIndex + 1; i < tabs.length; i++) {
if (!resolvedHiddenTabs[i]) {
selected = tabs[i]
return true
}
}
}
return false
},
@@ -144,6 +156,31 @@
})
let resolvedDisabledTabs: boolean[] = $state([])
let resolvedHiddenTabs: boolean[] = $state([])
// Filter visible tabs based on hiddenTabs configuration
let visibleTabsIndices = $derived(
(tabs ?? []).map((_, index) => index).filter((index) => !resolvedHiddenTabs[index])
)
// Auto-select first visible tab if current selection is hidden or invalid
$effect(() => {
if (visibleTabsIndices.length === 0) {
// Handle case where all tabs are hidden
selected = ''
selectedIndex = -1
return
}
const currentIndex = tabs?.indexOf(selected) ?? -1
if (currentIndex === -1 || resolvedHiddenTabs[currentIndex]) {
const firstVisibleTab = tabs[visibleTabsIndices[0]]
if (selected !== firstVisibleTab) {
// Current selection is hidden or invalid, select first visible tab
selected = firstVisibleTab
}
}
})
</script>
<InputValue key="kind" {id} input={configuration.tabsKind} bind:value={resolvedConfig.tabsKind} />
@@ -169,6 +206,15 @@
/>
{/each}
{#each hiddenTabs ?? [] as hideTab, index}
<InputValue
key="tabHidden {index}"
{id}
input={hideTab}
bind:value={resolvedHiddenTabs[index]}
/>
{/each}
{#if everRender}
<div class={resolvedConfig.tabsKind == 'sidebar' ? 'flex gap-4 w-full h-full' : 'w-full'}>
{#if !resolvedConfig.tabsKind || resolvedConfig.tabsKind == 'tabs' || (resolvedConfig.tabsKind == 'invisibleOnView' && $mode == 'dnd')}
@@ -179,16 +225,18 @@
style={css?.tabRow?.style}
>
{#each tabs ?? [] as res, index}
<Tab
value={res}
class={twMerge(css?.allTabs?.class, 'wm-tabs-alltabs')}
style={css?.allTabs?.style}
selectedClass={twMerge(css?.selectedTab?.class, 'wm-tabs-selectedTab')}
selectedStyle={css?.selectedTab?.style}
disabled={resolvedDisabledTabs[index]}
>
<span class="font-semibold">{res}</span>
</Tab>
{#if !resolvedHiddenTabs[index]}
<Tab
value={res}
class={twMerge(css?.allTabs?.class, 'wm-tabs-alltabs')}
style={css?.allTabs?.style}
selectedClass={twMerge(css?.selectedTab?.class, 'wm-tabs-selectedTab')}
selectedStyle={css?.selectedTab?.style}
disabled={resolvedDisabledTabs[index]}
>
<span class="font-semibold">{res}</span>
</Tab>
{/if}
{/each}
</Tabs>
</div>
@@ -202,78 +250,82 @@
style={css?.tabRow?.style}
>
{#each tabs ?? [] as res, index}
<button
onpointerdown={stopPropagation(bubble('pointerdown'))}
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
disabled={resolvedDisabledTabs[index]}
class={twMerge(
'rounded-sm !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'border-r border-primary border-l bg-surface text-primary',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: '',
resolvedDisabledTabs[index]
? 'opacity-50 cursor-not-allowed hover:text-secondary'
: ''
)}
style={selected == res
? [css?.allTabs?.style, css?.selectedTab?.style].filter(Boolean).join(';')
: css?.allTabs?.style}
>
{res}
</button>
{#if !resolvedHiddenTabs[index]}
<button
onpointerdown={stopPropagation(bubble('pointerdown'))}
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
disabled={resolvedDisabledTabs[index]}
class={twMerge(
'rounded-sm !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'border-r border-primary border-l bg-surface text-primary',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: '',
resolvedDisabledTabs[index]
? 'opacity-50 cursor-not-allowed hover:text-secondary'
: ''
)}
style={selected == res
? [css?.allTabs?.style, css?.selectedTab?.style].filter(Boolean).join(';')
: css?.allTabs?.style}
>
{res}
</button>
{/if}
{/each}
</div>
{/if}
{#if resolvedConfig.tabsKind == 'accordion'}
<div class="flex flex-col w-full">
{#each tabs ?? [] as res, index}
<div class="border-b">
<button
onpointerdown={stopPropagation(bubble('pointerdown'))}
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
disabled={resolvedDisabledTabs[index]}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'bg-surface text-primary ',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: 'text-secondary',
resolvedDisabledTabs[index]
? 'opacity-50 cursor-not-allowed hover:text-secondary'
: ''
)}
>
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
{res}
</button>
<div class={selected == res ? 'border-t' : ''}>
<SubGridEditor
{id}
visible={render && index === selectedIndex}
subGridId={`${id}-${index}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
onFocus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
{#if !resolvedHiddenTabs[index]}
<div class="border-b">
<button
onpointerdown={stopPropagation(bubble('pointerdown'))}
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
disabled={resolvedDisabledTabs[index]}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
css?.allTabs?.class,
'wm-tabs-alltabs',
selected == res
? twMerge(
'bg-surface text-primary ',
css?.selectedTab?.class,
'wm-tabs-selectedTab'
)
: 'text-secondary',
resolvedDisabledTabs[index]
? 'opacity-50 cursor-not-allowed hover:text-secondary'
: ''
)}
>
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
{res}
</button>
<div class={selected == res ? 'border-t' : ''}>
<SubGridEditor
{id}
visible={render && index === selectedIndex}
subGridId={`${id}-${index}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
onFocus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
</div>
</div>
</div>
{/if}
{/each}
</div>
{:else}
@@ -853,6 +853,7 @@
id={component.id}
tabs={component.tabs}
disabledTabs={component.disabledTabs}
hiddenTabs={component.hiddenTabs}
onTabChange={component.onTabChange}
customCss={component.customCss}
{componentContainerHeight}
@@ -239,6 +239,7 @@ export type FileInputComponent = BaseComponent<'fileinputcomponent'> & {
export type TabsComponent = BaseComponent<'tabscomponent'> & {
tabs: string[]
disabledTabs: RichConfiguration[]
hiddenTabs: RichConfiguration[]
onTabChange?: string[]
}
@@ -3012,7 +3013,15 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
},
componentInput: undefined,
numberOfSubgrids: 2,
tabs: ['First tab', 'Second tab'] as string[]
tabs: ['First tab', 'Second tab'] as string[],
disabledTabs: [
{ type: 'static', value: false, fieldType: 'boolean' },
{ type: 'static', value: false, fieldType: 'boolean' }
] as RichConfiguration[],
hiddenTabs: [
{ type: 'static', value: false, fieldType: 'boolean' },
{ type: 'static', value: false, fieldType: 'boolean' }
] as RichConfiguration[]
}
},
steppercomponent: {
@@ -391,8 +391,10 @@
<GridTab
bind:tabs={item.data.tabs}
bind:disabledTabs={item.data.disabledTabs}
bind:hiddenTabs={item.data.hiddenTabs}
bind:component={item.data}
canDisableTabs
canHideTabs
/>
{:else if item.data.type === 'aggridcomponentee'}
<GridAgGridLicenseKey bind:license={item.data.license} />
@@ -13,11 +13,14 @@
import { generateRandomString } from '$lib/utils'
import { GripVertical, Plus } from 'lucide-svelte'
import GridTabDisabled from './GridTabDisabled.svelte'
import GridTabHidden from './GridTabHidden.svelte'
interface Props {
tabs?: string[]
disabledTabs?: RichConfiguration[]
hiddenTabs?: RichConfiguration[]
canDisableTabs?: boolean
canHideTabs?: boolean
word?: string
component: AppComponent
}
@@ -25,7 +28,9 @@
let {
tabs = $bindable(undefined),
disabledTabs = $bindable(undefined),
hiddenTabs = $bindable(undefined),
canDisableTabs = false,
canHideTabs = false,
word = 'Tab',
component = $bindable()
}: Props = $props()
@@ -40,6 +45,12 @@
{ type: 'static', value: false, fieldType: 'boolean' }
]
}
if (hiddenTabs == undefined) {
hiddenTabs = [
{ type: 'static', value: false, fieldType: 'boolean' },
{ type: 'static', value: false, fieldType: 'boolean' }
]
}
})
let items = $state.raw(
@@ -77,6 +88,7 @@
component.numberOfSubgrids = items.length
disabledTabs = [...(disabledTabs ?? []), { type: 'static', value: false, fieldType: 'boolean' }]
hiddenTabs = [...(hiddenTabs ?? []), { type: 'static', value: false, fieldType: 'boolean' }]
}
function deleteSubgrid(index: number) {
@@ -99,6 +111,9 @@
// Delete the item in the disabledTabs array
disabledTabs = (disabledTabs ?? []).filter((_, i) => i !== index)
// Delete the item in the hiddenTabs array
hiddenTabs = (hiddenTabs ?? []).filter((_, i) => i !== index)
component.numberOfSubgrids = items.length
// Update the originalIndex of the remaining items
items.forEach((item, i) => {
@@ -136,10 +151,13 @@
}
const newDisabledTabs: RichConfiguration[] = []
const newHiddenTabs: RichConfiguration[] = []
for (let i = 0; i < items.length; i++) {
disabledTabs && newDisabledTabs.push(disabledTabs[items[i].originalIndex])
hiddenTabs && newHiddenTabs.push(hiddenTabs[items[i].originalIndex])
}
disabledTabs = newDisabledTabs
hiddenTabs = newHiddenTabs
// update originalIndex
items.forEach((item, i) => {
@@ -203,6 +221,11 @@
{#if canDisableTabs && disabledTabs}
<GridTabDisabled {index} bind:field={disabledTabs[index]} id={component.id} />
{/if}
{#if canHideTabs && hiddenTabs}
<div class="mt-2">
<GridTabHidden {index} bind:field={hiddenTabs[index]} id={component.id} />
</div>
{/if}
</div>
{/each}
</section>
@@ -0,0 +1,62 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import type { RichConfiguration } from '../../types'
import InputsSpecEditor from './InputsSpecEditor.svelte'
import { slide } from 'svelte/transition'
interface Props {
id: string
field: RichConfiguration
index: number
}
let { id, field = $bindable(), index }: Props = $props()
let hideable = $state(field && !(field?.type === 'static' && field?.value === false))
</script>
<Toggle
bind:checked={hideable}
size="xs"
options={{
right: 'Can be hidden'
}}
on:change={() => {
if (hideable) {
field = {
type: 'evalv2',
expr: 'false',
fieldType: 'boolean',
connections: []
}
} else {
field = {
type: 'static',
value: false,
fieldType: 'boolean'
}
}
}}
/>
{#if hideable}
<div transition:slide|local>
<InputsSpecEditor
key="tabHidden {index}"
bind:componentInput={field}
{id}
userInputEnabled={false}
shouldCapitalize={true}
resourceOnly={false}
fieldType={field?.['fieldType']}
subFieldType={field?.['subFieldType']}
format={field?.['format']}
selectOptions={field?.['selectOptions']}
tooltip={field?.['tooltip']}
fileUpload={field?.['fileUpload']}
placeholder={field?.['placeholder']}
customTitle={field?.['customTitle']}
displayType={false}
/>
</div>
{/if}