Card space type selector with submenu (#10750)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-04-12 12:37:00 +05:00
committed by GitHub
parent 576f5121f1
commit e390b94424
4 changed files with 254 additions and 15 deletions
@@ -0,0 +1,174 @@
<!--
// Copyright © 2025 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.
-->
<script lang="ts">
import { IntlString } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import ui, {
EditWithIcon,
Icon,
IconCheck,
IconChevronRight,
IconSearch,
Label,
NestedSelectPopup,
resizeObserver,
Submenu
} from '..'
import type { NestedSelectItem } from '../types'
export let items: NestedSelectItem[] = []
export let selectedValues: (string | number)[] = []
export let placeholder: IntlString = ui.string.Search
export let isTopLevel: boolean = true
export let onChange: ((values: (string | number)[]) => void) | undefined = undefined
const dispatch = createEventDispatcher()
let search: string = ''
function filterNodes (nodes: NestedSelectItem[], query: string): NestedSelectItem[] {
if (!query) return nodes
const q = query.toLowerCase()
return nodes
.map((node): NestedSelectItem | null => {
const filteredChildren = node.children ? filterNodes(node.children, query) : []
const labelStr = typeof node.label === 'string' ? node.label : ''
const matches = labelStr.toLowerCase().includes(q)
if (matches || filteredChildren.length > 0) {
return { ...node, children: filteredChildren }
}
return null
})
.filter((n): n is NestedSelectItem => n !== null)
}
$: displayItems = isTopLevel ? filterNodes(items, search) : items
function toggle (id: string | number): void {
const set = new Set(selectedValues)
if (set.has(id)) {
set.delete(id)
} else {
set.add(id)
}
selectedValues = Array.from(set)
dispatch('update', selectedValues)
onChange?.(selectedValues)
}
function handleUpdate (values: (string | number)[]): void {
selectedValues = values
dispatch('update', selectedValues)
onChange?.(selectedValues)
}
</script>
<div
class="selectPopup"
use:resizeObserver={() => {
dispatch('changeContent')
}}
>
{#if isTopLevel}
<div class="header">
<EditWithIcon icon={IconSearch} size={'large'} width={'100%'} bind:value={search} {placeholder} autoFocus />
</div>
{:else}
<div class="menu-space" />
{/if}
<div class="scroll">
<div class="box">
{#each displayItems as item (item.id)}
{#if item.children && item.children.length > 0}
<Submenu
withHover
props={{
items: item.children,
selectedValues,
isTopLevel: false,
onChange: handleUpdate
}}
options={{
component: NestedSelectPopup
}}
>
<div
slot="item"
class="flex-row-center w-full"
on:click={() => {
toggle(item.id)
}}
>
<div
class="check ml-0 mr-2"
on:click|stopPropagation={() => {
toggle(item.id)
}}
>
{#if selectedValues.includes(item.id)}
<Icon icon={IconCheck} size="small" />
{/if}
</div>
{#if item.icon}
<div class="icon">
<Icon icon={item.icon} iconProps={item.iconProps} size="small" />
</div>
{/if}
<div class="label"><Label label={item.label} /></div>
</div>
</Submenu>
{:else}
<button
class="menu-item flex-row-center w-full"
on:click={() => {
toggle(item.id)
}}
>
<div class="check ml-0 mr-2">
{#if selectedValues.includes(item.id)}
<Icon icon={IconCheck} size="small" />
{/if}
</div>
{#if item.icon}
<div class="icon">
<Icon icon={item.icon} iconProps={item.iconProps} size="small" />
</div>
{/if}
<div class="label"><Label label={item.label} /></div>
</button>
{/if}
{/each}
</div>
</div>
<div class="menu-space" />
</div>
<style lang="scss">
:global(.selectPopup .check),
:global(.selectPopup .icon) {
flex-shrink: 0;
width: 1rem;
height: 1rem;
display: flex;
justify-content: flex-start;
align-items: center;
}
:global(.selectPopup .icon .emoji) {
justify-content: flex-start !important;
}
</style>
+1
View File
@@ -288,6 +288,7 @@ export { default as EmbeddedHTML } from './components/EmbeddedHTML.svelte'
export { default as EmbeddedPDF } from './components/EmbeddedPDF.svelte'
export { default as NestedMenu } from './components/NestedMenu.svelte'
export { default as NestedDropdown } from './components/NestedDropdown.svelte'
export { default as NestedSelectPopup } from './components/NestedSelectPopup.svelte'
export { default as StatusBarButton } from './components/StatusBarButton.svelte'
export { default as AppLoading } from './components/AppLoading.svelte'
+6 -6
View File
@@ -13,12 +13,8 @@
// limitations under the License.
//
import type { AccountRole, Blob, Permission, Ref, Timestamp, TypedSpace } from '@hcengineering/core'
import type {
Asset,
IntlString,
/* Metadata, Plugin, plugin, */ Resource /*, Service */
} from '@hcengineering/platform'
import { /* getContext, */ type ComponentType } from 'svelte'
import type { Asset, IntlString, Resource } from '@hcengineering/platform'
import { type ComponentType } from 'svelte'
/**
* Describe a browser URI location parsed to path, query and fragment.
@@ -343,6 +339,10 @@ export interface DropdownIntlItem {
keys?: string[]
}
export interface NestedSelectItem extends DropdownIntlItem {
children?: NestedSelectItem[]
}
export interface PopupOptions {
props: Record<string, string | number>
showOverlay: boolean
@@ -13,22 +13,86 @@
// limitations under the License.
-->
<script lang="ts">
import { Button, Label, showPopup, getEventPositionElement } from '@hcengineering/ui'
import { ObjectBoxPopup } from '@hcengineering/view-resources'
import { Ref } from '@hcengineering/core'
import {
Button,
Label,
showPopup,
getEventPositionElement,
NestedSelectPopup,
type NestedSelectItem
} from '@hcengineering/ui'
import { Ref, ClassifierKind, type Class, type Doc } from '@hcengineering/core'
import { MasterTag } from '@hcengineering/card'
import { getClient, IconWithEmoji } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import card from '../../plugin'
export let value: Ref<MasterTag>[]
const client = getClient()
const hierarchy = client.getHierarchy()
function buildHierarchy (): NestedSelectItem[] {
const rootTags = hierarchy.getDescendants(card.class.Card)
const items: NestedSelectItem[] = []
for (const id of rootTags) {
const cls = hierarchy.getClass(id) as MasterTag
if (
cls.label === undefined ||
cls.kind !== ClassifierKind.CLASS ||
cls.removed === true ||
cls.extends !== card.class.Card
) {
continue
}
items.push({
id: cls._id,
label: cls.label,
...getIconProps(cls),
children: getChildrenNodes(id)
})
}
return items.sort((a, b) =>
typeof a.label === 'string' && typeof b.label === 'string' ? a.label.localeCompare(b.label) : 0
)
}
function getChildrenNodes (parentId: Ref<Class<Doc>>): NestedSelectItem[] {
const descendants = hierarchy.getDescendants(parentId)
const items: NestedSelectItem[] = []
for (const id of descendants) {
if (id === parentId) continue
const cls = hierarchy.getClass(id) as MasterTag
if (cls.label === undefined || cls.kind !== ClassifierKind.CLASS || cls.removed === true) continue
items.push({
id: cls._id,
label: cls.label,
...getIconProps(cls),
children: getChildrenNodes(id)
})
}
return items.sort((a, b) =>
typeof a.label === 'string' && typeof b.label === 'string' ? a.label.localeCompare(b.label) : 0
)
}
function getIconProps (tag: MasterTag): { icon: any, iconProps: any } {
return {
icon: tag.icon === view.ids.IconWithEmoji ? IconWithEmoji : (tag.icon ?? card.icon.MasterTag),
iconProps: tag.icon === view.ids.IconWithEmoji ? { icon: tag.color } : {}
}
}
function openPopup (ev: MouseEvent): void {
showPopup(
ObjectBoxPopup,
NestedSelectPopup,
{
_class: card.class.MasterTag,
selectedObjects: value,
multiSelect: true,
docQuery: { extends: card.class.Card, removed: { $ne: true } },
searchField: 'label'
items: buildHierarchy(),
selectedValues: value
},
getEventPositionElement(ev),
undefined,