feat: app builder accordion list and accordion tab component (#5132)

* feat: tabs component accordion

* accordion tabs

* accordionlistcomponent

* types

* Update AppTabs.svelte

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Alexander Petric
2025-01-27 09:47:58 -05:00
committed by GitHub
parent 2800903c9a
commit 0e41023889
7 changed files with 291 additions and 27 deletions
@@ -0,0 +1,163 @@
<script lang="ts">
import { getContext } from 'svelte'
import { initOutput } from '../../editor/appUtils'
import SubGridEditor from '../../editor/SubGridEditor.svelte'
import type { AppViewerContext, ComponentCustomCSS } from '../../types'
import { initCss } from '../../utils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import type { AppInput } from '../../inputType'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
import ListWrapper from '../layout/ListWrapper.svelte'
import { twMerge } from 'tailwind-merge'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
export let id: string
export let componentInput: AppInput | undefined
export let customCss: ComponentCustomCSS<'accordionlistcomponent'> | undefined = undefined
export let render: boolean
export let initializing: boolean | undefined
export let componentContainerHeight: number
type AccordionListValue = { header: string; [key: string]: any };
type InternalAccordionListInput = AppInput & {
value: AccordionListValue[];
};
const accordionInput = componentInput as InternalAccordionListInput
const { app, focusedGrid, selectedComponent, worldStore, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
let activeIndex: number = 0
const outputs = initOutput($worldStore, id, {
result: undefined,
activeIndex: 0,
loading: false,
inputs: {}
})
function onFocus() {
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
}
}
let css = initCss($app.css?.accordionlistcomponent, customCss)
let result: any[] | undefined = undefined
let inputs = {}
$: $selectedComponent?.includes(id) &&
$focusedGrid === undefined &&
($focusedGrid = {
parentComponentId: id,
subGridIndex: 0
})
function toggleAccordion(index: number) {
activeIndex = activeIndex === index ? -1 : index
outputs.activeIndex.set(activeIndex)
}
</script>
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.accordionlistcomponent}
/>
{/each}
<InitializeComponent {id} />
<RunnableWrapper
render={true}
{outputs}
autoRefresh
{componentInput}
{id}
bind:initializing
bind:result
>
<div class="w-full flex flex-col overflow-auto max-h-full">
{#if $app.subgrids?.[`${id}-0`]}
{#if Array.isArray(result) && result.length > 0}
{#each result ?? [] as value, index}
<div class="border-b">
<button
on:pointerdown|stopPropagation
on:click={() => toggleAccordion(index)}
class={twMerge(
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
'wm-tabs-alltabs',
activeIndex === index
? twMerge('bg-surface text-primary ', 'wm-tabs-selectedTab')
: 'text-secondary'
)}
>
<span class="mr-2 w-8 font-mono">{activeIndex === index ? '-' : '+'}</span>
{accordionInput?.value[index].header || `Header ${index}`}
</button>
{#if activeIndex === index}
<div class="p-2 overflow-auto w-full">
<ListWrapper
onSet={(id, value) => {
if (!inputs[id]) {
inputs[id] = { [index]: value }
} else {
inputs[id] = { ...inputs[id], [index]: value }
}
outputs?.inputs.set(inputs, true)
}}
onRemove={(id) => {
if (inputs?.[id] == undefined) {
return
}
if (index == 0) {
delete inputs[id]
inputs = { ...inputs }
} else {
delete inputs[id][index]
inputs[id] = { ...inputs[id] }
}
outputs?.inputs.set(inputs, true)
}}
{value}
{index}
>
<SubGridEditor
{id}
visible={render}
class={twMerge(css?.container?.class, 'wm-accordion')}
style={css?.container?.style}
subGridId={`${id}-0`}
containerHeight={componentContainerHeight -
(30 * accordionInput?.value.length + 40)}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
</ListWrapper>
</div>
{/if}
</div>
{/each}
{:else}
<ListWrapper disabled value={undefined} index={0}>
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
</ListWrapper>
{#if !Array.isArray(result)}
<div class="text-center text-tertiary">Input data is not an array</div>
{/if}
{/if}
{/if}
</div>
</RunnableWrapper>
@@ -48,6 +48,8 @@
selectedTabIndex: 0
})
const titleBarHeight = 40; // Example height of a single accordion title bar in pixels
function handleTabSelection() {
selectedIndex = tabs?.indexOf(selected)
outputs?.selectedTabIndex.set(selectedIndex)
@@ -170,28 +172,72 @@
</button>
{/each}
</div>
{/if}
<div class="w-full">
{#if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor
{id}
visible={render && i === selectedIndex}
subGridId={`${id}-${i}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={resolvedConfig.tabsKind !== 'sidebar' && $mode !== 'preview'
? componentContainerHeight - tabHeight
: componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
{:else if resolvedConfig.tabsKind == 'accordion'}
<div class="flex flex-col w-full">
{#each tabs ?? [] as res, index}
<div class="border-b">
<button
on:pointerdown|stopPropagation
on:click={() => (selected = res)}
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'
)}
>
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
{res}
</button>
{#if selected == res}
<div class="p-2 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)}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
{:else}
<div class="w-full">
{#if $app.subgrids}
{#each tabs ?? [] as _res, i}
<SubGridEditor
{id}
visible={render && i === selectedIndex}
subGridId={`${id}-${i}`}
class={twMerge(css?.container?.class, 'wm-tabs-container')}
style={css?.container?.style}
containerHeight={resolvedConfig.tabsKind !== 'sidebar' && $mode !== 'preview'
? componentContainerHeight - tabHeight
: componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
handleTabSelection()
}
}}
/>
{/each}
{/if}
</div>
{/if}
</div>
@@ -27,6 +27,7 @@
import AppJobIdLogComponent from '../../components/display/AppJobIdLogComponent.svelte'
import AppJobIdFlowStatus from '../../components/display/AppJobIdFlowStatus.svelte'
import AppCarouselList from '../../components/display/AppCarouselList.svelte'
import AppAccordionList from '../../components/display/AppAccordionList.svelte'
import AppAggridTableEe from '../../components/display/table/AppAggridTableEe.svelte'
import AppCustomComponent from '../../components/display/AppCustomComponent.svelte'
import AppStatCard from '../../components/display/AppStatCard.svelte'
@@ -871,6 +872,15 @@
{render}
bind:initializing
/>
{:else if component.type === 'accordionlistcomponent'}
<AppAccordionList
id={component.id}
componentInput={component.componentInput}
customCss={component.customCss}
{componentContainerHeight}
{render}
bind:initializing
/>
{:else if component.type === 'statcomponent'}
<AppStatCard
id={component.id}
@@ -51,7 +51,9 @@ import {
CalendarClock,
AppWindow,
PanelTop,
RefreshCw
RefreshCw,
ListCollapse,
GalleryThumbnails
} from 'lucide-svelte'
import type {
Aligned,
@@ -244,6 +246,7 @@ export type SelectTabComponent = BaseComponent<'selecttabcomponent'>
export type SelectStepComponent = BaseComponent<'selectstepcomponent'>
export type CarouselListComponent = BaseComponent<'carousellistcomponent'>
export type AccordionListComponent = BaseComponent<'accordionlistcomponent'>
export type StatisticCardComponent = BaseComponent<'statcomponent'>
export type MenuComponent = BaseComponent<'menucomponent'> & {
menuItems: (BaseAppComponent & ButtonComponent & GridItem)[]
@@ -351,6 +354,7 @@ export type TypedComponent =
| DownloadComponent
| ChartJsComponent
| CarouselListComponent
| AccordionListComponent
| PlotlyComponentV2
| ChartJsComponentV2
| StatisticCardComponent
@@ -446,7 +450,7 @@ const buttonColorOptions = [...BUTTON_COLORS]
export const selectOptions = {
buttonColorOptions,
tabsKindOptions: ['tabs', 'sidebar', 'invisibleOnView'],
tabsKindOptions: ['tabs', 'sidebar', 'accordion', 'invisibleOnView'],
buttonSizeOptions: ['xs', 'sm', 'md', 'lg', 'xl'],
tableSearchOptions: ['By Component', 'By Runnable', 'Disabled'],
chartThemeOptions: ['theme1', 'theme2', 'theme3'],
@@ -2852,7 +2856,7 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
},
carousellistcomponent: {
name: 'Carousel List',
icon: ListIcon,
icon: GalleryThumbnails,
documentationLink: `${documentationBaseUrl}/carousel`,
dims: '3:8-12:8' as AppComponentDimensions,
customCss: {
@@ -2878,6 +2882,25 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
numberOfSubgrids: 1
}
},
accordionlistcomponent: {
name: 'Accordion List',
icon: ListCollapse,
documentationLink: `${documentationBaseUrl}/accordion`,
dims: '3:8-12:8' as AppComponentDimensions,
customCss: {
container: { class: '', style: '' }
},
initialData: {
configuration: {},
componentInput: {
type: 'static',
fieldType: 'array',
subFieldType: 'object',
value: [{ header: 'First', foo: 1 }, { header: 'Second', foo: 2 }, { header: 'Third', foo: 3 }] as object[]
},
numberOfSubgrids: 1
}
},
iconcomponent: {
name: 'Icon',
icon: Smile,
@@ -4152,6 +4175,17 @@ export const presetComponents = {
},
type: 'sidebartabscomponent'
},
accordiontabcomponent: {
name: 'Accordion Tabs',
icon: ListCollapse,
targetComponent: 'tabscomponent' as const,
configuration: {
tabsKind: {
value: 'accordion'
}
},
type: 'accordiontabcomponent'
},
invisibletabscomponent: {
name: 'Invisible Tabs',
icon: PanelTopInactive,
@@ -3,7 +3,7 @@ import type { ComponentSet } from '../../types'
const tabs: ComponentSet = {
title: 'Tabs',
components: ['tabscomponent', 'conditionalwrapper'],
presets: ['sidebartabscomponent', 'invisibletabscomponent']
presets: ['sidebartabscomponent', 'accordiontabcomponent', 'invisibletabscomponent']
} as const
const layout: ComponentSet = {
@@ -19,6 +19,7 @@ const layout: ComponentSet = {
'modalcomponent',
'steppercomponent',
'carousellistcomponent',
'accordionlistcomponent',
'decisiontreecomponent',
'navbarcomponent',
'recomputeallcomponent'
@@ -299,6 +299,13 @@ export const customisationByComponent: Customisation[] = [
],
variables: []
},
{
components: ['accordionlistcomponent'],
selectors: [
{ selector: '.wm-accordion', comment: 'Accordion component', customCssKey: 'container' }
],
variables: []
},
{
components: ['formcomponent'],
selectors: [
@@ -654,6 +654,9 @@ export const quickStyleProperties: Record<
header: [...containerDefaultProps, typographyGrouping],
container: containerDefaultProps
},
accordionlistcomponent: {
container: containerDefaultProps
},
carousellistcomponent: {
container: containerDefaultProps
},