diff --git a/packages/ui/src/__test__/selectPopupCategories.test.ts b/packages/ui/src/__test__/selectPopupCategories.test.ts new file mode 100644 index 00000000000..e7b0022a4d0 --- /dev/null +++ b/packages/ui/src/__test__/selectPopupCategories.test.ts @@ -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): 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) + }) +}) diff --git a/packages/ui/src/components/SelectPopup.svelte b/packages/ui/src/components/SelectPopup.svelte index 967babacaad..0f5f2088a8d 100644 --- a/packages/ui/src/components/SelectPopup.svelte +++ b/packages/ui/src/components/SelectPopup.svelte @@ -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' @@ -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 = '' @@ -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) { @@ -84,7 +115,7 @@ if (key.code === 'Enter') { key.preventDefault() key.stopPropagation() - sendSelect(filteredObjects[selection].id) + selectRow(rows[selection]) return true } return false @@ -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) { @@ -154,60 +199,83 @@ {/if}
- dispatch('changeContent')} - > + dispatch('changeContent')}> - {@const item = filteredObjects[itemId]} - + {:else} + {@const item = row.item} +
- + + {#if item.label} + + {/if} + {#if hasSelected} +
+ {#if item.isSelected} + + {/if} +
+ {/if} + {#if item.id === selected && loading} + + {/if} +
+ + {/if} - {@const obj = filteredObjects[row]} - {#if obj.category && ((row === 0 && obj.category.label !== undefined) || obj.category.label !== filteredObjects[row - 1]?.category?.label)} - {#if row > 0}