@neovici/cosmoz-dropdown 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +80 -0
- package/src/cosmoz-dropdown.js +57 -0
- package/src/index.js +2 -0
- package/src/on-scrolled.js +37 -0
- package/src/use-focus.js +49 -0
- package/src/use-position.js +51 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neovici/cosmoz-dropdown",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A simple dropdown web component",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"lit-html",
|
|
7
|
+
"web-components"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/neovici/cosmoz-dropdown#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/neovici/cosmoz-dropdown/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/neovici/cosmoz-dropdown.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": "",
|
|
19
|
+
"main": "cosmoz-dropdown.js",
|
|
20
|
+
"directories": {
|
|
21
|
+
"test": "test"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"lint": "eslint --cache --ext .js .",
|
|
25
|
+
"lint-tsc": "tsc",
|
|
26
|
+
"start": "wds",
|
|
27
|
+
"test": "wtr --coverage",
|
|
28
|
+
"test:watch": "wtr --watch",
|
|
29
|
+
"storybook:build": "build-storybook",
|
|
30
|
+
"storybook:deploy": "storybook-to-ghpages",
|
|
31
|
+
"prepare": "husky install"
|
|
32
|
+
},
|
|
33
|
+
"release": {
|
|
34
|
+
"plugins": [
|
|
35
|
+
"@semantic-release/commit-analyzer",
|
|
36
|
+
"@semantic-release/release-notes-generator",
|
|
37
|
+
"@semantic-release/changelog",
|
|
38
|
+
"@semantic-release/github",
|
|
39
|
+
"@semantic-release/npm",
|
|
40
|
+
"@semantic-release/git"
|
|
41
|
+
],
|
|
42
|
+
"branch": "master",
|
|
43
|
+
"preset": "conventionalcommits"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"src/*.js"
|
|
50
|
+
],
|
|
51
|
+
"commitlint": {
|
|
52
|
+
"extends": [
|
|
53
|
+
"@commitlint/config-conventional"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@neovici/cosmoz-utils": "^3.24.0",
|
|
58
|
+
"haunted": "^4.7.0",
|
|
59
|
+
"lit-html": "^1.4.0",
|
|
60
|
+
"position.js": "^0.3.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@commitlint/cli": "^13.0.0",
|
|
64
|
+
"@commitlint/config-conventional": "^13.0.0",
|
|
65
|
+
"@neovici/eslint-config": "^1.2.0",
|
|
66
|
+
"@open-wc/testing": "^2.5.32",
|
|
67
|
+
"@semantic-release/changelog": "^6.0.0",
|
|
68
|
+
"@semantic-release/git": "^10.0.0",
|
|
69
|
+
"@storybook/storybook-deployer": "^2.8.5",
|
|
70
|
+
"@web/dev-server": "^0.1.28",
|
|
71
|
+
"@web/dev-server-storybook": "^0.3.8",
|
|
72
|
+
"@web/test-runner": "^0.13.0",
|
|
73
|
+
"@web/test-runner-selenium": "^0.5.0",
|
|
74
|
+
"husky": "^7.0.0",
|
|
75
|
+
"prettier": "^2.5.1",
|
|
76
|
+
"semantic-release": "^18.0.0",
|
|
77
|
+
"sinon": "^11.0.0",
|
|
78
|
+
"typescript": "^4.0.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { html, component, useCallback, useEffect } from 'haunted';
|
|
2
|
+
import { usePosition } from './use-position';
|
|
3
|
+
import { useFocus } from './use-focus';
|
|
4
|
+
|
|
5
|
+
const preventDefault = e => e.preventDefault(),
|
|
6
|
+
fevs = ['focusin', 'focusout'],
|
|
7
|
+
Content = host => {
|
|
8
|
+
usePosition({ anchor: host.anchor, host });
|
|
9
|
+
return html` <style>
|
|
10
|
+
:host {
|
|
11
|
+
position: fixed;
|
|
12
|
+
left: -9999999999px;
|
|
13
|
+
min-width: 72px;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
</style>
|
|
17
|
+
<slot></slot>`;
|
|
18
|
+
},
|
|
19
|
+
Dropdown = host => {
|
|
20
|
+
const { active, onFocus, onToggle } = useFocus(host),
|
|
21
|
+
anchor = useCallback(() => host.shadowRoot.querySelector('.anchor'), []);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
host.setAttribute('tabindex', '-1');
|
|
24
|
+
fevs.forEach(ev => host.addEventListener(ev, onFocus));
|
|
25
|
+
return () => {
|
|
26
|
+
fevs.forEach(ev => host.removeEventListener(ev, onFocus));
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
return html`
|
|
30
|
+
<style>
|
|
31
|
+
.anchor {
|
|
32
|
+
pointer-events: none;
|
|
33
|
+
}
|
|
34
|
+
button {
|
|
35
|
+
border: none;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
pointer-events: auto;
|
|
38
|
+
outline: none;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
41
|
+
<div class="anchor" part="anchor">
|
|
42
|
+
<button @click=${ onToggle } @mousedown=${ preventDefault } part="button">
|
|
43
|
+
<slot name="button">...</slot>
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
${ active
|
|
47
|
+
? html` <cosmoz-dropdown-content .anchor=${ anchor } part="dropdown">
|
|
48
|
+
<slot></slot>
|
|
49
|
+
</cosmoz-dropdown-content>`
|
|
50
|
+
: [] }
|
|
51
|
+
`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
customElements.define('cosmoz-dropdown', component(Dropdown));
|
|
55
|
+
customElements.define('cosmoz-dropdown-content', component(Content));
|
|
56
|
+
|
|
57
|
+
export { Dropdown, Content };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns all ancestor ShadowRoot elements
|
|
3
|
+
* @param {HTMLElement} el The element
|
|
4
|
+
* @param {HTMLElement} limit The limit to stop searching
|
|
5
|
+
* @returns {ShadowRoot[]} Array of ancestor roots
|
|
6
|
+
*/
|
|
7
|
+
const ancestorRoots = (el, limit = document.body) => {
|
|
8
|
+
const roots = [];
|
|
9
|
+
let ancestor = el;
|
|
10
|
+
while (ancestor && ancestor !== limit) {
|
|
11
|
+
if (ancestor.assignedSlot) {
|
|
12
|
+
ancestor = ancestor.assignedSlot;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (ancestor instanceof ShadowRoot) {
|
|
16
|
+
roots.push(ancestor);
|
|
17
|
+
ancestor = ancestor.host;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
ancestor = ancestor.parentNode;
|
|
21
|
+
}
|
|
22
|
+
return roots;
|
|
23
|
+
},
|
|
24
|
+
/**
|
|
25
|
+
* Listen to a scroll event in a ancestor root
|
|
26
|
+
* @param {HTMLElement} el The element
|
|
27
|
+
* @param {function} handler The event handler
|
|
28
|
+
* @returns {function} Function to cleanup the event listeners
|
|
29
|
+
*/
|
|
30
|
+
onScrolled = (el, handler) => {
|
|
31
|
+
const roots = ancestorRoots(el);
|
|
32
|
+
roots.forEach(r => r.addEventListener('scroll', handler, true));
|
|
33
|
+
return () => {
|
|
34
|
+
roots.forEach(r => r.removeEventListener('scroll', handler, true));
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export { onScrolled };
|
package/src/use-focus.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from 'haunted';
|
|
2
|
+
import { useMeta } from '@neovici/cosmoz-utils/lib/hooks/use-meta';
|
|
3
|
+
|
|
4
|
+
const isFocused = t => t.matches(':focus-within');
|
|
5
|
+
|
|
6
|
+
export const useFocus = ({ disabled, onFocus }) => {
|
|
7
|
+
const [{ focused, closed } = {}, setState] = useState(),
|
|
8
|
+
active = focused && !disabled,
|
|
9
|
+
meta = useMeta({ closed, onFocus }),
|
|
10
|
+
setClosed = useCallback(closed => setState(p => ({ ...p, closed })), []),
|
|
11
|
+
onToggle = useCallback(e => {
|
|
12
|
+
const target = e.currentTarget;
|
|
13
|
+
return isFocused(target)
|
|
14
|
+
? setState(p => ({ focused: true, closed: !p?.closed }))
|
|
15
|
+
: target.focus();
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!active) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const handler = e => {
|
|
23
|
+
if (e.defaultPrevented) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const { closed } = meta;
|
|
27
|
+
if (e.key === 'Escape' && !closed) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setClosed(true);
|
|
30
|
+
} else if (['ArrowUp', 'Up'].includes(e.key) && closed) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
setClosed(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
document.addEventListener('keydown', handler, true);
|
|
36
|
+
return () => document.removeEventListener('keydown', handler, true);
|
|
37
|
+
}, [active]);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
active: active && !closed,
|
|
41
|
+
setClosed,
|
|
42
|
+
onToggle,
|
|
43
|
+
onFocus: useCallback(e => {
|
|
44
|
+
const focused = isFocused(e.currentTarget);
|
|
45
|
+
setState({ focused });
|
|
46
|
+
meta.onFocus?.(focused);
|
|
47
|
+
}, [meta])
|
|
48
|
+
};
|
|
49
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect } from 'haunted';
|
|
2
|
+
import getPosition from 'position.js';
|
|
3
|
+
import { onScrolled } from './on-scrolled';
|
|
4
|
+
|
|
5
|
+
const defaultPlacement = ['bottom-left', 'bottom-right', 'bottom', 'top-left', 'top-right', 'top'],
|
|
6
|
+
position = ({ host, anchor, placement = defaultPlacement, confinement, limit }) => {
|
|
7
|
+
const anchorBounds = anchor.getBoundingClientRect(),
|
|
8
|
+
hostBounds = host.getBoundingClientRect(),
|
|
9
|
+
{ popupOffset: offset } = getPosition(hostBounds, anchorBounds, placement, {
|
|
10
|
+
fixed: true,
|
|
11
|
+
adjustXY: 'both',
|
|
12
|
+
offsetParent: confinement
|
|
13
|
+
}),
|
|
14
|
+
{ style } = host;
|
|
15
|
+
style.left = offset.left + 'px';
|
|
16
|
+
style.top = offset.top + 'px';
|
|
17
|
+
if (limit) {
|
|
18
|
+
style.minWidth = Math.max(anchorBounds.width, hostBounds.width) + 'px';
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
usePosition = ({ anchor: anchorage, host, ...thru }) => {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const anchor = typeof anchorage === 'function' ? anchorage() : anchorage;
|
|
24
|
+
if (anchor == null) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let rid;
|
|
28
|
+
const reposition = () => position({ host, anchor, ...thru }),
|
|
29
|
+
ro = new ResizeObserver(reposition);
|
|
30
|
+
|
|
31
|
+
ro.observe(host);
|
|
32
|
+
ro.observe(anchor);
|
|
33
|
+
|
|
34
|
+
const onReposition = () => {
|
|
35
|
+
cancelAnimationFrame(rid);
|
|
36
|
+
rid = requestAnimationFrame(reposition);
|
|
37
|
+
},
|
|
38
|
+
offScroll = onScrolled(anchor, onReposition);
|
|
39
|
+
window.addEventListener('resize', onReposition, true);
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
ro.unobserve(host);
|
|
43
|
+
ro.unobserve(anchor);
|
|
44
|
+
offScroll();
|
|
45
|
+
window.removeEventListener('resize', onReposition, true);
|
|
46
|
+
cancelAnimationFrame(rid);
|
|
47
|
+
};
|
|
48
|
+
}, [anchorage, ...Object.values(thru)]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { usePosition, defaultPlacement };
|