Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/ui/src/__test__/selectPopupCategories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import type { IntlString } from '@hcengineering/platform'
import { buildSelectPopupRows, loadCollapsedCategories, saveCollapsedCategories } from '../selectPopupCategories'
import type { SelectPopupValueType } from '../types'

const todo = { label: 'cat:ToDo' as IntlString }
const done = { label: 'cat:Done' as IntlString }

const items: SelectPopupValueType[] = [
{ id: 'a', text: 'A', category: todo },
{ id: 'b', text: 'B', category: todo },
{ id: 'c', text: 'C', category: done },
{ id: 'd', text: 'D' }
]

function describeRows (rows: ReturnType<typeof buildSelectPopupRows>): string[] {
return rows.map((row) =>
row.kind === 'category'
? `[${row.category.label} ${row.count}${row.collapsed ? ' collapsed' : ''}]`
: String(row.item.id)
)
}

describe('buildSelectPopupRows', () => {
it('adds a header before each category group', () => {
expect(describeRows(buildSelectPopupRows(items, new Set()))).toEqual([
'[cat:ToDo 2]',
'a',
'b',
'[cat:Done 1]',
'c',
'd'
])
})

it('hides items of collapsed categories but keeps the header with the item count', () => {
expect(describeRows(buildSelectPopupRows(items, new Set([todo.label])))).toEqual([
'[cat:ToDo 2 collapsed]',
'[cat:Done 1]',
'c',
'd'
])
})

it('expands all groups when requested, e.g. while searching', () => {
expect(describeRows(buildSelectPopupRows(items, new Set([todo.label, done.label]), true))).toEqual([
'[cat:ToDo 2]',
'a',
'b',
'[cat:Done 1]',
'c',
'd'
])
})
})

describe('collapsed categories storage', () => {
beforeEach(() => {
localStorage.clear()
})

it('remembers collapsed categories per key', () => {
saveCollapsedCategories('status', new Set([todo.label]))
expect(Array.from(loadCollapsedCategories('status'))).toEqual([todo.label])
expect(loadCollapsedCategories('other').size).toBe(0)
expect(loadCollapsedCategories(undefined).size).toBe(0)

saveCollapsedCategories('status', new Set())
expect(loadCollapsedCategories('status').size).toBe(0)
})

it('ignores malformed stored values', () => {
localStorage.setItem('selectPopup.collapsedCategories.status', '{broken')
expect(loadCollapsedCategories('status').size).toBe(0)
})
})
181 changes: 131 additions & 50 deletions packages/ui/src/components/SelectPopup.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@
import Spinner from './Spinner.svelte'
import IconCheck from './icons/Check.svelte'
import IconSearch from './icons/Search.svelte'
import IconCollapseArrow from './icons/CollapseArrow.svelte'
import {
buildSelectPopupRows,
loadCollapsedCategories,
saveCollapsedCategories,
type SelectPopupRow
} from '../selectPopupCategories'
import { translate } from '@hcengineering/platform'
import { themeStore } from '@hcengineering/theme'

Expand All @@ -40,6 +47,9 @@
export let embedded: boolean = false
export let componentLink: boolean = false
export let loading: boolean = false
// Show category headers as collapsible groups; collapse state is remembered under collapsedCategoriesKey
export let collapsibleCategories: boolean = false
export let collapsedCategoriesKey: string | undefined = undefined

let popupElement: HTMLDivElement | undefined = undefined
export let search: string = ''
Expand All @@ -53,6 +63,27 @@

let selected: any

let collapsedCategories = loadCollapsedCategories(collapsedCategoriesKey)

function toggleCategory (label: string): void {
if (collapsedCategories.has(label)) {
collapsedCategories.delete(label)
} else {
collapsedCategories.add(label)
}
collapsedCategories = collapsedCategories
saveCollapsedCategories(collapsedCategoriesKey, collapsedCategories)
}

function selectRow (row: SelectPopupRow | undefined): void {
if (row === undefined) return
if (row.kind === 'category') {
toggleCategory(row.category.label)
} else {
sendSelect(row.item.id)
}
}

function sendSelect (id: SelectPopupValueType['id']): void {
selected = id
if (onSelect) {
Expand Down Expand Up @@ -84,7 +115,7 @@
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
sendSelect(filteredObjects[selection].id)
selectRow(rows[selection])
return true
}
return false
Expand Down Expand Up @@ -112,6 +143,20 @@
(itemLabelsTranslation.get(el.label ?? '') ?? el.text ?? '').toLowerCase().includes(search.toLowerCase())
)

$: rows = collapsibleCategories
? buildSelectPopupRows(filteredObjects, collapsedCategories, search !== '')
: filteredObjects.map((item): SelectPopupRow => ({ kind: 'item', item }))

