@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
|
@@ -21,17 +21,28 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
21
21
|
// the peek) leave it open. Below 860px it is a bottom sheet over a dimmed
|
|
22
22
|
// page; there it is modal: the page is inert and scroll-locked, the dimmed
|
|
23
23
|
// page takes the tap that closes it. Put it directly under <body>.
|
|
24
|
+
//
|
|
25
|
+
// `resizable` (NotionKit 1.10.0): the left edge takes the pointer – and the
|
|
26
|
+
// arrow keys, 16px a step – to make the peek wider or narrower, as in
|
|
27
|
+
// Notion: from `min` (380) to `max` (all but 320px of the window). The width
|
|
28
|
+
// is the token --nk-peek-width, set on :root; `width` (px) sets it too, and
|
|
29
|
+
// nk-resize { width } fires when a drag or a key ends – the moment to keep
|
|
30
|
+
// it. resize-label names the edge (default "Resize").
|
|
31
|
+
// `inset`: while it is open on the desktop, the page's <nk-app> gets
|
|
32
|
+
// `peek-inset` – its main column makes room instead of lying under the peek.
|
|
24
33
|
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>';
|
|
25
|
-
const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-cmdk', 'nk-toast']);
|
|
26
|
-
const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-cmdk-backdrop'];
|
|
34
|
+
const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-dialog', 'nk-cmdk', 'nk-toast']);
|
|
35
|
+
const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-dialog-backdrop', 'nk-cmdk-backdrop'];
|
|
27
36
|
const phone = () => matchMedia('(max-width: 860px)').matches;
|
|
28
37
|
|
|
29
38
|
class NkPeek extends NkElement {
|
|
30
|
-
static get observedAttributes() { return ['open', 'label', 'close-label']; }
|
|
39
|
+
static get observedAttributes() { return ['open', 'label', 'close-label', 'resizable', 'width', 'min', 'max', 'resize-label', 'inset']; }
|
|
31
40
|
|
|
32
41
|
render() {
|
|
33
42
|
this._backdrop = this.createElement('div', ['nk-peek-backdrop']);
|
|
34
43
|
this._box = this.createElement('aside', ['nk-peek'], { role: 'dialog', tabindex: '-1' });
|
|
44
|
+
this._handle = this.createElement('div', ['pk-resize'], { role: 'separator', 'aria-orientation': 'vertical', tabindex: '0' });
|
|
45
|
+
this._box.appendChild(this._handle);
|
|
35
46
|
const bar = this.createElement('div', ['pk-bar']);
|
|
36
47
|
this._close = this.createElement('button', ['nk-topbar-btn'], { type: 'button' });
|
|
37
48
|
this._close.innerHTML = CLOSE_ICON;
|
|
@@ -42,9 +53,41 @@ class NkPeek extends NkElement {
|
|
|
42
53
|
this._backdrop.appendChild(this._box);
|
|
43
54
|
this._wrapper.appendChild(this._backdrop);
|
|
44
55
|
this._syncLabels();
|
|
56
|
+
this._syncResize();
|
|
57
|
+
if (this.hasAttribute('width')) this._setWidth(Number(this.getAttribute('width')));
|
|
45
58
|
this._syncOpen();
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
// ── width ──
|
|
62
|
+
get _min() { return Number(this.getAttribute('min')) || 380; }
|
|
63
|
+
get _max() { return Number(this.getAttribute('max')) || Math.max(this._min, innerWidth - 320); }
|
|
64
|
+
/** The peek's width in px – the token --nk-peek-width. */
|
|
65
|
+
get width() { return parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nk-peek-width')) || 560; }
|
|
66
|
+
set width(v) { this._setWidth(Number(v)); }
|
|
67
|
+
|
|
68
|
+
_setWidth(w) {
|
|
69
|
+
if (!Number.isFinite(w)) return;
|
|
70
|
+
w = Math.round(Math.min(Math.max(w, this._min), Math.max(this._min, this._max)));
|
|
71
|
+
document.documentElement.style.setProperty('--nk-peek-width', `${w}px`);
|
|
72
|
+
this._handle.setAttribute('aria-valuenow', w);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_syncResize() {
|
|
76
|
+
this._handle.hidden = !this.getBoolAttr('resizable');
|
|
77
|
+
this._handle.setAttribute('aria-label', this.getAttribute('resize-label') || 'Resize');
|
|
78
|
+
this._handle.setAttribute('aria-valuemin', this._min);
|
|
79
|
+
this._handle.setAttribute('aria-valuenow', Math.round(this.width));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The app whose main column makes room: the peek's own, else the page's. */
|
|
83
|
+
_app() { return this.closest('nk-app') ?? document.querySelector('nk-app'); }
|
|
84
|
+
_syncInset() {
|
|
85
|
+
const on = this.getBoolAttr('inset') && this.getBoolAttr('open') && !phone();
|
|
86
|
+
if (on) this._app()?.setAttribute('peek-inset', '');
|
|
87
|
+
else if (this._insetApp) this._app()?.removeAttribute('peek-inset');
|
|
88
|
+
this._insetApp = on;
|
|
89
|
+
}
|
|
90
|
+
|
|
48
91
|
_syncLabels() {
|
|
49
92
|
const label = this.getAttribute('label');
|
|
50
93
|
if (label) this._box.setAttribute('aria-label', label); else this._box.removeAttribute('aria-label');
|
|
@@ -54,6 +97,7 @@ class NkPeek extends NkElement {
|
|
|
54
97
|
_syncOpen() {
|
|
55
98
|
const open = this.getBoolAttr('open');
|
|
56
99
|
this._backdrop.classList.toggle('open', open);
|
|
100
|
+
this._syncInset();
|
|
57
101
|
if (open === this._wasOpen) return;
|
|
58
102
|
this._wasOpen = open;
|
|
59
103
|
if (open) {
|
|
@@ -88,23 +132,54 @@ class NkPeek extends NkElement {
|
|
|
88
132
|
if (path.some(n => n.nodeType === 1 && (OVERLAYS.has(n.localName) || OVERLAY_CLASSES.some(c => n.classList.contains(c))))) return;
|
|
89
133
|
this.close();
|
|
90
134
|
};
|
|
135
|
+
// The left edge: a drag with pointer capture, or the arrow keys.
|
|
136
|
+
const resized = () => this.emit('nk-resize', { width: Math.round(this.width) });
|
|
137
|
+
this._onResizeStart = (e) => {
|
|
138
|
+
if (e.button !== 0) return;
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
this._handle.setPointerCapture(e.pointerId);
|
|
141
|
+
this._handle.classList.add('active');
|
|
142
|
+
};
|
|
143
|
+
this._onResizeMove = (e) => { if (this._handle.hasPointerCapture(e.pointerId)) this._setWidth(innerWidth - e.clientX); };
|
|
144
|
+
this._onResizeEnd = () => { this._handle.classList.remove('active'); resized(); };
|
|
145
|
+
this._onResizeKey = (e) => {
|
|
146
|
+
const step = { ArrowLeft: 16, ArrowRight: -16 }[e.key];
|
|
147
|
+
if (!step) return;
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
this._setWidth(this.width + step);
|
|
150
|
+
resized();
|
|
151
|
+
};
|
|
152
|
+
this._onViewport = () => this._syncInset();
|
|
91
153
|
this._close.addEventListener('click', this._onClose);
|
|
92
154
|
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
155
|
+
this._handle.addEventListener('pointerdown', this._onResizeStart);
|
|
156
|
+
this._handle.addEventListener('pointermove', this._onResizeMove);
|
|
157
|
+
this._handle.addEventListener('lostpointercapture', this._onResizeEnd);
|
|
158
|
+
this._handle.addEventListener('keydown', this._onResizeKey);
|
|
93
159
|
document.addEventListener('keydown', this._onKey);
|
|
94
160
|
document.addEventListener('click', this._onOutside);
|
|
161
|
+
addEventListener('resize', this._onViewport);
|
|
95
162
|
}
|
|
96
163
|
|
|
97
164
|
teardownEvents() {
|
|
98
165
|
this._close?.removeEventListener('click', this._onClose);
|
|
99
166
|
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
167
|
+
this._handle?.removeEventListener('pointerdown', this._onResizeStart);
|
|
168
|
+
this._handle?.removeEventListener('pointermove', this._onResizeMove);
|
|
169
|
+
this._handle?.removeEventListener('lostpointercapture', this._onResizeEnd);
|
|
170
|
+
this._handle?.removeEventListener('keydown', this._onResizeKey);
|
|
100
171
|
document.removeEventListener('keydown', this._onKey);
|
|
101
172
|
document.removeEventListener('click', this._onOutside);
|
|
173
|
+
removeEventListener('resize', this._onViewport);
|
|
174
|
+
if (this._insetApp) { this._app()?.removeAttribute('peek-inset'); this._insetApp = false; }
|
|
102
175
|
if (this._modal) { this._modal = false; unlockScroll(); this._undoInert?.(); this._undoInert = null; }
|
|
103
176
|
this._wasOpen = false;
|
|
104
177
|
}
|
|
105
178
|
|
|
106
|
-
onAttributeChanged(name) {
|
|
107
|
-
if (name
|
|
179
|
+
onAttributeChanged(name, _old, value) {
|
|
180
|
+
if (name === 'width') { this._setWidth(Number(value)); return; }
|
|
181
|
+
if (name === 'inset') { this._syncInset(); return; }
|
|
182
|
+
if (name !== 'open') { this._syncLabels(); this._syncResize(); return; }
|
|
108
183
|
this._syncOpen();
|
|
109
184
|
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
110
185
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NkElement } from './base.js';
|
|
2
|
+
import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
|
|
2
3
|
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
3
4
|
|
|
4
5
|
// <nk-sidebar slot="sidebar">
|
|
@@ -16,13 +17,28 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
16
17
|
// .open) – the sidebar slides and the scrim fades by the same rules as the
|
|
17
18
|
// class markup; the element toggles the two classes. <nk-btn variant="sidebar">
|
|
18
19
|
// in the topbar is the ☰ that opens it.
|
|
20
|
+
//
|
|
21
|
+
// `collapsible` (NotionKit 1.10.0): on the desktop Notion's « beside the
|
|
22
|
+
// workspace row collapses the sidebar – it slides out to the left, the main
|
|
23
|
+
// column takes the width, nothing in it takes focus – and the topbar's ☰
|
|
24
|
+
// brings it back; ⌘\ or Ctrl+\ toggles it (on a phone the drawer).
|
|
25
|
+
// `collapsed` is the state, collapse() / expand() / toggleCollapsed() the
|
|
26
|
+
// API, nk-collapse { collapsed } the moment to keep it. collapse-label names
|
|
27
|
+
// the « (default "Close sidebar") and is its tooltip for <nk-tooltip>.
|
|
28
|
+
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>';
|
|
29
|
+
const phone = () => matchMedia('(max-width: 860px)').matches;
|
|
30
|
+
|
|
19
31
|
class NkSidebar extends NkElement {
|
|
20
|
-
static get observedAttributes() { return ['open']; }
|
|
32
|
+
static get observedAttributes() { return ['open', 'collapsible', 'collapsed', 'collapse-label']; }
|
|
21
33
|
|
|
22
34
|
render() {
|
|
23
35
|
this._backdrop = this.createElement('div', ['nk-sidebar-backdrop']);
|
|
24
36
|
this._aside = this.createElement('aside', ['nk-sidebar']);
|
|
25
|
-
this.
|
|
37
|
+
const head = this.createElement('div', ['nk-sidebar-head']);
|
|
38
|
+
this._collapse = this.createElement('button', ['nk-sidebar-collapse'], { type: 'button', 'data-tooltip-key': '⌘\\' });
|
|
39
|
+
this._collapse.innerHTML = COLLAPSE_ICON;
|
|
40
|
+
head.append(this.createElement('slot', [], { name: 'workspace' }), this._collapse);
|
|
41
|
+
this._aside.appendChild(head);
|
|
26
42
|
const scroll = this.createElement('div', ['nk-sidebar-scroll']);
|
|
27
43
|
scroll.appendChild(document.createElement('slot'));
|
|
28
44
|
this._aside.appendChild(scroll);
|
|
@@ -34,6 +50,21 @@ class NkSidebar extends NkElement {
|
|
|
34
50
|
this._wrapper.appendChild(this._aside);
|
|
35
51
|
this._syncFooter();
|
|
36
52
|
this._syncOpen();
|
|
53
|
+
this._syncCollapse();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_syncCollapse() {
|
|
57
|
+
const label = this.getAttribute('collapse-label') || 'Close sidebar';
|
|
58
|
+
this._collapse.hidden = !this.getBoolAttr('collapsible');
|
|
59
|
+
this._collapse.setAttribute('aria-label', label);
|
|
60
|
+
this._collapse.dataset.tooltip = label;
|
|
61
|
+
this._aside.classList.toggle('collapsed', this.getBoolAttr('collapsed'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The ☰ that brings the sidebar back: the one in its own app, else the page's. */
|
|
65
|
+
_toggleButton() {
|
|
66
|
+
const sel = 'nk-btn[variant="sidebar"]';
|
|
67
|
+
return this.closest('nk-app')?.querySelector(sel) ?? document.querySelector(sel);
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
_syncOpen() {
|
|
@@ -53,22 +84,47 @@ class NkSidebar extends NkElement {
|
|
|
53
84
|
setupEvents() {
|
|
54
85
|
this._onSlot = () => this._syncFooter();
|
|
55
86
|
this._onBackdrop = () => this.close();
|
|
56
|
-
this._onKey = (e) => {
|
|
87
|
+
this._onKey = (e) => {
|
|
88
|
+
if (e.key === 'Escape' && this.getBoolAttr('open')) this.close();
|
|
89
|
+
// Notion's ⌘\: the collapse on the desktop, the drawer on a phone.
|
|
90
|
+
if (e.key === '\\' && (e.metaKey || e.ctrlKey) && this.getBoolAttr('collapsible') && !e.defaultPrevented) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
phone() ? this.toggle() : this.toggleCollapsed();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
this._onCollapse = () => this.collapse();
|
|
57
96
|
this._footerSlot.addEventListener('slotchange', this._onSlot);
|
|
58
97
|
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
98
|
+
this._collapse.addEventListener('click', this._onCollapse);
|
|
59
99
|
document.addEventListener('keydown', this._onKey);
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
teardownEvents() {
|
|
63
103
|
this._footerSlot?.removeEventListener('slotchange', this._onSlot);
|
|
64
104
|
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
105
|
+
this._collapse?.removeEventListener('click', this._onCollapse);
|
|
65
106
|
document.removeEventListener('keydown', this._onKey);
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
onAttributeChanged(name) {
|
|
69
|
-
if (name
|
|
70
|
-
|
|
71
|
-
|
|
110
|
+
if (name === 'open') {
|
|
111
|
+
this._syncOpen();
|
|
112
|
+
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Focus follows to the control that is left: the ☰ after collapsing, the « after expanding.
|
|
116
|
+
const active = deepActiveElement();
|
|
117
|
+
const fromSidebar = active && (this.contains(active) || this._shadow.contains(active));
|
|
118
|
+
const fromToggle = active?.closest?.('.nk-sidebar-toggle');
|
|
119
|
+
this._syncCollapse();
|
|
120
|
+
if (name !== 'collapsed') return;
|
|
121
|
+
const collapsed = this.getBoolAttr('collapsed');
|
|
122
|
+
this.emit('nk-collapse', { collapsed });
|
|
123
|
+
// Two frames later: with reduced motion every nk- element transitions for
|
|
124
|
+
// .01ms, the inherited visibility included, so the « is focusable only then.
|
|
125
|
+
const later = fn => requestAnimationFrame(() => requestAnimationFrame(fn));
|
|
126
|
+
if (collapsed && fromSidebar) later(() => this._toggleButton()?.focus({ preventScroll: true }));
|
|
127
|
+
if (!collapsed && fromToggle && !this._collapse.hidden) later(() => this._collapse.focus({ preventScroll: true }));
|
|
72
128
|
}
|
|
73
129
|
|
|
74
130
|
show() { this.setBoolAttr('open', true); }
|
|
@@ -77,6 +133,12 @@ class NkSidebar extends NkElement {
|
|
|
77
133
|
|
|
78
134
|
get open() { return this.getBoolAttr('open'); }
|
|
79
135
|
set open(v) { this.setBoolAttr('open', v); }
|
|
136
|
+
|
|
137
|
+
collapse() { this.setBoolAttr('collapsed', true); }
|
|
138
|
+
expand() { this.setBoolAttr('collapsed', false); }
|
|
139
|
+
toggleCollapsed() { this.setBoolAttr('collapsed', !this.getBoolAttr('collapsed')); }
|
|
140
|
+
get collapsed() { return this.getBoolAttr('collapsed'); }
|
|
141
|
+
set collapsed(v) { this.setBoolAttr('collapsed', v); }
|
|
80
142
|
}
|
|
81
143
|
|
|
82
144
|
customElements.define('nk-sidebar', NkSidebar);
|
|
@@ -7,8 +7,10 @@ import './shared/avatar-BiqCaHOt.js';
|
|
|
7
7
|
// `wrap` lets cell text break (`.nk-table.wrap`), like Notion's "wrap column".
|
|
8
8
|
// Standalone: view.columns = […]; view.rows = […]; Inside <nk-database> the
|
|
9
9
|
// database pushes `data`. → <div class="nk-table-wrap"><table class="nk-table">…</table><div class="nk-new-row">+ New page</div></div>
|
|
10
|
-
// Cells are plain markup from renderPropertyCell(); rows fire nk-select
|
|
11
|
-
//
|
|
10
|
+
// Cells are plain markup from renderPropertyCell(); rows fire nk-select
|
|
11
|
+
// { row, id, key, cell } – `key` and `cell` say which cell was hit, to open
|
|
12
|
+
// an editor there, like the date picker under a due date – checkboxes
|
|
13
|
+
// nk-change, headers nk-action { action: 'sort' } (and sort locally
|
|
12
14
|
// with `sortable`), the add row nk-action { action: 'new-row' }.
|
|
13
15
|
class NkTableView extends NkElement {
|
|
14
16
|
static get observedAttributes() { return ['name', 'label', 'badge', 'count', 'new-row', 'new-row-label', 'sortable', 'sort-key', 'sort-dir', 'wrap']; }
|
|
@@ -76,7 +78,11 @@ class NkTableView extends NkElement {
|
|
|
76
78
|
}
|
|
77
79
|
if (e.target.closest('input[type=checkbox], a')) return;
|
|
78
80
|
const tr = e.target.closest('tr[data-id]');
|
|
79
|
-
if (tr)
|
|
81
|
+
if (!tr) return;
|
|
82
|
+
// Which cell: `key` names its column, `cell` is there to anchor an editor to.
|
|
83
|
+
const cell = e.target.closest('td');
|
|
84
|
+
const row = this._rowById(tr.dataset.id);
|
|
85
|
+
this.emit('nk-select', { row, id: row?.id, value: row?.id, key: cell ? this._columns[cell.cellIndex]?.key : undefined, cell });
|
|
80
86
|
};
|
|
81
87
|
this._onChange = (e) => {
|
|
82
88
|
const input = e.target;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { NkElement } from './base.js';
|
|
2
|
+
import { a as placeNear } from './shared/floating-CxHxy-Cb.js';
|
|
3
|
+
import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
|
|
4
|
+
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
5
|
+
|
|
6
|
+
// <nk-tooltip></nk-tooltip> one for the page: every [data-tooltip]
|
|
7
|
+
// <button data-tooltip="Close sidebar" data-tooltip-key="⌘\">«</button>
|
|
8
|
+
// <nk-tooltip for="saveBtn" shortcut="⌘S">Save the page</nk-tooltip>
|
|
9
|
+
// tip.show(barOrRect, 'Shell · 12.10.–30.11.'); tip.hide();
|
|
10
|
+
// → <div class="nk-tooltip open" role="tooltip">Close sidebar<span class="tt-key">⌘\</span></div>
|
|
11
|
+
//
|
|
12
|
+
// Notion's hover hint (NotionKit 1.10.0). Without `for` it serves every
|
|
13
|
+
// element with data-tooltip – and data-tooltip-key for a shortcut – on the
|
|
14
|
+
// page, inside shadow roots too: the tooltips of a whole toolbar in one
|
|
15
|
+
// element. With `for` it belongs to the element with that id and shows its
|
|
16
|
+
// own content and `shortcut`. It appears after `delay` ms (400) under the
|
|
17
|
+
// pointer and at once on keyboard focus, never on touch; leaving, a press,
|
|
18
|
+
// blur, scrolling and Escape hide it. It sits 6px below its target and
|
|
19
|
+
// centred, above it where the window ends – placement="top" prefers above –
|
|
20
|
+
// and 8px inside the window (placeNear in util/floating.js). show(target,
|
|
21
|
+
// text, shortcut) takes an element or a rect, for what has no element of
|
|
22
|
+
// its own: the bars of a Gantt chart. While it shows, the target's
|
|
23
|
+
// aria-describedby names it (in the same document or shadow root).
|
|
24
|
+
const hostSheet = new CSSStyleSheet();
|
|
25
|
+
hostSheet.replaceSync(`:host { display: block; position: fixed; top: 0; left: 0; z-index: 130; pointer-events: none; }`);
|
|
26
|
+
let count = 0;
|
|
27
|
+
|
|
28
|
+
class NkTooltip extends NkElement {
|
|
29
|
+
static get hostStyles() { return hostSheet; }
|
|
30
|
+
static get observedAttributes() { return ['for', 'shortcut']; }
|
|
31
|
+
|
|
32
|
+
render() {
|
|
33
|
+
this._bubble = this.createElement('div', ['nk-tooltip'], { role: 'tooltip' });
|
|
34
|
+
this._slot = document.createElement('slot');
|
|
35
|
+
this._text = document.createTextNode('');
|
|
36
|
+
this._key = this.createElement('span', ['tt-key']);
|
|
37
|
+
this._bubble.append(this._slot, this._text, this._key);
|
|
38
|
+
this._wrapper.appendChild(this._bubble);
|
|
39
|
+
this._fill(null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get _delay() { const d = Number(this.getAttribute('delay')); return this.hasAttribute('delay') && d >= 0 ? d : 400; }
|
|
43
|
+
|
|
44
|
+
/** Own content (text null) or a given text and shortcut. */
|
|
45
|
+
_fill(text, key = this.getAttribute('shortcut')) {
|
|
46
|
+
this._slot.style.display = text == null ? '' : 'none';
|
|
47
|
+
this._text.data = text ?? '';
|
|
48
|
+
this._key.textContent = key || '';
|
|
49
|
+
this._key.hidden = !key;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Shows it at `target` – an element or a rect – with `text` (else its own content) and `shortcut`. */
|
|
53
|
+
show(target, text, shortcut) {
|
|
54
|
+
clearTimeout(this._timer);
|
|
55
|
+
if (!target) return;
|
|
56
|
+
this._fill(text ?? null, shortcut);
|
|
57
|
+
this._describe(target);
|
|
58
|
+
placeNear(this, target, this.getAttribute('placement') === 'top' ? 'top' : 'bottom');
|
|
59
|
+
this._bubble.classList.add('open');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hide() {
|
|
63
|
+
clearTimeout(this._timer);
|
|
64
|
+
this._bubble?.classList.remove('open');
|
|
65
|
+
this._describe(null);
|
|
66
|
+
this._target = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get open() { return !!this._bubble?.classList.contains('open'); }
|
|
70
|
+
|
|
71
|
+
_describe(target) {
|
|
72
|
+
if (this._described) {
|
|
73
|
+
const ids = (this._described.getAttribute('aria-describedby') || '').split(/\s+/).filter(id => id && id !== this.id);
|
|
74
|
+
ids.length ? this._described.setAttribute('aria-describedby', ids.join(' ')) : this._described.removeAttribute('aria-describedby');
|
|
75
|
+
this._described = null;
|
|
76
|
+
}
|
|
77
|
+
if (!(target instanceof Element) || target.getRootNode() !== this.getRootNode()) return;
|
|
78
|
+
this.id ||= `nk-tooltip-${++count}`;
|
|
79
|
+
const ids = (target.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
|
|
80
|
+
if (!ids.includes(this.id)) target.setAttribute('aria-describedby', [...ids, this.id].join(' '));
|
|
81
|
+
this._described = target;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The element this tooltip serves for an event: its `for` target, or the nearest [data-tooltip]. */
|
|
85
|
+
_targetOf(e) {
|
|
86
|
+
const path = e.composedPath();
|
|
87
|
+
const own = this.getAttribute('for');
|
|
88
|
+
if (own) { const el = this.getRootNode().getElementById?.(own); return el && path.includes(el) ? el : null; }
|
|
89
|
+
return path.find(n => n instanceof Element && n.hasAttribute('data-tooltip')) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_showFor(el) {
|
|
93
|
+
const own = this.hasAttribute('for');
|
|
94
|
+
this.show(el, own ? null : el.getAttribute('data-tooltip'), own ? this.getAttribute('shortcut') : el.getAttribute('data-tooltip-key'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setupEvents() {
|
|
98
|
+
this._onOver = (e) => {
|
|
99
|
+
const el = e.pointerType === 'touch' ? null : this._targetOf(e);
|
|
100
|
+
if (el === this._target) return;
|
|
101
|
+
this.hide();
|
|
102
|
+
if (!el) return;
|
|
103
|
+
this._target = el;
|
|
104
|
+
this._timer = setTimeout(() => this._showFor(el), this._delay);
|
|
105
|
+
};
|
|
106
|
+
this._onFocus = (e) => {
|
|
107
|
+
const el = this._targetOf(e);
|
|
108
|
+
if (!el || !deepActiveElement()?.matches(':focus-visible')) return;
|
|
109
|
+
this.hide();
|
|
110
|
+
this._target = el;
|
|
111
|
+
this._showFor(el);
|
|
112
|
+
};
|
|
113
|
+
this._onHide = () => this.hide();
|
|
114
|
+
this._onKey = (e) => { if (e.key === 'Escape' && this.open) this.hide(); };
|
|
115
|
+
document.addEventListener('pointerover', this._onOver);
|
|
116
|
+
document.addEventListener('focusin', this._onFocus);
|
|
117
|
+
document.addEventListener('focusout', this._onHide);
|
|
118
|
+
document.addEventListener('pointerdown', this._onHide, true);
|
|
119
|
+
document.addEventListener('scroll', this._onHide, true);
|
|
120
|
+
document.addEventListener('keydown', this._onKey);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
teardownEvents() {
|
|
124
|
+
document.removeEventListener('pointerover', this._onOver);
|
|
125
|
+
document.removeEventListener('focusin', this._onFocus);
|
|
126
|
+
document.removeEventListener('focusout', this._onHide);
|
|
127
|
+
document.removeEventListener('pointerdown', this._onHide, true);
|
|
128
|
+
document.removeEventListener('scroll', this._onHide, true);
|
|
129
|
+
document.removeEventListener('keydown', this._onKey);
|
|
130
|
+
this.hide();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onAttributeChanged(name) { if (name === 'shortcut' && !this._text.data) this._fill(null); if (name === 'for') this.hide(); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
customElements.define('nk-tooltip', NkTooltip);
|
|
137
|
+
|
|
138
|
+
export { NkTooltip };
|
|
@@ -9,7 +9,7 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
9
9
|
// row; clicking outside or pressing Escape closes it.
|
|
10
10
|
const menuSheet = new CSSStyleSheet();
|
|
11
11
|
menuSheet.replaceSync(`
|
|
12
|
-
.nk-ws-anchor { position: relative; }
|
|
12
|
+
.nk-ws-anchor { position: relative; flex: 1 1 auto; min-width: 0; }
|
|
13
13
|
.nk-ws-menu { display: none; position: absolute; left: 8px; top: calc(100% - 2px); z-index: 50; }
|
|
14
14
|
:host([open]) .nk-ws-menu { display: block; }
|
|
15
15
|
`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Calendar days for nk-calendar and nk-calendar-view: local days as
|
|
2
|
+
// 'YYYY-MM-DD', no library. Weeks are ISO weeks (Monday-based, the week with
|
|
3
|
+
// the year's first Thursday); the first weekday comes from the locale.
|
|
4
|
+
const pad = n => String(n).padStart(2, '0');
|
|
5
|
+
|
|
6
|
+
const isoOf = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
7
|
+
const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
8
|
+
|
|
9
|
+
/** 'YYYY-MM-DD[THH:MM]', 'D.M.YYYY', a Date or { start } → a local Date at midnight, or null. */
|
|
10
|
+
function parseDay(v) {
|
|
11
|
+
if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : new Date(v.getFullYear(), v.getMonth(), v.getDate());
|
|
12
|
+
if (v && typeof v === 'object') return v.start ? parseDay(v.start) : null;
|
|
13
|
+
const s = String(v ?? '').trim();
|
|
14
|
+
let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
15
|
+
if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
|
|
16
|
+
m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
|
17
|
+
return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isoWeek(d) {
|
|
21
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
22
|
+
t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));
|
|
23
|
+
return Math.ceil(((t - Date.UTC(t.getUTCFullYear(), 0, 1)) / 864e5 + 1) / 7);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The page's language unless the element names one. */
|
|
27
|
+
const resolveLocale = attr => attr || document.documentElement.lang || navigator.language || 'en';
|
|
28
|
+
|
|
29
|
+
/** 0 = Sunday … 6 = Saturday, from Intl.Locale's week info; Monday where the browser cannot say. */
|
|
30
|
+
function firstWeekday(locale) {
|
|
31
|
+
try {
|
|
32
|
+
const l = new Intl.Locale(locale), info = l.getWeekInfo?.() ?? l.weekInfo;
|
|
33
|
+
if (info?.firstDay) return info.firstDay % 7;
|
|
34
|
+
} catch { /* an unknown tag */ }
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Short weekday names from `weekStart` on; `length` cuts them (2 → "Mo"). */
|
|
39
|
+
function weekdayNames(locale, weekStart, length = 0) {
|
|
40
|
+
const f = new Intl.DateTimeFormat(locale, { weekday: 'short' });
|
|
41
|
+
// 7 June 2026 is a Sunday, so 7 + n is weekday n.
|
|
42
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
43
|
+
const name = f.format(new Date(2026, 5, 7 + ((weekStart + i) % 7))).replace('.', '');
|
|
44
|
+
return length ? name.slice(0, length) : name;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A month from its week start: `first`, `day(i)` for the i-th cell, the `rows` it needs. */
|
|
49
|
+
function monthGrid(month, weekStart) {
|
|
50
|
+
const [y, m] = month.split('-').map(Number), first = new Date(y, m - 1, 1), offset = (first.getDay() - weekStart + 7) % 7;
|
|
51
|
+
return { m, first, day: i => new Date(y, m - 1, 1 - offset + i), rows: Math.ceil((offset + new Date(y, m, 0).getDate()) / 7) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stepMonth(month, n) {
|
|
55
|
+
const [y, m] = month.split('-').map(Number);
|
|
56
|
+
return isoOf(new Date(y, m - 1 + n, 1)).slice(0, 7);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** "KW" for German, "W" elsewhere – the head of the week column. */
|
|
60
|
+
const weekLabel = locale => (/^de\b/i.test(locale) ? 'KW' : 'W');
|
|
61
|
+
|
|
62
|
+
const CHEVRONS = {
|
|
63
|
+
prev: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>',
|
|
64
|
+
next: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export { CHEVRONS as C, weekdayNames as a, isoWeek as b, addDays as c, firstWeekday as f, isoOf as i, monthGrid as m, parseDay as p, resolveLocale as r, stepMonth as s, weekLabel as w };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Placement for elements that float over the page – <nk-menu floating>,
|
|
2
|
+
// <nk-calendar floating>: the host is a fixed box put under its anchor.
|
|
3
|
+
|
|
4
|
+
/** The border box of an element, or of the first descendant with one (display: contents hosts). */
|
|
5
|
+
function boxOf(el) {
|
|
6
|
+
if (el.getClientRects().length) return el.getBoundingClientRect();
|
|
7
|
+
for (const child of [...(el.shadowRoot?.children ?? []), ...el.children]) {
|
|
8
|
+
const r = boxOf(child);
|
|
9
|
+
if (r.width || r.height) return r;
|
|
10
|
+
}
|
|
11
|
+
return el.getBoundingClientRect();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Puts `host` 6px below `target` – an element or a rect ({ left, top, right,
|
|
16
|
+
* bottom }, a DOMRect) – centred on it: above with side="top", or where the
|
|
17
|
+
* window ends below; kept 8px inside the window. For <nk-tooltip>.
|
|
18
|
+
*/
|
|
19
|
+
function placeNear(host, target, side = 'bottom', gap = 6) {
|
|
20
|
+
const r = target instanceof Element ? boxOf(target) : target;
|
|
21
|
+
const { width: w, height: h } = host.getBoundingClientRect();
|
|
22
|
+
const below = r.bottom + gap, above = r.top - gap - h;
|
|
23
|
+
const top = side === 'top' ? (above < 8 ? below : above) : (below + h > innerHeight - 8 ? above : below);
|
|
24
|
+
host.style.top = `${Math.round(top)}px`;
|
|
25
|
+
host.style.left = `${Math.round(Math.min(Math.max(8, (r.left + r.right) / 2 - w / 2), innerWidth - 8 - w))}px`;
|
|
26
|
+
host.style.right = '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Puts `host` 6px under `anchor`, right edges aligned – left edges with align="start". */
|
|
30
|
+
function placeUnder(host, anchor, align) {
|
|
31
|
+
const r = boxOf(anchor);
|
|
32
|
+
host.style.top = `${r.bottom + 6}px`;
|
|
33
|
+
if (align === 'start') { host.style.left = `${r.left}px`; host.style.right = ''; }
|
|
34
|
+
else { host.style.right = `${Math.max(8, innerWidth - r.right)}px`; host.style.left = ''; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { placeNear as a, placeUnder as p };
|