Add Submenu component. Update tooltip, Menu. ()

Signed-off-by: Alexander Platov <sas_lord@mail.ru>
This commit is contained in:
Alexander Platov 2022-06-16 04:25:49 +03:00 committed by GitHub
parent d2e969b715
commit b8e10b4240
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 293 additions and 96 deletions
packages
plugins
tracker-resources/src/components
workbench-resources/src/components
tests/sanity/tests

View File

@ -29,6 +29,14 @@ a {
text-decoration: underline;
}
&:visited { color: var(--theme-caption-color); }
&.stealth {
display: inline-flex;
align-items: center;
width: 100%;
&:hover, &:active { text-decoration: none; }
}
}
button {
display: flex;
@ -196,6 +204,7 @@ input.search {
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.justify-center { justify-content: center; }
.justify-stretch { justify-content: stretch; }
.items-baseline { align-items: baseline; }
.items-center { align-items: center; }

View File

@ -238,6 +238,12 @@
}
}
&.withCheck { justify-content: space-between; }
&.withIcon {
margin: 0;
.icon { color: var(--content-color); }
&:focus .icon { color: var(--accent-color); }
}
// &:hover { background-color: var(--popup-bg-hover); }
&:focus {
@ -331,6 +337,41 @@
}
}
// Submenu
.antiPopup-submenu {
display: flex;
align-items: center;
flex-shrink: 0;
justify-content: start;
padding: .25rem .75rem;
min-width: 0;
min-height: 2rem;
text-align: left;
color: var(--caption-color);
cursor: pointer;
.icon { color: var(--content-color); }
&:focus .icon,
&.withHover:hover .icon { color: var(--accent-color); }
&.withHover:hover { background-color: var(--popup-bg-hover); }
}
.antiPopup .ap-menuItem.arrow,
.selectPopup .menu-item.arrow,
.antiPopup-submenu {
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
right: 0.5rem;
font-size: 0.375rem;
color: var(--dark-color);
transform: translateY(-50%);
}
}
.notifyPopup {
overflow: hidden;
display: flex;

View File

@ -59,41 +59,43 @@
</div>
{/if}
{#each actions as action, i}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={btns[i]}
class="ap-menuItem flex-row-center withIcon"
on:keydown={(evt) => keyDown(evt, i)}
on:mouseover={(evt) => {
evt.currentTarget.focus()
}}
on:click={(evt) => {
if (!action.inline) {
dispatch('close')
}
action.action(ctx, evt)
}}
>
{#if action.icon}
<div class="icon"><Icon icon={action.icon} size={'small'} /></div>
{/if}
<div class="ml-3 pr-1"><Label label={action.label} /></div>
</button>
{#if action.link}
<a class="stealth" href={action.link}>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={btns[i]}
class="ap-menuItem flex-row-center withIcon w-full"
on:keydown={(evt) => keyDown(evt, i)}
on:mouseover={(evt) => evt.currentTarget.focus()}
on:click|preventDefault|stopPropagation={(evt) => {
if (!action.inline) dispatch('close')
action.action(ctx, evt)
}}
>
{#if action.icon}<div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div>{/if}
<span class="overflow-label pr-1"><Label label={action.label} /></span>
</button>
</a>
{:else}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={btns[i]}
class="ap-menuItem flex-row-center withIcon"
on:keydown={(evt) => keyDown(evt, i)}
on:mouseover={(evt) => evt.currentTarget.focus()}
on:click={(evt) => {
if (!action.inline) dispatch('close')
action.action(ctx, evt)
}}
>
{#if action.icon}
<div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div>
{/if}
<span class="overflow-label pr-1"><Label label={action.label} /></span>
</button>
{/if}
{/each}
</div>
</div>
<div class="ap-space" />
</div>
<style lang="scss">
.withIcon {
margin: 0;
.icon {
color: var(--content-color);
}
&:focus .icon {
color: var(--accent-color);
}
}
</style>

View File

@ -0,0 +1,53 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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 { Asset, IntlString } from '@anticrm/platform'
import type { LabelAndProps, AnySvelteComponent, AnyComponent } from '../types'
import { Icon, Label, Component, Menu } from '..'
import { tooltip } from '../tooltips'
export let component: AnySvelteComponent | AnyComponent | undefined = undefined
export let props: any = {}
export let options: LabelAndProps = { kind: 'submenu' }
export let focusIndex = -1
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let text: string | undefined = undefined
export let label: IntlString | undefined = undefined
export let labelProps: Record<string, any> = {}
export let withHover: boolean = false
let element: HTMLElement
let optionsMod: LabelAndProps
$: optionsMod = { component: options.component ?? Menu, props, element, kind: 'submenu' }
</script>
<div bind:this={element} use:tooltip={optionsMod} class="antiPopup-submenu" class:withHover tabindex={focusIndex}>
{#if component}
{#if typeof component === 'string'}
<Component is={component} {props} />
{:else}
<svelte:component this={component} {...props} />
{/if}
{:else}
{#if icon}
<div class="icon mr-3"><Icon {icon} size={'small'} /></div>
{/if}
<span class="overflow-label pr-1">
{#if label}<Label {label} params={labelProps} />
{:else if text}{text}{/if}
</span>
{/if}
</div>

View File

@ -26,31 +26,37 @@
let tooltipSW: boolean // tooltipSW = true - Label; false - Component
let nubDirection: 'top' | 'bottom' | 'left' | 'right' | undefined = undefined
let clWidth: number
let docWidth: number
let docHeight: number
$: tooltipSW = !$tooltip.component
$: tooltipSW = !$tooltip.component && $tooltip.kind !== 'submenu'
$: onUpdate = $tooltip.onUpdate
$: kind = $tooltip.kind
const clearStyles = (): void => {
tooltipHTML.style.top =
tooltipHTML.style.bottom =
tooltipHTML.style.left =
tooltipHTML.style.right =
tooltipHTML.style.height =
''
}
const fitTooltip = (): void => {
if (($tooltip.label || $tooltip.component) && tooltipHTML) {
if ($tooltip.element) {
const doc = document.body.getBoundingClientRect()
rect = $tooltip.element.getBoundingClientRect()
rectAnchor = $tooltip.anchor
? $tooltip.anchor.getBoundingClientRect()
: $tooltip.element.getBoundingClientRect()
if ($tooltip.component) {
tooltipHTML.style.top =
tooltipHTML.style.bottom =
tooltipHTML.style.left =
tooltipHTML.style.right =
tooltipHTML.style.height =
''
if (rect.bottom + tooltipHTML.clientHeight + 28 < doc.height) {
clearStyles()
if (rect.bottom + tooltipHTML.clientHeight + 28 < docHeight) {
tooltipHTML.style.top = `calc(${rect.bottom}px + 5px + .25rem)`
dir = 'bottom'
} else if (rect.top > doc.height - rect.bottom) {
tooltipHTML.style.bottom = `calc(${doc.height - rect.y}px + 5px + .25rem)`
} else if (rect.top > docHeight - rect.bottom) {
tooltipHTML.style.bottom = `calc(${docHeight - rect.y}px + 5px + .25rem)`
if (tooltipHTML.clientHeight > rect.top - 28) {
tooltipHTML.style.top = '1rem'
tooltipHTML.style.height = `calc(${rect.top}px - 5px - 1.25rem)`
@ -58,15 +64,15 @@
dir = 'top'
} else {
tooltipHTML.style.top = `calc(${rect.bottom}px + 5px + .25rem)`
if (tooltipHTML.clientHeight > doc.height - rect.bottom - 28) {
if (tooltipHTML.clientHeight > docHeight - rect.bottom - 28) {
tooltipHTML.style.bottom = '1rem'
tooltipHTML.style.height = `calc(${doc.height - rect.bottom}px - 5px - 1.25rem)`
tooltipHTML.style.height = `calc(${docHeight - rect.bottom}px - 5px - 1.25rem)`
}
dir = 'bottom'
}
const tempLeft = rect.width / 2 + rect.left - clWidth / 2
if (tempLeft + clWidth > doc.width - 8) tooltipHTML.style.right = '.5rem'
if (tempLeft + clWidth > docWidth - 8) tooltipHTML.style.right = '.5rem'
else if (tempLeft < 8) tooltipHTML.style.left = '.5rem'
else tooltipHTML.style.left = `${tempLeft}px`
@ -79,8 +85,8 @@
}
} else {
if (!$tooltip.direction) {
if (rectAnchor.right < doc.width / 5) dir = 'right'
else if (rectAnchor.left > doc.width - doc.width / 5) dir = 'left'
if (rectAnchor.right < docWidth / 5) dir = 'right'
else if (rectAnchor.left > docWidth - docWidth / 5) dir = 'left'
else if (rectAnchor.top < tooltipHTML.clientHeight) dir = 'bottom'
else dir = 'top'
} else dir = $tooltip.direction
@ -91,14 +97,14 @@
tooltipHTML.style.transform = 'translateY(-50%)'
} else if (dir === 'left') {
tooltipHTML.style.top = rectAnchor.y + rectAnchor.height / 2 + 'px'
tooltipHTML.style.right = `calc(${doc.width - rectAnchor.x}px + .75rem)`
tooltipHTML.style.right = `calc(${docWidth - rectAnchor.x}px + .75rem)`
tooltipHTML.style.transform = 'translateY(-50%)'
} else if (dir === 'bottom') {
tooltipHTML.style.top = `calc(${rectAnchor.bottom}px + .5rem)`
tooltipHTML.style.left = rectAnchor.x + rectAnchor.width / 2 + 'px'
tooltipHTML.style.transform = 'translateX(-50%)'
} else if (dir === 'top') {
tooltipHTML.style.bottom = `calc(${doc.height - rectAnchor.y}px + .75rem)`
tooltipHTML.style.bottom = `calc(${docHeight - rectAnchor.y}px + .75rem)`
tooltipHTML.style.left = rectAnchor.x + rectAnchor.width / 2 + 'px'
tooltipHTML.style.transform = 'translateX(-50%)'
}
@ -116,6 +122,29 @@
} else if (tooltipHTML) tooltipHTML.style.visibility = 'hidden'
}
const fitSubmenu = (): void => {
if (($tooltip.label || $tooltip.component) && tooltipHTML) {
clearStyles()
if ($tooltip.element) {
rect = $tooltip.element.getBoundingClientRect()
const rectP = tooltipHTML.getBoundingClientRect()
const dirH =
docWidth - rect.right - rectP.width - 16 > 0 ? 'right' : rect.left > docWidth - rect.right ? 'left' : 'right'
const dirV =
docHeight - rect.top - rectP.height - 16 > 0
? 'bottom'
: rect.bottom > docHeight - rect.top
? 'top'
: 'bottom'
if (dirH === 'right') tooltipHTML.style.left = rect.right - 4 + 'px'
else tooltipHTML.style.right = docWidth - rect.left - 4 + 'px'
if (dirV === 'bottom') tooltipHTML.style.top = rect.top - 4 + 'px'
else tooltipHTML.style.bottom = docHeight - rect.bottom - 4 + 'px'
tooltipHTML.style.visibility = 'visible'
}
} else if (tooltipHTML) tooltipHTML.style.visibility = 'hidden'
}
const hideTooltip = (): void => {
if (tooltipHTML) tooltipHTML.style.visibility = 'hidden'
closeTooltip()
@ -124,29 +153,23 @@
const whileShow = (ev: MouseEvent): void => {
if ($tooltip.element && tooltipHTML) {
const rectP = tooltipHTML.getBoundingClientRect()
const dT: number = dir === 'bottom' ? 12 : 0
const dB: number = dir === 'top' ? 12 : 0
const dT: number = dir === 'bottom' && $tooltip.kind !== 'submenu' ? 12 : 0
const dB: number = dir === 'top' && $tooltip.kind !== 'submenu' ? 12 : 0
const inTrigger: boolean = ev.x >= rect.left && ev.x <= rect.right && ev.y >= rect.top && ev.y <= rect.bottom
const inPopup: boolean =
ev.x >= rectP.left && ev.x <= rectP.right && ev.y >= rectP.top - dT && ev.y <= rectP.bottom + dB
if (tooltipSW) {
if (!inTrigger) {
hideTooltip()
}
} else {
if (!(inTrigger || inPopup)) {
hideTooltip()
}
}
if ((tooltipSW && !inTrigger) || !(inTrigger || inPopup)) hideTooltip()
}
}
afterUpdate(() => fitTooltip())
afterUpdate(() => (kind === 'submenu' ? fitSubmenu() : fitTooltip()))
onDestroy(() => hideTooltip())
</script>
<svelte:window
bind:innerWidth={docWidth}
bind:innerHeight={docHeight}
on:resize={hideTooltip}
on:mousemove={(ev) => {
whileShow(ev)
@ -159,7 +182,7 @@
}
}}
/>
{#if $tooltip.component}
{#if $tooltip.component && $tooltip.kind !== 'submenu'}
<div class="popup-tooltip" class:doublePadding={$tooltip.label} bind:clientWidth={clWidth} bind:this={tooltipHTML}>
{#if $tooltip.label}<div class="fs-title mb-4">
<Label label={$tooltip.label} params={$tooltip.props ?? {}} />
@ -179,13 +202,36 @@
{/if}
</div>
<div bind:this={nubHTML} class="nub {nubDirection ?? ''}" />
{:else if $tooltip.label}
{:else if $tooltip.label && $tooltip.kind !== 'submenu'}
<div class="tooltip {dir ?? ''}" bind:this={tooltipHTML}>
<Label label={$tooltip.label} params={$tooltip.props ?? {}} />
</div>
{:else if $tooltip.kind === 'submenu'}
<div class="submenu-container {dir ?? ''}" bind:clientWidth={clWidth} bind:this={tooltipHTML}>
{#if typeof $tooltip.component === 'string'}
<Component
is={$tooltip.component}
props={$tooltip.props}
on:update={onUpdate !== undefined ? onUpdate : async () => {}}
/>
{:else}
<svelte:component
this={$tooltip.component}
{...$tooltip.props}
on:update={onUpdate !== undefined ? onUpdate : async () => {}}
/>
{/if}
</div>
{/if}
<style lang="scss">
.submenu-container {
position: fixed;
width: auto;
height: auto;
border-radius: 0.5rem;
z-index: 10000;
}
.popup-tooltip {
overflow: hidden;
position: fixed;

View File

@ -93,6 +93,7 @@ export { default as DropdownLabelsIntl } from './components/DropdownLabelsIntl.s
export { default as DropdownRecord } from './components/DropdownRecord.svelte'
export { default as ShowMore } from './components/ShowMore.svelte'
export { default as Menu } from './components/Menu.svelte'
export { default as Submenu } from './components/Submenu.svelte'
export { default as TimeShiftPicker } from './components/TimeShiftPicker.svelte'
export { default as ErrorPresenter } from './components/ErrorPresenter.svelte'
export { default as Scroller } from './components/Scroller.svelte'

View File

@ -9,7 +9,8 @@ const emptyTooltip: LabelAndProps = {
component: undefined,
props: undefined,
anchor: undefined,
onUpdate: undefined
onUpdate: undefined,
kind: 'tooltip'
}
let storedValue: LabelAndProps = emptyTooltip
export const tooltipstore = writable<LabelAndProps>(emptyTooltip)
@ -23,10 +24,14 @@ export function tooltip (node: HTMLElement, options?: LabelAndProps): any {
const show = (): void => {
const shown = !!(storedValue.label !== undefined || storedValue.component !== undefined)
if (!shown) {
clearTimeout(toHandler)
toHandler = setTimeout(() => {
showTooltip(opt.label, node, opt.direction, opt.component, opt.props, opt.anchor, opt.onUpdate)
}, 250)
if (opt.kind !== 'submenu') {
clearTimeout(toHandler)
toHandler = setTimeout(() => {
showTooltip(opt.label, node, opt.direction, opt.component, opt.props, opt.anchor, opt.onUpdate, opt.kind)
}, 250)
} else {
showTooltip(opt.label, node, opt.direction, opt.component, opt.props, opt.anchor, opt.onUpdate, opt.kind)
}
}
}
const hide = (): void => {
@ -53,7 +58,8 @@ export function showTooltip (
component?: AnySvelteComponent | AnyComponent,
props?: any,
anchor?: HTMLElement,
onUpdate?: (result: any) => void
onUpdate?: (result: any) => void,
kind?: 'tooltip' | 'submenu'
): void {
storedValue = {
label: label,
@ -62,7 +68,8 @@ export function showTooltip (
component: component,
props: props,
anchor: anchor,
onUpdate: onUpdate
onUpdate: onUpdate,
kind: kind ?? 'tooltip'
}
tooltipstore.set(storedValue)
}

View File

@ -44,6 +44,7 @@ export interface Action {
icon: Asset | AnySvelteComponent
action: (props: any, ev: Event) => Promise<void>
inline?: boolean
link?: string
}
export interface IPopupItem {
@ -120,6 +121,7 @@ export interface LabelAndProps {
props?: any
anchor?: HTMLElement
onUpdate?: (result: any) => void
kind?: 'tooltip' | 'submenu'
}
export interface ListItem {

View File

@ -15,9 +15,8 @@
<script lang="ts">
import { AttachedData, FindOptions, SortingOrder } from '@anticrm/core'
import { Issue, IssueStatusCategory, Team, calcRank } from '@anticrm/tracker'
import { createQuery, getClient } from '@anticrm/presentation'
import { createQuery, getClient, ObjectPopup } from '@anticrm/presentation'
import { Icon } from '@anticrm/ui'
import ObjectPopup from '@anticrm/presentation/src/components/ObjectPopup.svelte'
import { createEventDispatcher } from 'svelte'
import tracker from '../plugin'
import { getIssueId } from '../utils'

View File

@ -19,7 +19,6 @@
import presentation, { createQuery, getClient, MessageViewer } from '@anticrm/presentation'
import type { Issue, IssueStatus, Team } from '@anticrm/tracker'
import {
ActionIcon,
Button,
EditBox,
IconDownOutline,
@ -203,11 +202,11 @@
</svelte:fragment>
<svelte:fragment slot="tools">
{#if isEditing}
<Button kind="transparent" label={presentation.string.Cancel} on:click={cancelEditing} />
<Button kind={'transparent'} label={presentation.string.Cancel} on:click={cancelEditing} />
<Button disabled={!canSave} label={presentation.string.Save} on:click={save} />
{:else}
<Button icon={IconEdit} kind="transparent" size="medium" on:click={edit} />
<ActionIcon icon={IconMoreH} size={'medium'} action={showMenu} />
<Button icon={IconEdit} kind={'transparent'} size={'medium'} on:click={edit} />
<Button icon={IconMoreH} kind={'transparent'} size={'medium'} on:click={showMenu} />
{/if}
</svelte:fragment>

View File

@ -26,8 +26,12 @@
Label,
navigate,
setMetadataLocalStorage,
showPopup
showPopup,
Submenu,
locationToUrl
} from '@anticrm/ui'
import type { Action } from '@anticrm/ui'
import view from '@anticrm/view'
const client = getClient()
async function getItems (): Promise<SettingsCategory[]> {
@ -84,6 +88,27 @@
if (profile === undefined) return
selectCategory(profile)
}
function getURLCategory (sp: SettingsCategory): string {
const loc = getCurrentLocation()
loc.path[1] = setting.ids.SettingApp
loc.path[2] = sp.name
loc.path.length = 3
return locationToUrl(loc)
}
const getSubmenu = (items: SettingsCategory[]): Action[] => {
const actions: Action[] = filterItems(items).map((i) => {
return {
icon: i.icon,
label: i.label,
action: async () => selectCategory(i),
link: getURLCategory(i),
inline: true
}
})
return actions
}
</script>
<div class="selectPopup autoHeight">
@ -107,29 +132,27 @@
</div>
</div>
{#if items}
{#each filterItems(items) as item}
<button class="menu-item" on:click={() => selectCategory(item)}>
<div class="mr-2">
<Icon icon={item.icon} size={'small'} />
</div>
<Label label={item.label} />
</button>
{/each}
<Submenu
icon={view.icon.Setting}
label={setting.string.Settings}
props={{ actions: getSubmenu(items) }}
withHover
/>
{/if}
<button class="menu-item" on:click={selectWorkspace}>
<div class="mr-2">
<div class="icon mr-3">
<Icon icon={setting.icon.SelectWorkspace} size={'small'} />
</div>
<Label label={setting.string.SelectWorkspace} />
</button>
<button class="menu-item" on:click={inviteWorkspace}>
<div class="mr-2">
<div class="icon mr-3">
<Icon icon={login.icon.InviteWorkspace} size={'small'} />
</div>
<Label label={setting.string.InviteWorkspace} />
</button>
<button class="menu-item" on:click={signOut}>
<div class="mr-2">
<div class="icon mr-3">
<Icon icon={setting.icon.Signout} size={'small'} />
</div>
<Label label={setting.string.Signout} />

View File

@ -14,8 +14,9 @@ test.describe('contact tests', () => {
await page.goto(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp`)
// Click #profile-button
await page.click('#profile-button')
// Click text=Setting
await page.click('text=Setting')
await page.click('.antiPopup-submenu >> text=Settings')
// Click button:has-text("Setting")
await page.click('button:has-text("Setting")')
await expect(page).toHaveURL(
`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp/setting%3Aids%3ASettingApp/setting`
)
@ -46,8 +47,9 @@ test.describe('contact tests', () => {
await page.goto(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp`)
// Click #profile-button
await page.click('#profile-button')
// Click text=Templates
await page.click('text=Templates')
await page.click('.antiPopup-submenu >> text=Settings')
// Click button:has-text("Templates")
await page.click('button:has-text("Templates")')
await expect(page).toHaveURL(
`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp/setting%3Aids%3ASettingApp/message-templates`
)
@ -80,8 +82,9 @@ test.describe('contact tests', () => {
await page.goto(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp`)
// Click #profile-button
await page.click('#profile-button')
// Click text=Manage Statuses
await page.click('text=Manage Statuses')
await page.click('.antiPopup-submenu >> text=Settings')
// Click button:has-text("Manage Statuses")
await page.click('button:has-text("Manage Statuses")')
await expect(page).toHaveURL(
`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp/setting%3Aids%3ASettingApp/statuses`
)

View File

@ -53,4 +53,16 @@ test.describe('workbench tests', () => {
// Click text=John Appleseed
await expect(page.locator('text=John Appleseed')).toBeVisible()
})
test('submenu', async ({ page }) => {
// await page.goto('http://localhost:8080/workbench%3Acomponent%3AWorkbenchApp');
// Click #profile-button
await page.click('#profile-button')
// Click text=Settings
await page.click('.antiPopup-submenu >> text=Settings')
// Click button:has-text("Terms")
await page.click('button:has-text("Terms")')
await expect(page).toHaveURL(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp/setting%3Aids%3ASettingApp/terms`)
// Click .ac-header
await expect(page.locator('.ac-header >> text=Terms')).toBeVisible()
})
})