// Start keyboard navigation on the first item instead of the first category header
let initialRowSelected = false
$: if (collapsibleCategories && !initialRowSelected && rows.length > 0) {
initialRowSelected = true
selection = Math.max(
0,
rows.findIndex((row) => row.kind === 'item')
)
}

$: huge = size === 'medium' || size === 'large'

$: if (popupElement) {
Expand Down Expand Up @@ -154,64 +199,100 @@
{/if}
<div class="scroll">
<div class="box">
<ListView
bind:this={list}
count={filteredObjects.length}
bind:selection
on:changeContent={() => dispatch('changeContent')}
>
<ListView bind:this={list} count={rows.length} bind:selection on:changeContent={() => dispatch('changeContent')}>
<svelte:fragment slot="item" let:item={itemId}>
{@const item = filteredObjects[itemId]}
<button
class="menu-item withList w-full"
on:click={() => {
sendSelect(item.id)
}}
disabled={loading}
>
<div class="flex-row-center flex-grow" class:pointer-events-none={!componentLink}>
{#if item.component}
<div class="flex-grow clear-mins"><svelte:component this={item.component} {...item.props} /></div>
{:else}
{#if item.icon}
<div class="icon mr-2">
<Icon icon={item.icon} iconProps={item.iconProps} fill={item.iconColor ?? 'currentColor'} {size} />
</div>
{/if}
<span class="label overflow-label flex-grow" class:text-base={huge}>
{#if item.label}
<Label label={item.label} />
{:else if item.text}
{item.text}
{/if}
{@const row = rows[itemId]}
{#if row.kind === 'category'}
<button
class="menu-item withList w-full"
class:collapsed={row.collapsed}
on:click={() => {
toggleCategory(row.category.label)
}}
>
<div class="flex-row-center flex-grow pointer-events-none">
<div class="category-chevron mr-2"><IconCollapseArrow size={'small'} /></div>
<span class="overflow-label flex-grow content-dark-color">
<Label label={row.category.label} />
</span>
{/if}
{#if hasSelected}
<div class="check">
{#if item.isSelected}
<Icon icon={IconCheck} size={'small'} />
<span class="ml-2 content-dark-color">{row.count}</span>
</div>
</button>
{:else}
{@const item = row.item}
<button
class="menu-item withList w-full"
on:click={() => {
sendSelect(item.id)
}}
disabled={loading}
>
<div class="flex-row-center flex-grow" class:pointer-events-none={!componentLink}>
{#if item.component}
<div class="flex-grow clear-mins"><svelte:component this={item.component} {...item.props} /></div>
{:else}
{#if item.icon}
<div class="icon mr-2">
<Icon
icon={item.icon}
iconProps={item.iconProps}
fill={item.iconColor ?? 'currentColor'}
{size}
/>
</div>
{/if}
</div>
{/if}
{#if item.id === selected && loading}
<Spinner size={'small'} />
{/if}
</div>
</button>
<span class="label overflow-label flex-grow" class:text-base={huge}>
{#if item.label}
<Label label={item.label} />
{:else if item.text}
{item.text}
{/if}
</span>
{/if}
{#if hasSelected}
<div class="check">
{#if item.isSelected}
<Icon icon={IconCheck} size={'small'} />
{/if}
</div>
{/if}
{#if item.id === selected && loading}
<Spinner size={'small'} />
{/if}
</div>
</button>
{/if}
</svelte:fragment>
<svelte:fragment slot="category" let:item={row}>
{@const obj = filteredObjects[row]}
{#if obj.category && ((row === 0 && obj.category.label !== undefined) || obj.category.label !== filteredObjects[row - 1]?.category?.label)}
{#if row > 0}<div class="menu-separator" />{/if}
<div class="menu-group__header flex-row-center">
<span class="overflow-label">
<Label label={obj.category.label} />
</span>
</div>
{#if collapsibleCategories}
{#if row > 0 && rows[row]?.kind === 'category'}<div class="menu-separator" />{/if}
{:else}
{@const obj = filteredObjects[row]}
{#if obj.category && ((row === 0 && obj.category.label !== undefined) || obj.category.label !== filteredObjects[row - 1]?.category?.label)}
{#if row > 0}<div class="menu-separator" />{/if}
<div class="menu-group__header flex-row-center">
<span class="overflow-label">
<Label label={obj.category.label} />
</span>
</div>
{/if}
{/if}
</svelte:fragment>
</ListView>
</div>
</div>
{#if !embedded}<div class="menu-space" />{/if}
</div>

<style lang="scss">
.category-chevron {
display: flex;
flex-shrink: 0;
color: var(--theme-dark-color);
transform: rotate(90deg);
transition: transform 0.15s ease-in-out;
}
.collapsed .category-chevron {
transform: rotate(0deg);
}
</style>
Loading