@jungherz-de/notionkit-elements 1.8.0 → 1.10.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/README.md +13 -13
- package/dist/components/nk-app.js +8 -1
- package/dist/components/nk-btn.js +17 -3
- package/dist/components/nk-calendar-view.js +138 -0
- package/dist/components/nk-calendar.js +356 -0
- package/dist/components/nk-dialog.js +150 -0
- package/dist/components/nk-menu.js +2 -14
- package/dist/components/nk-peek.js +80 -5
- package/dist/components/nk-sidebar.js +68 -6
- package/dist/components/nk-table-view.js +9 -3
- package/dist/components/nk-tooltip.js +138 -0
- package/dist/components/nk-workspace-switcher.js +1 -1
- package/dist/components/shared/dates-a32DcW1l.js +67 -0
- package/dist/components/shared/floating-CxHxy-Cb.js +37 -0
- package/dist/notionkit-elements.esm.js +1102 -91
- package/dist/notionkit-elements.js +1105 -90
- package/dist/notionkit-elements.min.js +1 -1
- package/package.json +3 -3
- package/src/components/data/nk-calendar-view.js +136 -0
- package/src/components/data/nk-table-view.js +9 -3
- package/src/components/forms/nk-btn.js +17 -3
- package/src/components/forms/nk-calendar.js +354 -0
- package/src/components/overlays/nk-dialog.js +148 -0
- package/src/components/overlays/nk-menu.js +2 -14
- package/src/components/overlays/nk-peek.js +80 -5
- package/src/components/overlays/nk-tooltip.js +136 -0
- package/src/components/shell/nk-app.js +8 -1
- package/src/components/shell/nk-sidebar.js +68 -6
- package/src/components/shell/nk-workspace-switcher.js +1 -1
- package/src/index.js +4 -0
- package/src/util/dates.js +65 -0
- package/src/util/floating.js +35 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { NkElement } from '../../base.js';
|
|
2
|
+
import { lockScroll, unlockScroll, inertOutside, firstFocusable, deepActiveElement } from '../../util/focus.js';
|
|
3
|
+
|
|
4
|
+
// <nk-dialog id="trash" title="Move “Roadmap” to trash?" alert>
|
|
5
|
+
// The page and its sub-pages can be restored from Trash for 30 days.
|
|
6
|
+
// <nk-btn slot="actions" variant="secondary" value="">Cancel</nk-btn>
|
|
7
|
+
// <nk-btn slot="actions" variant="danger-solid" value="trash">Move to trash</nk-btn>
|
|
8
|
+
// </nk-dialog>
|
|
9
|
+
// → <div class="nk-dialog-backdrop open"><div class="nk-dialog" role="alertdialog">
|
|
10
|
+
// <div class="dl-title">…</div><div class="dl-body">…</div><div class="dl-actions">…</div></div></div>
|
|
11
|
+
//
|
|
12
|
+
// A question or a short form (NotionKit 1.10.0) with the contract of
|
|
13
|
+
// <nk-modal> and <nk-sheet>: show(), close(), toggle(), nk-toggle; Escape –
|
|
14
|
+
// captured, so it closes before a peek, a menu or a modal behind it – and
|
|
15
|
+
// the backdrop close it; focus moves in, to an [autofocus] element or the
|
|
16
|
+
// first field or button, and back out; the page behind is inert and
|
|
17
|
+
// scroll-locked. It lies above the modal and the sheet, so a question can
|
|
18
|
+
// come from either. 440px, `wide` 560px; below 860px a bottom sheet with the
|
|
19
|
+
// buttons stacked, the confirming one on top.
|
|
20
|
+
// A button in slot="actions" with a `value` closes it with that value; so
|
|
21
|
+
// does the submit of a <form method="dialog"> inside, with its submitter's
|
|
22
|
+
// value. Before it closes, nk-close { value } fires and can be cancelled –
|
|
23
|
+
// the place to check an input dialog; Escape and the backdrop close with ''.
|
|
24
|
+
// `returnValue` keeps the last value. `alert` makes it an alertdialog, named
|
|
25
|
+
// and described by its title and text. `title` is the heading, never a
|
|
26
|
+
// tooltip – see NkElement.takeTitle(). Put it directly under <body>.
|
|
27
|
+
class NkDialog extends NkElement {
|
|
28
|
+
static get observedAttributes() { return ['open', 'title', 'wide', 'alert']; }
|
|
29
|
+
|
|
30
|
+
render() {
|
|
31
|
+
this.takeTitle();
|
|
32
|
+
this.returnValue = '';
|
|
33
|
+
this._backdrop = this.createElement('div', ['nk-dialog-backdrop']);
|
|
34
|
+
this._backdrop.inert = true;
|
|
35
|
+
this._box = this.createElement('div', ['nk-dialog'], { 'aria-modal': 'true', tabindex: '-1' });
|
|
36
|
+
this._heading = this.createElement('div', ['dl-title'], { id: 'title' });
|
|
37
|
+
const body = this.createElement('div', ['dl-body'], { id: 'body' });
|
|
38
|
+
body.appendChild(document.createElement('slot'));
|
|
39
|
+
this._actions = this.createElement('div', ['dl-actions']);
|
|
40
|
+
this._actionsSlot = this.createElement('slot', [], { name: 'actions' });
|
|
41
|
+
this._actions.appendChild(this._actionsSlot);
|
|
42
|
+
this._box.append(this._heading, body, this._actions);
|
|
43
|
+
this._backdrop.appendChild(this._box);
|
|
44
|
+
this._wrapper.appendChild(this._backdrop);
|
|
45
|
+
this._syncTitle();
|
|
46
|
+
this._syncMode();
|
|
47
|
+
this._syncOpen();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_syncTitle() {
|
|
51
|
+
const text = this._titleText || '';
|
|
52
|
+
this._heading.textContent = text;
|
|
53
|
+
this._heading.hidden = !text;
|
|
54
|
+
if (text) this._box.setAttribute('aria-labelledby', 'title');
|
|
55
|
+
else this._box.removeAttribute('aria-labelledby');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_syncMode() {
|
|
59
|
+
const alert = this.getBoolAttr('alert');
|
|
60
|
+
this._box.setAttribute('role', alert ? 'alertdialog' : 'dialog');
|
|
61
|
+
if (alert) this._box.setAttribute('aria-describedby', 'body'); else this._box.removeAttribute('aria-describedby');
|
|
62
|
+
this._box.classList.toggle('wide', this.getBoolAttr('wide'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_syncOpen() {
|
|
66
|
+
const open = this.getBoolAttr('open');
|
|
67
|
+
this._backdrop.classList.toggle('open', open);
|
|
68
|
+
this._backdrop.inert = !open;
|
|
69
|
+
if (open === this._wasOpen) return;
|
|
70
|
+
this._wasOpen = open;
|
|
71
|
+
if (open) {
|
|
72
|
+
this._returnFocus = this._from ?? deepActiveElement();
|
|
73
|
+
this._from = null;
|
|
74
|
+
// An overlay behind – a modal – made this host inert; the dialog lifts that while it is open.
|
|
75
|
+
this._wasInert = this.inert;
|
|
76
|
+
this.inert = false;
|
|
77
|
+
lockScroll();
|
|
78
|
+
this._undoInert = inertOutside(this);
|
|
79
|
+
requestAnimationFrame(() => (this.querySelector('[autofocus]') || firstFocusable(this) || this._box).focus({ preventScroll: true }));
|
|
80
|
+
} else {
|
|
81
|
+
unlockScroll();
|
|
82
|
+
this._undoInert?.(); this._undoInert = null;
|
|
83
|
+
this.inert = this._wasInert;
|
|
84
|
+
const back = this._returnFocus;
|
|
85
|
+
this._returnFocus = null;
|
|
86
|
+
if (back?.isConnected && typeof back.focus === 'function') back.focus({ preventScroll: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setupEvents() {
|
|
91
|
+
this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(''); };
|
|
92
|
+
this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(''); } };
|
|
93
|
+
// A button with a value closes it; a submit button is the form's.
|
|
94
|
+
this._onClick = (e) => {
|
|
95
|
+
const button = e.composedPath().find(n => n instanceof Element && n !== this && this.contains(n) && n.hasAttribute('value') && (n.localName === 'nk-btn' || n.localName === 'button'));
|
|
96
|
+
if (!button || button.getAttribute('type') === 'submit') return;
|
|
97
|
+
this.close(button.getAttribute('value'));
|
|
98
|
+
};
|
|
99
|
+
this._onSubmit = (e) => {
|
|
100
|
+
if ((e.target.getAttribute('method') || '').toLowerCase() !== 'dialog') return;
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
this.close(e.submitter?.value || e.submitter?.getAttribute?.('value') || 'submit');
|
|
103
|
+
};
|
|
104
|
+
this._onSlot = () => { this._actions.hidden = !this._actionsSlot.assignedElements().length; };
|
|
105
|
+
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
106
|
+
this.addEventListener('click', this._onClick);
|
|
107
|
+
this.addEventListener('submit', this._onSubmit);
|
|
108
|
+
this._actionsSlot.addEventListener('slotchange', this._onSlot);
|
|
109
|
+
document.addEventListener('keydown', this._onKey, true);
|
|
110
|
+
this._onSlot();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
teardownEvents() {
|
|
114
|
+
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
115
|
+
this.removeEventListener('click', this._onClick);
|
|
116
|
+
this.removeEventListener('submit', this._onSubmit);
|
|
117
|
+
this._actionsSlot?.removeEventListener('slotchange', this._onSlot);
|
|
118
|
+
document.removeEventListener('keydown', this._onKey, true);
|
|
119
|
+
if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onAttributeChanged(name, oldValue, value) {
|
|
123
|
+
if (name === 'title') { if (this.takeTitle(value)) this._syncTitle(); return; }
|
|
124
|
+
if (name !== 'open') { this._syncMode(); return; }
|
|
125
|
+
this._syncOpen();
|
|
126
|
+
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Opens it; focus goes back to `from` when it closes (default: what had focus – pass the ⋯ of a menu that closes). */
|
|
130
|
+
show(from) { this.returnValue = ''; this._from = from ?? null; this.setBoolAttr('open', true); }
|
|
131
|
+
/** Closes with `value`, unless an nk-close listener cancels it. Returns whether it closed. */
|
|
132
|
+
close(value = '') {
|
|
133
|
+
if (!this.getBoolAttr('open')) return false;
|
|
134
|
+
if (!this.emit('nk-close', { value })) return false;
|
|
135
|
+
this.returnValue = value;
|
|
136
|
+
this.setBoolAttr('open', false);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
toggle() { this.getBoolAttr('open') ? this.close('') : this.show(); }
|
|
140
|
+
|
|
141
|
+
get open() { return this.getBoolAttr('open'); }
|
|
142
|
+
set open(v) { v ? this.show() : this.close(''); }
|
|
143
|
+
get title() { return this._titleText ?? ''; }
|
|
144
|
+
set title(v) { this._titleText = v == null ? '' : String(v); if (this._initialized) this._syncTitle(); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
customElements.define('nk-dialog', NkDialog);
|
|
148
|
+
export { NkDialog };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NkElement } from '../../base.js';
|
|
2
2
|
import { deepActiveElement } from '../../util/focus.js';
|
|
3
|
+
import { placeUnder } from '../../util/floating.js';
|
|
3
4
|
|
|
4
5
|
// <nk-menu>
|
|
5
6
|
// <nk-menu-item type="label">Page</nk-menu-item>
|
|
@@ -20,16 +21,6 @@ import { deepActiveElement } from '../../util/focus.js';
|
|
|
20
21
|
const floatSheet = new CSSStyleSheet();
|
|
21
22
|
floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
|
|
22
23
|
|
|
23
|
-
/** The border box of an element, or of the first descendant with one (display: contents hosts). */
|
|
24
|
-
function boxOf(el) {
|
|
25
|
-
if (el.getClientRects().length) return el.getBoundingClientRect();
|
|
26
|
-
for (const child of [...(el.shadowRoot?.children ?? []), ...el.children]) {
|
|
27
|
-
const r = boxOf(child);
|
|
28
|
-
if (r.width || r.height) return r;
|
|
29
|
-
}
|
|
30
|
-
return el.getBoundingClientRect();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
24
|
class NkMenu extends NkElement {
|
|
34
25
|
static get hostStyles() { return floatSheet; }
|
|
35
26
|
static get observedAttributes() { return ['floating', 'sheet', 'open', 'align']; }
|
|
@@ -70,10 +61,7 @@ class NkMenu extends NkElement {
|
|
|
70
61
|
show(anchor) {
|
|
71
62
|
if (anchor) {
|
|
72
63
|
this._anchor = anchor;
|
|
73
|
-
|
|
74
|
-
this.style.top = `${r.bottom + 6}px`;
|
|
75
|
-
if (this.getAttribute('align') === 'start') { this.style.left = `${r.left}px`; this.style.right = ''; }
|
|
76
|
-
else { this.style.right = `${Math.max(8, innerWidth - r.right)}px`; this.style.left = ''; }
|
|
64
|
+
placeUnder(this, anchor, this.getAttribute('align'));
|
|
77
65
|
}
|
|
78
66
|
this.setBoolAttr('open', true);
|
|
79
67
|
}
|
|
@@ -20,17 +20,28 @@ import { lockScroll, unlockScroll, inertOutside, deepActiveElement } from '../..
|
|
|
20
20
|
// the peek) leave it open. Below 860px it is a bottom sheet over a dimmed
|
|
21
21
|
// page; there it is modal: the page is inert and scroll-locked, the dimmed
|
|
22
22
|
// page takes the tap that closes it. Put it directly under <body>.
|
|
23
|
+
//
|
|
24
|
+
// `resizable` (NotionKit 1.10.0): the left edge takes the pointer – and the
|
|
25
|
+
// arrow keys, 16px a step – to make the peek wider or narrower, as in
|
|
26
|
+
// Notion: from `min` (380) to `max` (all but 320px of the window). The width
|
|
27
|
+
// is the token --nk-peek-width, set on :root; `width` (px) sets it too, and
|
|
28
|
+
// nk-resize { width } fires when a drag or a key ends – the moment to keep
|
|
29
|
+
// it. resize-label names the edge (default "Resize").
|
|
30
|
+
// `inset`: while it is open on the desktop, the page's <nk-app> gets
|
|
31
|
+
// `peek-inset` – its main column makes room instead of lying under the peek.
|
|
23
32
|
const CLOSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 17 5-5-5-5M13 17l5-5-5-5"/></svg>';
|
|
24
|
-
const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-cmdk', 'nk-toast']);
|
|
25
|
-
const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-cmdk-backdrop'];
|
|
33
|
+
const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-dialog', 'nk-cmdk', 'nk-toast']);
|
|
34
|
+
const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-dialog-backdrop', 'nk-cmdk-backdrop'];
|
|
26
35
|
const phone = () => matchMedia('(max-width: 860px)').matches;
|
|
27
36
|
|
|
28
37
|
class NkPeek extends NkElement {
|
|
29
|
-
static get observedAttributes() { return ['open', 'label', 'close-label']; }
|
|
38
|
+
static get observedAttributes() { return ['open', 'label', 'close-label', 'resizable', 'width', 'min', 'max', 'resize-label', 'inset']; }
|
|
30
39
|
|
|
31
40
|
render() {
|
|
32
41
|
this._backdrop = this.createElement('div', ['nk-peek-backdrop']);
|
|
33
42
|
this._box = this.createElement('aside', ['nk-peek'], { role: 'dialog', tabindex: '-1' });
|
|
43
|
+
this._handle = this.createElement('div', ['pk-resize'], { role: 'separator', 'aria-orientation': 'vertical', tabindex: '0' });
|
|
44
|
+
this._box.appendChild(this._handle);
|
|
34
45
|
const bar = this.createElement('div', ['pk-bar']);
|
|
35
46
|
this._close = this.createElement('button', ['nk-topbar-btn'], { type: 'button' });
|
|
36
47
|
this._close.innerHTML = CLOSE_ICON;
|
|
@@ -41,9 +52,41 @@ class NkPeek extends NkElement {
|
|
|
41
52
|
this._backdrop.appendChild(this._box);
|
|
42
53
|
this._wrapper.appendChild(this._backdrop);
|
|
43
54
|
this._syncLabels();
|
|
55
|
+
this._syncResize();
|
|
56
|
+
if (this.hasAttribute('width')) this._setWidth(Number(this.getAttribute('width')));
|
|
44
57
|
this._syncOpen();
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
// ── width ──
|
|
61
|
+
get _min() { return Number(this.getAttribute('min')) || 380; }
|
|
62
|
+
get _max() { return Number(this.getAttribute('max')) || Math.max(this._min, innerWidth - 320); }
|
|
63
|
+
/** The peek's width in px – the token --nk-peek-width. */
|
|
64
|
+
get width() { return parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nk-peek-width')) || 560; }
|
|
65
|
+
set width(v) { this._setWidth(Number(v)); }
|
|
66
|
+
|
|
67
|
+
_setWidth(w) {
|
|
68
|
+
if (!Number.isFinite(w)) return;
|
|
69
|
+
w = Math.round(Math.min(Math.max(w, this._min), Math.max(this._min, this._max)));
|
|
70
|
+
document.documentElement.style.setProperty('--nk-peek-width', `${w}px`);
|
|
71
|
+
this._handle.setAttribute('aria-valuenow', w);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_syncResize() {
|
|
75
|
+
this._handle.hidden = !this.getBoolAttr('resizable');
|
|
76
|
+
this._handle.setAttribute('aria-label', this.getAttribute('resize-label') || 'Resize');
|
|
77
|
+
this._handle.setAttribute('aria-valuemin', this._min);
|
|
78
|
+
this._handle.setAttribute('aria-valuenow', Math.round(this.width));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The app whose main column makes room: the peek's own, else the page's. */
|
|
82
|
+
_app() { return this.closest('nk-app') ?? document.querySelector('nk-app'); }
|
|
83
|
+
_syncInset() {
|
|
84
|
+
const on = this.getBoolAttr('inset') && this.getBoolAttr('open') && !phone();
|
|
85
|
+
if (on) this._app()?.setAttribute('peek-inset', '');
|
|
86
|
+
else if (this._insetApp) this._app()?.removeAttribute('peek-inset');
|
|
87
|
+
this._insetApp = on;
|
|
88
|
+
}
|
|
89
|
+
|
|
47
90
|
_syncLabels() {
|
|
48
91
|
const label = this.getAttribute('label');
|
|
49
92
|
if (label) this._box.setAttribute('aria-label', label); else this._box.removeAttribute('aria-label');
|
|
@@ -53,6 +96,7 @@ class NkPeek extends NkElement {
|
|
|
53
96
|
_syncOpen() {
|
|
54
97
|
const open = this.getBoolAttr('open');
|
|
55
98
|
this._backdrop.classList.toggle('open', open);
|
|
99
|
+
this._syncInset();
|
|
56
100
|
if (open === this._wasOpen) return;
|
|
57
101
|
this._wasOpen = open;
|
|
58
102
|
if (open) {
|
|
@@ -87,23 +131,54 @@ class NkPeek extends NkElement {
|
|
|
87
131
|
if (path.some(n => n.nodeType === 1 && (OVERLAYS.has(n.localName) || OVERLAY_CLASSES.some(c => n.classList.contains(c))))) return;
|
|
88
132
|
this.close();
|
|
89
133
|
};
|
|
134
|
+
// The left edge: a drag with pointer capture, or the arrow keys.
|
|
135
|
+
const resized = () => this.emit('nk-resize', { width: Math.round(this.width) });
|
|
136
|
+
this._onResizeStart = (e) => {
|
|
137
|
+
if (e.button !== 0) return;
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
this._handle.setPointerCapture(e.pointerId);
|
|
140
|
+
this._handle.classList.add('active');
|
|
141
|
+
};
|
|
142
|
+
this._onResizeMove = (e) => { if (this._handle.hasPointerCapture(e.pointerId)) this._setWidth(innerWidth - e.clientX); };
|
|
143
|
+
this._onResizeEnd = () => { this._handle.classList.remove('active'); resized(); };
|
|
144
|
+
this._onResizeKey = (e) => {
|
|
145
|
+
const step = { ArrowLeft: 16, ArrowRight: -16 }[e.key];
|
|
146
|
+
if (!step) return;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
this._setWidth(this.width + step);
|
|
149
|
+
resized();
|
|
150
|
+
};
|
|
151
|
+
this._onViewport = () => this._syncInset();
|
|
90
152
|
this._close.addEventListener('click', this._onClose);
|
|
91
153
|
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
154
|
+
this._handle.addEventListener('pointerdown', this._onResizeStart);
|
|
155
|
+
this._handle.addEventListener('pointermove', this._onResizeMove);
|
|
156
|
+
this._handle.addEventListener('lostpointercapture', this._onResizeEnd);
|
|
157
|
+
this._handle.addEventListener('keydown', this._onResizeKey);
|
|
92
158
|
document.addEventListener('keydown', this._onKey);
|
|
93
159
|
document.addEventListener('click', this._onOutside);
|
|
160
|
+
addEventListener('resize', this._onViewport);
|
|
94
161
|
}
|
|
95
162
|
|
|
96
163
|
teardownEvents() {
|
|
97
164
|
this._close?.removeEventListener('click', this._onClose);
|
|
98
165
|
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
166
|
+
this._handle?.removeEventListener('pointerdown', this._onResizeStart);
|
|
167
|
+
this._handle?.removeEventListener('pointermove', this._onResizeMove);
|
|
168
|
+
this._handle?.removeEventListener('lostpointercapture', this._onResizeEnd);
|
|
169
|
+
this._handle?.removeEventListener('keydown', this._onResizeKey);
|
|
99
170
|
document.removeEventListener('keydown', this._onKey);
|
|
100
171
|
document.removeEventListener('click', this._onOutside);
|
|
172
|
+
removeEventListener('resize', this._onViewport);
|
|
173
|
+
if (this._insetApp) { this._app()?.removeAttribute('peek-inset'); this._insetApp = false; }
|
|
101
174
|
if (this._modal) { this._modal = false; unlockScroll(); this._undoInert?.(); this._undoInert = null; }
|
|
102
175
|
this._wasOpen = false;
|
|
103
176
|
}
|
|
104
177
|
|
|
105
|
-
onAttributeChanged(name) {
|
|
106
|
-
if (name
|
|
178
|
+
onAttributeChanged(name, _old, value) {
|
|
179
|
+
if (name === 'width') { this._setWidth(Number(value)); return; }
|
|
180
|
+
if (name === 'inset') { this._syncInset(); return; }
|
|
181
|
+
if (name !== 'open') { this._syncLabels(); this._syncResize(); return; }
|
|
107
182
|
this._syncOpen();
|
|
108
183
|
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
109
184
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { NkElement } from '../../base.js';
|
|
2
|
+
import { placeNear } from '../../util/floating.js';
|
|
3
|
+
import { deepActiveElement } from '../../util/focus.js';
|
|
4
|
+
|
|
5
|
+
// <nk-tooltip></nk-tooltip> one for the page: every [data-tooltip]
|
|
6
|
+
// <button data-tooltip="Close sidebar" data-tooltip-key="⌘\">«</button>
|
|
7
|
+
// <nk-tooltip for="saveBtn" shortcut="⌘S">Save the page</nk-tooltip>
|
|
8
|
+
// tip.show(barOrRect, 'Shell · 12.10.–30.11.'); tip.hide();
|
|
9
|
+
// → <div class="nk-tooltip open" role="tooltip">Close sidebar<span class="tt-key">⌘\</span></div>
|
|
10
|
+
//
|
|
11
|
+
// Notion's hover hint (NotionKit 1.10.0). Without `for` it serves every
|
|
12
|
+
// element with data-tooltip – and data-tooltip-key for a shortcut – on the
|
|
13
|
+
// page, inside shadow roots too: the tooltips of a whole toolbar in one
|
|
14
|
+
// element. With `for` it belongs to the element with that id and shows its
|
|
15
|
+
// own content and `shortcut`. It appears after `delay` ms (400) under the
|
|
16
|
+
// pointer and at once on keyboard focus, never on touch; leaving, a press,
|
|
17
|
+
// blur, scrolling and Escape hide it. It sits 6px below its target and
|
|
18
|
+
// centred, above it where the window ends – placement="top" prefers above –
|
|
19
|
+
// and 8px inside the window (placeNear in util/floating.js). show(target,
|
|
20
|
+
// text, shortcut) takes an element or a rect, for what has no element of
|
|
21
|
+
// its own: the bars of a Gantt chart. While it shows, the target's
|
|
22
|
+
// aria-describedby names it (in the same document or shadow root).
|
|
23
|
+
const hostSheet = new CSSStyleSheet();
|
|
24
|
+
hostSheet.replaceSync(`:host { display: block; position: fixed; top: 0; left: 0; z-index: 130; pointer-events: none; }`);
|
|
25
|
+
let count = 0;
|
|
26
|
+
|
|
27
|
+
class NkTooltip extends NkElement {
|
|
28
|
+
static get hostStyles() { return hostSheet; }
|
|
29
|
+
static get observedAttributes() { return ['for', 'shortcut']; }
|
|
30
|
+
|
|
31
|
+
render() {
|
|
32
|
+
this._bubble = this.createElement('div', ['nk-tooltip'], { role: 'tooltip' });
|
|
33
|
+
this._slot = document.createElement('slot');
|
|
34
|
+
this._text = document.createTextNode('');
|
|
35
|
+
this._key = this.createElement('span', ['tt-key']);
|
|
36
|
+
this._bubble.append(this._slot, this._text, this._key);
|
|
37
|
+
this._wrapper.appendChild(this._bubble);
|
|
38
|
+
this._fill(null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get _delay() { const d = Number(this.getAttribute('delay')); return this.hasAttribute('delay') && d >= 0 ? d : 400; }
|
|
42
|
+
|
|
43
|
+
/** Own content (text null) or a given text and shortcut. */
|
|
44
|
+
_fill(text, key = this.getAttribute('shortcut')) {
|
|
45
|
+
this._slot.style.display = text == null ? '' : 'none';
|
|
46
|
+
this._text.data = text ?? '';
|
|
47
|
+
this._key.textContent = key || '';
|
|
48
|
+
this._key.hidden = !key;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Shows it at `target` – an element or a rect – with `text` (else its own content) and `shortcut`. */
|
|
52
|
+
show(target, text, shortcut) {
|
|
53
|
+
clearTimeout(this._timer);
|
|
54
|
+
if (!target) return;
|
|
55
|
+
this._fill(text ?? null, shortcut);
|
|
56
|
+
this._describe(target);
|
|
57
|
+
placeNear(this, target, this.getAttribute('placement') === 'top' ? 'top' : 'bottom');
|
|
58
|
+
this._bubble.classList.add('open');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hide() {
|
|
62
|
+
clearTimeout(this._timer);
|
|
63
|
+
this._bubble?.classList.remove('open');
|
|
64
|
+
this._describe(null);
|
|
65
|
+
this._target = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get open() { return !!this._bubble?.classList.contains('open'); }
|
|
69
|
+
|
|
70
|
+
_describe(target) {
|
|
71
|
+
if (this._described) {
|
|
72
|
+
const ids = (this._described.getAttribute('aria-describedby') || '').split(/\s+/).filter(id => id && id !== this.id);
|
|
73
|
+
ids.length ? this._described.setAttribute('aria-describedby', ids.join(' ')) : this._described.removeAttribute('aria-describedby');
|
|
74
|
+
this._described = null;
|
|
75
|
+
}
|
|
76
|
+
if (!(target instanceof Element) || target.getRootNode() !== this.getRootNode()) return;
|
|
77
|
+
this.id ||= `nk-tooltip-${++count}`;
|
|
78
|
+
const ids = (target.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
|
|
79
|
+
if (!ids.includes(this.id)) target.setAttribute('aria-describedby', [...ids, this.id].join(' '));
|
|
80
|
+
this._described = target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The element this tooltip serves for an event: its `for` target, or the nearest [data-tooltip]. */
|
|
84
|
+
_targetOf(e) {
|
|
85
|
+
const path = e.composedPath();
|
|
86
|
+
const own = this.getAttribute('for');
|
|
87
|
+
if (own) { const el = this.getRootNode().getElementById?.(own); return el && path.includes(el) ? el : null; }
|
|
88
|
+
return path.find(n => n instanceof Element && n.hasAttribute('data-tooltip')) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_showFor(el) {
|
|
92
|
+
const own = this.hasAttribute('for');
|
|
93
|
+
this.show(el, own ? null : el.getAttribute('data-tooltip'), own ? this.getAttribute('shortcut') : el.getAttribute('data-tooltip-key'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setupEvents() {
|
|
97
|
+
this._onOver = (e) => {
|
|
98
|
+
const el = e.pointerType === 'touch' ? null : this._targetOf(e);
|
|
99
|
+
if (el === this._target) return;
|
|
100
|
+
this.hide();
|
|
101
|
+
if (!el) return;
|
|
102
|
+
this._target = el;
|
|
103
|
+
this._timer = setTimeout(() => this._showFor(el), this._delay);
|
|
104
|
+
};
|
|
105
|
+
this._onFocus = (e) => {
|
|
106
|
+
const el = this._targetOf(e);
|
|
107
|
+
if (!el || !deepActiveElement()?.matches(':focus-visible')) return;
|
|
108
|
+
this.hide();
|
|
109
|
+
this._target = el;
|
|
110
|
+
this._showFor(el);
|
|
111
|
+
};
|
|
112
|
+
this._onHide = () => this.hide();
|
|
113
|
+
this._onKey = (e) => { if (e.key === 'Escape' && this.open) this.hide(); };
|
|
114
|
+
document.addEventListener('pointerover', this._onOver);
|
|
115
|
+
document.addEventListener('focusin', this._onFocus);
|
|
116
|
+
document.addEventListener('focusout', this._onHide);
|
|
117
|
+
document.addEventListener('pointerdown', this._onHide, true);
|
|
118
|
+
document.addEventListener('scroll', this._onHide, true);
|
|
119
|
+
document.addEventListener('keydown', this._onKey);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
teardownEvents() {
|
|
123
|
+
document.removeEventListener('pointerover', this._onOver);
|
|
124
|
+
document.removeEventListener('focusin', this._onFocus);
|
|
125
|
+
document.removeEventListener('focusout', this._onHide);
|
|
126
|
+
document.removeEventListener('pointerdown', this._onHide, true);
|
|
127
|
+
document.removeEventListener('scroll', this._onHide, true);
|
|
128
|
+
document.removeEventListener('keydown', this._onKey);
|
|
129
|
+
this.hide();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onAttributeChanged(name) { if (name === 'shortcut' && !this._text.data) this._fill(null); if (name === 'for') this.hide(); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
customElements.define('nk-tooltip', NkTooltip);
|
|
136
|
+
export { NkTooltip };
|
|
@@ -7,15 +7,22 @@ import { NkElement } from '../../base.js';
|
|
|
7
7
|
// </nk-app>
|
|
8
8
|
// → <div class="nk-app"><aside class="nk-sidebar">…</aside><main class="nk-main">…</main></div>
|
|
9
9
|
// The outermost element of a workspace app: a full-height flex row.
|
|
10
|
+
// `peek-inset` (NotionKit 1.10.0) gives the main column a right padding as
|
|
11
|
+
// wide as the side peek – <nk-peek inset> sets it while it is open.
|
|
10
12
|
class NkApp extends NkElement {
|
|
13
|
+
static get observedAttributes() { return ['peek-inset']; }
|
|
14
|
+
|
|
11
15
|
render() {
|
|
12
|
-
const app = this.createElement('div', ['nk-app']);
|
|
16
|
+
const app = this._app = this.createElement('div', ['nk-app']);
|
|
17
|
+
app.classList.toggle('peek-inset', this.getBoolAttr('peek-inset'));
|
|
13
18
|
app.appendChild(this.createElement('slot', [], { name: 'sidebar' }));
|
|
14
19
|
const main = this.createElement('main', ['nk-main']);
|
|
15
20
|
main.appendChild(document.createElement('slot'));
|
|
16
21
|
app.appendChild(main);
|
|
17
22
|
this._wrapper.appendChild(app);
|
|
18
23
|
}
|
|
24
|
+
|
|
25
|
+
onAttributeChanged() { this._app.classList.toggle('peek-inset', this.getBoolAttr('peek-inset')); }
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
customElements.define('nk-app', NkApp);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NkElement } from '../../base.js';
|
|
2
|
+
import { deepActiveElement } from '../../util/focus.js';
|
|
2
3
|
|
|
3
4
|
// <nk-sidebar slot="sidebar">
|
|
4
5
|
// <nk-workspace-switcher slot="workspace" name="Acme"></nk-workspace-switcher>
|
|
@@ -15,13 +16,28 @@ import { NkElement } from '../../base.js';
|
|
|
15
16
|
// .open) – the sidebar slides and the scrim fades by the same rules as the
|
|
16
17
|
// class markup; the element toggles the two classes. <nk-btn variant="sidebar">
|
|
17
18
|
// in the topbar is the ☰ that opens it.
|
|
19
|
+
//
|
|
20
|
+
// `collapsible` (NotionKit 1.10.0): on the desktop Notion's « beside the
|
|
21
|
+
// workspace row collapses the sidebar – it slides out to the left, the main
|
|
22
|
+
// column takes the width, nothing in it takes focus – and the topbar's ☰
|
|
23
|
+
// brings it back; ⌘\ or Ctrl+\ toggles it (on a phone the drawer).
|
|
24
|
+
// `collapsed` is the state, collapse() / expand() / toggleCollapsed() the
|
|
25
|
+
// API, nk-collapse { collapsed } the moment to keep it. collapse-label names
|
|
26
|
+
// the « (default "Close sidebar") and is its tooltip for <nk-tooltip>.
|
|
27
|
+
const COLLAPSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m18 17-5-5 5-5M11 17l-5-5 5-5"/></svg>';
|
|
28
|
+
const phone = () => matchMedia('(max-width: 860px)').matches;
|
|
29
|
+
|
|
18
30
|
class NkSidebar extends NkElement {
|
|
19
|
-
static get observedAttributes() { return ['open']; }
|
|
31
|
+
static get observedAttributes() { return ['open', 'collapsible', 'collapsed', 'collapse-label']; }
|
|
20
32
|
|
|
21
33
|
render() {
|
|
22
34
|
this._backdrop = this.createElement('div', ['nk-sidebar-backdrop']);
|
|
23
35
|
this._aside = this.createElement('aside', ['nk-sidebar']);
|
|
24
|
-
this.
|
|
36
|
+
const head = this.createElement('div', ['nk-sidebar-head']);
|
|
37
|
+
this._collapse = this.createElement('button', ['nk-sidebar-collapse'], { type: 'button', 'data-tooltip-key': '⌘\\' });
|
|
38
|
+
this._collapse.innerHTML = COLLAPSE_ICON;
|
|
39
|
+
head.append(this.createElement('slot', [], { name: 'workspace' }), this._collapse);
|
|
40
|
+
this._aside.appendChild(head);
|
|
25
41
|
const scroll = this.createElement('div', ['nk-sidebar-scroll']);
|
|
26
42
|
scroll.appendChild(document.createElement('slot'));
|
|
27
43
|
this._aside.appendChild(scroll);
|
|
@@ -33,6 +49,21 @@ class NkSidebar extends NkElement {
|
|
|
33
49
|
this._wrapper.appendChild(this._aside);
|
|
34
50
|
this._syncFooter();
|
|
35
51
|
this._syncOpen();
|
|
52
|
+
this._syncCollapse();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_syncCollapse() {
|
|
56
|
+
const label = this.getAttribute('collapse-label') || 'Close sidebar';
|
|
57
|
+
this._collapse.hidden = !this.getBoolAttr('collapsible');
|
|
58
|
+
this._collapse.setAttribute('aria-label', label);
|
|
59
|
+
this._collapse.dataset.tooltip = label;
|
|
60
|
+
this._aside.classList.toggle('collapsed', this.getBoolAttr('collapsed'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The ☰ that brings the sidebar back: the one in its own app, else the page's. */
|
|
64
|
+
_toggleButton() {
|
|
65
|
+
const sel = 'nk-btn[variant="sidebar"]';
|
|
66
|
+
return this.closest('nk-app')?.querySelector(sel) ?? document.querySelector(sel);
|
|
36
67
|
}
|
|
37
68
|
|
|
38
69
|
_syncOpen() {
|
|
@@ -52,22 +83,47 @@ class NkSidebar extends NkElement {
|
|
|
52
83
|
setupEvents() {
|
|
53
84
|
this._onSlot = () => this._syncFooter();
|
|
54
85
|
this._onBackdrop = () => this.close();
|
|
55
|
-
this._onKey = (e) => {
|
|
86
|
+
this._onKey = (e) => {
|
|
87
|
+
if (e.key === 'Escape' && this.getBoolAttr('open')) this.close();
|
|
88
|
+
// Notion's ⌘\: the collapse on the desktop, the drawer on a phone.
|
|
89
|
+
if (e.key === '\\' && (e.metaKey || e.ctrlKey) && this.getBoolAttr('collapsible') && !e.defaultPrevented) {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
phone() ? this.toggle() : this.toggleCollapsed();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
this._onCollapse = () => this.collapse();
|
|
56
95
|
this._footerSlot.addEventListener('slotchange', this._onSlot);
|
|
57
96
|
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
97
|
+
this._collapse.addEventListener('click', this._onCollapse);
|
|
58
98
|
document.addEventListener('keydown', this._onKey);
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
teardownEvents() {
|
|
62
102
|
this._footerSlot?.removeEventListener('slotchange', this._onSlot);
|
|
63
103
|
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
104
|
+
this._collapse?.removeEventListener('click', this._onCollapse);
|
|
64
105
|
document.removeEventListener('keydown', this._onKey);
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
onAttributeChanged(name) {
|
|
68
|
-
if (name
|
|
69
|
-
|
|
70
|
-
|
|
109
|
+
if (name === 'open') {
|
|
110
|
+
this._syncOpen();
|
|
111
|
+
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Focus follows to the control that is left: the ☰ after collapsing, the « after expanding.
|
|
115
|
+
const active = deepActiveElement();
|
|
116
|
+
const fromSidebar = active && (this.contains(active) || this._shadow.contains(active));
|
|
117
|
+
const fromToggle = active?.closest?.('.nk-sidebar-toggle');
|
|
118
|
+
this._syncCollapse();
|
|
119
|
+
if (name !== 'collapsed') return;
|
|
120
|
+
const collapsed = this.getBoolAttr('collapsed');
|
|
121
|
+
this.emit('nk-collapse', { collapsed });
|
|
122
|
+
// Two frames later: with reduced motion every nk- element transitions for
|
|
123
|
+
// .01ms, the inherited visibility included, so the « is focusable only then.
|
|
124
|
+
const later = fn => requestAnimationFrame(() => requestAnimationFrame(fn));
|
|
125
|
+
if (collapsed && fromSidebar) later(() => this._toggleButton()?.focus({ preventScroll: true }));
|
|
126
|
+
if (!collapsed && fromToggle && !this._collapse.hidden) later(() => this._collapse.focus({ preventScroll: true }));
|
|
71
127
|
}
|
|
72
128
|
|
|
73
129
|
show() { this.setBoolAttr('open', true); }
|
|
@@ -76,6 +132,12 @@ class NkSidebar extends NkElement {
|
|
|
76
132
|
|
|
77
133
|
get open() { return this.getBoolAttr('open'); }
|
|
78
134
|
set open(v) { this.setBoolAttr('open', v); }
|
|
135
|
+
|
|
136
|
+
collapse() { this.setBoolAttr('collapsed', true); }
|
|
137
|
+
expand() { this.setBoolAttr('collapsed', false); }
|
|
138
|
+
toggleCollapsed() { this.setBoolAttr('collapsed', !this.getBoolAttr('collapsed')); }
|
|
139
|
+
get collapsed() { return this.getBoolAttr('collapsed'); }
|
|
140
|
+
set collapsed(v) { this.setBoolAttr('collapsed', v); }
|
|
79
141
|
}
|
|
80
142
|
|
|
81
143
|
customElements.define('nk-sidebar', NkSidebar);
|
|
@@ -8,7 +8,7 @@ import { NkElement } from '../../base.js';
|
|
|
8
8
|
// row; clicking outside or pressing Escape closes it.
|
|
9
9
|
const menuSheet = new CSSStyleSheet();
|
|
10
10
|
menuSheet.replaceSync(`
|
|
11
|
-
.nk-ws-anchor { position: relative; }
|
|
11
|
+
.nk-ws-anchor { position: relative; flex: 1 1 auto; min-width: 0; }
|
|
12
12
|
.nk-ws-menu { display: none; position: absolute; left: 8px; top: calc(100% - 2px); z-index: 50; }
|
|
13
13
|
:host([open]) .nk-ws-menu { display: block; }
|
|
14
14
|
`);
|
package/src/index.js
CHANGED
|
@@ -88,7 +88,10 @@ export { NkSteps } from './components/page/nk-steps.js';
|
|
|
88
88
|
export { NkBookmark } from './components/content/nk-bookmark.js';
|
|
89
89
|
export { NkCopyField } from './components/forms/nk-copy-field.js';
|
|
90
90
|
export { NkImagePicker } from './components/forms/nk-image-picker.js';
|
|
91
|
+
export { NkCalendar } from './components/forms/nk-calendar.js';
|
|
91
92
|
export { NkPeek } from './components/overlays/nk-peek.js';
|
|
93
|
+
export { NkDialog } from './components/overlays/nk-dialog.js';
|
|
94
|
+
export { NkTooltip } from './components/overlays/nk-tooltip.js';
|
|
92
95
|
|
|
93
96
|
// Wave 4 – overlays
|
|
94
97
|
export { NkModal } from './components/overlays/nk-modal.js';
|
|
@@ -106,6 +109,7 @@ export { NkDatabase } from './components/data/nk-database.js';
|
|
|
106
109
|
export { NkTableView } from './components/data/nk-table-view.js';
|
|
107
110
|
export { NkBoardView } from './components/data/nk-board-view.js';
|
|
108
111
|
export { NkListView } from './components/data/nk-list-view.js';
|
|
112
|
+
export { NkCalendarView } from './components/data/nk-calendar-view.js';
|
|
109
113
|
export { NkFilterBar } from './components/data/nk-filter-bar.js';
|
|
110
114
|
export { NkComments } from './components/data/nk-comments.js';
|
|
111
115
|
export { NkComment } from './components/data/nk-comment.js';
|