Skip to content

Commit 44db1e6

Browse files
committed
✨ Add ContextMenu component
1 parent ad35125 commit 44db1e6

File tree

14 files changed

+504
-2
lines changed

14 files changed

+504
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ import { Accordion } from 'webcoreui/react'
244244
- [Checkbox](https://github.com/Frontendland/webcoreui/tree/main/src/components/Checkbox)
245245
- [Collapsible](https://github.com/Frontendland/webcoreui/tree/main/src/components/Collapsible)
246246
- [ConditionalWrapper](https://github.com/Frontendland/webcoreui/tree/main/src/components/ConditionalWrapper)
247+
- [ContextMenu](https://github.com/Frontendland/webcoreui/tree/main/src/components/ContextMenu)
247248
- [Copy](https://github.com/Frontendland/webcoreui/tree/main/src/components/Copy)
248249
- [DataTable](https://github.com/Frontendland/webcoreui/tree/main/src/components/DataTable)
249250
- [Flex](https://github.com/Frontendland/webcoreui/tree/main/src/components/Flex)

scripts/utilityTypes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export type Toast = {
126126
declare module 'webcoreui' {
127127
export const bodyFreeze: (freeze: boolean) => void
128128
export const classNames: (classes: any[]) => string
129+
export const closeContext: (ctx: string | HTMLElement) => void
129130
130131
export const setCookie: (name: string, value: string, days: number) => void
131132
export const getCookie: (name: string) => string | null
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
import type { ContextMenuProps } from './contextmenu'
3+
4+
import { classNames } from '../../utils/classNames'
5+
6+
import styles from './contextmenu.module.scss'
7+
8+
interface Props extends ContextMenuProps {}
9+
10+
const {
11+
element = 'div',
12+
className,
13+
...rest
14+
} = Astro.props
15+
16+
const Component = element
17+
18+
const classes = classNames([
19+
styles.ctx,
20+
className
21+
])
22+
23+
const props = {
24+
class: classes
25+
}
26+
27+
if (!Astro.slots.has('context')) {
28+
// eslint-disable-next-line no-console, max-len
29+
console.error('Missing "context" slot. Attach slot="context" to one of the children of your <ContextMenu> component.')
30+
}
31+
---
32+
33+
<Component {...rest} {...props} data-id="w-ctx">
34+
<slot />
35+
<div class={styles.content} data-id="w-ctx-content">
36+
<slot name="context" />
37+
</div>
38+
</Component>
39+
40+
<script>
41+
import { get, on } from '../../utils/DOMUtils'
42+
43+
const addEventListeners = () => {
44+
const ctxs = get('[data-id="w-ctx"]', true) as NodeListOf<HTMLElement>
45+
const contents: HTMLDivElement[] = []
46+
47+
const hideContent = (content: HTMLDivElement, event: MouseEvent) => {
48+
if (content.contains(event.target as Node)) {
49+
return
50+
}
51+
52+
content.dataset.show = 'false'
53+
54+
setTimeout(() => {
55+
content.style.top = ''
56+
content.style.left = ''
57+
}, 200)
58+
}
59+
60+
[...ctxs].forEach((ctx, i) => {
61+
on(ctx, 'contextmenu', (event: MouseEvent) => {
62+
event.preventDefault()
63+
64+
if (!contents[i]) {
65+
const target = event.currentTarget as HTMLElement
66+
contents[i] = target.lastElementChild as HTMLDivElement
67+
}
68+
69+
if (contents.length > 1) {
70+
contents.forEach((content, y) => {
71+
if (i !== y) {
72+
hideContent(content, event)
73+
}
74+
})
75+
}
76+
77+
contents[i].style.top = `${event.offsetY}px`
78+
contents[i].style.left = `${event.offsetX}px`
79+
contents[i].dataset.show = 'true'
80+
})
81+
})
82+
83+
on(document, 'click', (event: MouseEvent) => {
84+
contents.forEach(content => hideContent(content, event))
85+
})
86+
}
87+
88+
on(document, 'astro:after-swap', addEventListeners)
89+
addEventListeners()
90+
</script>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte'
3+
import type { SvelteContextMenuProps } from './contextmenu'
4+
5+
import { classNames } from '../../utils/classNames'
6+
import { on } from '../../utils/DOMUtils'
7+
8+
import styles from './contextmenu.module.scss'
9+
10+
const {
11+
element = 'div',
12+
className,
13+
children,
14+
context,
15+
...rest
16+
}: SvelteContextMenuProps = $props()
17+
18+
const classes = classNames([
19+
styles.ctx,
20+
className
21+
])
22+
23+
const showContext = (event: MouseEvent) => {
24+
event.preventDefault()
25+
26+
if (content) {
27+
content.style.top = `${event.offsetY}px`
28+
content.style.left = `${event.offsetX}px`
29+
content.dataset.show = 'true'
30+
}
31+
}
32+
33+
const hideContext = (event: MouseEvent) => {
34+
if (content) {
35+
if (content.contains(event.target as Node)) {
36+
return
37+
}
38+
39+
content.dataset.show = 'false'
40+
41+
setTimeout(() => {
42+
if (content) {
43+
content.style.top = ''
44+
content.style.left = ''
45+
}
46+
}, 200)
47+
}
48+
}
49+
50+
let ctx: HTMLElement
51+
let content: HTMLDivElement
52+
53+
onMount(() => {
54+
if (ctx && content) {
55+
on(ctx, 'contextmenu', showContext)
56+
on(document, 'click', hideContext)
57+
}
58+
59+
return () => {
60+
ctx?.removeEventListener('contextmenu', showContext)
61+
document.removeEventListener('click', hideContext)
62+
}
63+
})
64+
65+
if (!context) {
66+
// eslint-disable-next-line no-console, max-len
67+
console.error('Missing "context" slot. Attach slot="context" to one of the children of your <ContextMenu> component.')
68+
}
69+
</script>
70+
71+
<svelte:element {...rest} this={element} class={classes} bind:this={ctx}>
72+
{@render children?.()}
73+
<div class={styles.content} bind:this={content}>
74+
{@render context?.()}
75+
</div>
76+
</svelte:element>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React, { useEffect, useRef } from 'react'
2+
import type { ReactContextMenuProps } from './contextmenu'
3+
4+
import { classNames } from '../../utils/classNames'
5+
import { on } from '../../utils/DOMUtils'
6+
7+
import styles from './contextmenu.module.scss'
8+
9+
const ContextMenu = ({
10+
Element = 'div',
11+
className,
12+
children,
13+
context,
14+
...rest
15+
}: ReactContextMenuProps) => {
16+
const ctx = useRef<HTMLElement>(null)
17+
const content = useRef<HTMLDivElement>(null)
18+
const classes = classNames([
19+
styles.ctx,
20+
className
21+
])
22+
23+
const showContext = (event: MouseEvent) => {
24+
event.preventDefault()
25+
26+
if (content.current) {
27+
content.current.style.top = `${event.offsetY}px`
28+
content.current.style.left = `${event.offsetX}px`
29+
content.current.dataset.show = 'true'
30+
}
31+
}
32+
33+
const hideContext = (event: MouseEvent) => {
34+
if (content.current) {
35+
if (content.current.contains(event.target as Node)) {
36+
return
37+
}
38+
39+
content.current.dataset.show = 'false'
40+
41+
setTimeout(() => {
42+
if (content.current) {
43+
content.current.style.top = ''
44+
content.current.style.left = ''
45+
}
46+
}, 200)
47+
}
48+
}
49+
50+
useEffect(() => {
51+
if (ctx.current) {
52+
on(ctx.current, 'contextmenu', showContext)
53+
on(document, 'click', hideContext)
54+
}
55+
56+
return () => {
57+
ctx.current?.removeEventListener('contextmenu', showContext)
58+
document.removeEventListener('click', hideContext)
59+
}
60+
}, [])
61+
62+
if (!context) {
63+
// eslint-disable-next-line no-console
64+
console.error('Missing `context` prop. Add `context={...}` to your <ContextMenu> component.')
65+
}
66+
67+
return (
68+
<Element {...rest} className={classes} ref={ctx}>
69+
{children}
70+
<div className={styles.content} ref={content} onClick={event => event.stopPropagation()}>
71+
{context}
72+
</div>
73+
</Element>
74+
75+
)
76+
}
77+
78+
export default ContextMenu
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@use '../../scss/config.scss' as *;
2+
3+
.ctx {
4+
@include position(relative);
5+
}
6+
7+
.content {
8+
@include position(absolute);
9+
@include background(primary-70);
10+
@include size(wmax-content);
11+
@include layer(modal);
12+
@include transition(.2s);
13+
@include visibility(0);
14+
15+
transform: scale(.95);
16+
pointer-events: none;
17+
18+
&[data-show="true"] {
19+
@include visibility(1);
20+
21+
transform: scale(1);
22+
pointer-events: all;
23+
}
24+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type React from 'react'
2+
import type { Snippet } from 'svelte'
3+
4+
export type ContextMenuProps = {
5+
element?: string
6+
className?: string
7+
[key: string]: any
8+
}
9+
10+
export type SvelteContextMenuProps = {
11+
children: Snippet
12+
context: Snippet
13+
} & ContextMenuProps
14+
15+
export type ReactContextMenuProps = {
16+
Element?: React.ElementType
17+
children: React.ReactNode
18+
context: React.ReactNode
19+
} & Omit<ContextMenuProps, 'element'>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
import ComponentWrapper from '@static/ComponentWrapper.astro'
3+
import Layout from '@static/Layout.astro'
4+
5+
import AstroContextMenu from '@components/ContextMenu/ContextMenu.astro'
6+
import SvelteContextMenu from '@components/ContextMenu/ContextMenu.svelte'
7+
import List from '@components/List/List.astro'
8+
9+
import { listWithGroups } from '@data'
10+
---
11+
12+
<Layout>
13+
<h1>ContextMenu</h1>
14+
<div class="grid md-2 lg-3">
15+
<ComponentWrapper type="Astro">
16+
<AstroContextMenu className="ctx">
17+
<span class="muted">Right-click here</span>
18+
<span class="muted">Click "Sign out" to close context.</span>
19+
20+
<List
21+
itemGroups={listWithGroups}
22+
showSearchBar={true}
23+
showSearchBarIcon={true}
24+
searchBarPlaceholder="Search the app..."
25+
noResultsLabel="Nothing found..."
26+
slot="context"
27+
wrapperClassName="ctx-content"
28+
/>
29+
</AstroContextMenu>
30+
</ComponentWrapper>
31+
32+
<ComponentWrapper type="Svelte">
33+
<SvelteContextMenu className="ctx s" client:visible>
34+
<span class="muted">Right-click here</span>
35+
<span class="muted">Click "Sign out" to close context.</span>
36+
37+
<List
38+
itemGroups={listWithGroups}
39+
showSearchBar={true}
40+
showSearchBarIcon={true}
41+
searchBarPlaceholder="Search the app..."
42+
noResultsLabel="Nothing found..."
43+
slot="context"
44+
wrapperClassName="ctx-content"
45+
/>
46+
</SvelteContextMenu>
47+
</ComponentWrapper>
48+
49+
<ComponentWrapper type="React">
50+
<div class="ctx">
51+
<a href="/react" class="muted">See in React playground {'->'}</a>
52+
</div>
53+
</ComponentWrapper>
54+
</div>
55+
</Layout>
56+
57+
<style lang="scss" is:global>
58+
@use '../../scss/config.scss' as *;
59+
60+
// If you need to trigger ctx when clicking on elements
61+
// Inside the context area
62+
.ctx span {
63+
pointer-events: none;
64+
}
65+
</style>
66+
67+
<script>
68+
import { closeContext } from '@utils/context'
69+
import { on } from '@utils/DOMUtils'
70+
71+
on('[data-value="sign-out"]', 'click', () => closeContext('.ctx-content'), true)
72+
</script>

src/pages/index.astro

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ import {
165165
<Badge slot="on">Expand</Badge>
166166
</Collapsible>
167167
</CardWrapper>
168+
<CardWrapper title="ContextMenu" href="/components/context-menu" bodyClassName="card-sm">
169+
<div class="ctx">
170+
<span class="muted">Right-click here</span>
171+
</div>
172+
</CardWrapper>
168173
<CardWrapper title="Copy" href="/components/copy">
169174
<Copy>Click to copy</Copy>
170175
</CardWrapper>

0 commit comments

Comments
 (0)