@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 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,2 @@
1
+ export * from './cosmoz-dropdown';
2
+
@@ -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 };
@@ -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 };