@jungherz-de/notionkit-elements 1.6.0 → 1.8.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 +15 -15
- package/dist/components/nk-bookmark.js +69 -0
- package/dist/components/nk-btn.js +29 -3
- package/dist/components/nk-cmdk.js +1 -1
- package/dist/components/nk-copy-field.js +88 -0
- package/dist/components/nk-database.js +14 -5
- package/dist/components/nk-filter-bar.js +57 -36
- package/dist/components/nk-image-picker.js +130 -0
- package/dist/components/nk-menu-item.js +10 -6
- package/dist/components/nk-menu.js +86 -1
- package/dist/components/nk-modal.js +1 -1
- package/dist/components/nk-peek.js +122 -0
- package/dist/components/nk-segmented.js +19 -0
- package/dist/components/nk-sheet.js +97 -0
- package/dist/components/nk-sidebar.js +15 -36
- package/dist/components/nk-steps.js +61 -0
- package/dist/components/shared/{focus-DXrK9oLe.js → focus-BNAChOXO.js} +12 -4
- package/dist/notionkit-elements.esm.js +786 -95
- package/dist/notionkit-elements.js +791 -94
- package/dist/notionkit-elements.min.js +1 -1
- package/package.json +6 -6
- package/src/components/content/nk-bookmark.js +67 -0
- package/src/components/data/nk-database.js +14 -5
- package/src/components/data/nk-filter-bar.js +57 -36
- package/src/components/forms/nk-btn.js +29 -3
- package/src/components/forms/nk-copy-field.js +86 -0
- package/src/components/forms/nk-image-picker.js +128 -0
- package/src/components/overlays/nk-menu-item.js +10 -6
- package/src/components/overlays/nk-menu.js +86 -1
- package/src/components/overlays/nk-peek.js +120 -0
- package/src/components/overlays/nk-sheet.js +95 -0
- package/src/components/page/nk-segmented.js +19 -0
- package/src/components/page/nk-steps.js +59 -0
- package/src/components/shell/nk-sidebar.js +15 -36
- package/src/index.js +10 -0
- package/src/util/focus.js +11 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { NkElement } from './base.js';
|
|
2
|
+
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
3
|
+
|
|
4
|
+
// <nk-image-picker initials="MK" label="Profile picture"></nk-image-picker>
|
|
5
|
+
// <nk-image-picker square initials="M" max="256" type="image/png"></nk-image-picker>
|
|
6
|
+
// → <div class="nk-profile-row"><div class="big-avatar">MK | <img></div>
|
|
7
|
+
// <div class="pr-actions"><button class="nk-btn secondary small">Upload image</button>
|
|
8
|
+
// <button class="nk-btn secondary small pr-remove">Remove</button><input type="file" hidden></div></div>
|
|
9
|
+
//
|
|
10
|
+
// A picture for a person or a workspace (NotionKit 1.8.0): the profile row
|
|
11
|
+
// with its actions, round – or `square`, a workspace icon. Ported from
|
|
12
|
+
// GlassKit Elements' glk-image-picker: the file goes through
|
|
13
|
+
// createImageBitmap with imageOrientation 'from-image' – the EXIF rotation
|
|
14
|
+
// of a phone photo without a library – onto a canvas no larger than `max`
|
|
15
|
+
// (default 512), out as `type` (default image/jpeg, on white; image/png
|
|
16
|
+
// keeps transparency) at `quality` (0.82). The file input is hidden and
|
|
17
|
+
// opened from a real button, and cleared after every pick, so the same file
|
|
18
|
+
// can be chosen again. Not form-associated: listen to nk-change and upload
|
|
19
|
+
// the data URL. `src` set as a property is not reflected, so megabytes of
|
|
20
|
+
// data URL never land in the DOM.
|
|
21
|
+
// Events: nk-change { dataUrl, width, height, size } after a pick, and on
|
|
22
|
+
// remove with an empty dataUrl; nk-error { message, name } when the
|
|
23
|
+
// file cannot be decoded (HEIC outside Safari, a broken file).
|
|
24
|
+
// Texts: choose-label, change-label, remove-label; label names the group.
|
|
25
|
+
|
|
26
|
+
function dataUrlBytes(url) {
|
|
27
|
+
const b64 = url.slice(url.indexOf(',') + 1);
|
|
28
|
+
const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
|
|
29
|
+
return Math.floor((b64.length * 3) / 4) - padding;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class NkImagePicker extends NkElement {
|
|
33
|
+
static get observedAttributes() { return ['src', 'initials', 'square', 'label', 'max', 'type', 'quality', 'accept', 'choose-label', 'change-label', 'remove-label']; }
|
|
34
|
+
|
|
35
|
+
render() {
|
|
36
|
+
this._src ??= this.getAttribute('src') || '';
|
|
37
|
+
this._row = this.createElement('div', ['nk-profile-row'], { role: 'group' });
|
|
38
|
+
this._avatar = this.createElement('div', ['big-avatar']);
|
|
39
|
+
const actions = this.createElement('div', ['pr-actions']);
|
|
40
|
+
this._choose = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button' });
|
|
41
|
+
this._remove = this.createElement('button', ['nk-btn', 'secondary', 'small', 'pr-remove'], { type: 'button' });
|
|
42
|
+
this._input = this.createElement('input', [], { type: 'file', tabindex: '-1' });
|
|
43
|
+
this._input.hidden = true;
|
|
44
|
+
actions.append(this._choose, this._remove, this._input);
|
|
45
|
+
this._row.append(this._avatar, actions);
|
|
46
|
+
this._wrapper.appendChild(this._row);
|
|
47
|
+
this._sync();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get _max() { return Math.max(1, Math.floor(Number(this.getAttribute('max'))) || 512); }
|
|
51
|
+
get _type() { return this.getAttribute('type') || 'image/jpeg'; }
|
|
52
|
+
get _quality() { const q = Number(this.getAttribute('quality')); return this.hasAttribute('quality') && q >= 0 && q <= 1 ? q : 0.82; }
|
|
53
|
+
|
|
54
|
+
_sync() {
|
|
55
|
+
if (!this._row) return;
|
|
56
|
+
this._avatar.classList.toggle('square', this.getBoolAttr('square'));
|
|
57
|
+
if (!this._src) {
|
|
58
|
+
this._img = null;
|
|
59
|
+
this._avatar.textContent = this.getAttribute('initials') || '';
|
|
60
|
+
} else if (!this._img || this._img.getAttribute('src') !== this._src) {
|
|
61
|
+
this._img = this.createElement('img', [], { src: this._src, alt: '' });
|
|
62
|
+
this._avatar.replaceChildren(this._img);
|
|
63
|
+
}
|
|
64
|
+
const label = this.getAttribute('label');
|
|
65
|
+
if (label) this._row.setAttribute('aria-label', label); else this._row.removeAttribute('aria-label');
|
|
66
|
+
this._choose.textContent = this._src ? (this.getAttribute('change-label') || 'Change image') : (this.getAttribute('choose-label') || 'Upload image');
|
|
67
|
+
this._remove.textContent = this.getAttribute('remove-label') || 'Remove';
|
|
68
|
+
this._remove.hidden = !this._src;
|
|
69
|
+
this._input.accept = this.getAttribute('accept') || 'image/*';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _pick(file) {
|
|
73
|
+
if (!file) return;
|
|
74
|
+
try {
|
|
75
|
+
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
|
76
|
+
const scale = Math.min(1, this._max / Math.max(bitmap.width, bitmap.height));
|
|
77
|
+
const canvas = document.createElement('canvas');
|
|
78
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
79
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
80
|
+
const ctx = canvas.getContext('2d');
|
|
81
|
+
if (this._type === 'image/jpeg') {
|
|
82
|
+
// JPEG has no alpha; without this a transparent PNG comes out on black.
|
|
83
|
+
ctx.fillStyle = '#fff';
|
|
84
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
85
|
+
}
|
|
86
|
+
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
87
|
+
bitmap.close();
|
|
88
|
+
const dataUrl = canvas.toDataURL(this._type, this._quality);
|
|
89
|
+
this.src = dataUrl;
|
|
90
|
+
this.emit('nk-change', { dataUrl, width: canvas.width, height: canvas.height, size: dataUrlBytes(dataUrl) });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.emit('nk-error', { message: error?.message || String(error), name: file.name });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setupEvents() {
|
|
97
|
+
this._onChoose = () => this._input.click();
|
|
98
|
+
this._onChange = (e) => { const file = e.target.files?.[0]; e.target.value = ''; this._pick(file); };
|
|
99
|
+
this._onRemove = () => {
|
|
100
|
+
this.src = '';
|
|
101
|
+
this._choose.focus(); // the remove button just went away under the focus
|
|
102
|
+
this.emit('nk-change', { dataUrl: '', width: 0, height: 0, size: 0 });
|
|
103
|
+
};
|
|
104
|
+
this._choose.addEventListener('click', this._onChoose);
|
|
105
|
+
this._input.addEventListener('change', this._onChange);
|
|
106
|
+
this._remove.addEventListener('click', this._onRemove);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
teardownEvents() {
|
|
110
|
+
this._choose?.removeEventListener('click', this._onChoose);
|
|
111
|
+
this._input?.removeEventListener('change', this._onChange);
|
|
112
|
+
this._remove?.removeEventListener('click', this._onRemove);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onAttributeChanged(name, _old, value) {
|
|
116
|
+
if (name === 'src') this._src = value || '';
|
|
117
|
+
this._sync();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Opens the file dialog, as the button does. */
|
|
121
|
+
choose() { this._input?.click(); }
|
|
122
|
+
get src() { return this._src ?? this.getAttribute('src') ?? ''; }
|
|
123
|
+
set src(v) { this._src = v || ''; this._sync(); }
|
|
124
|
+
get square() { return this.getBoolAttr('square'); }
|
|
125
|
+
set square(v) { this.setBoolAttr('square', v); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
customElements.define('nk-image-picker', NkImagePicker);
|
|
129
|
+
|
|
130
|
+
export { NkImagePicker };
|
|
@@ -5,10 +5,12 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
5
5
|
// <nk-menu-item type="separator"></nk-menu-item> <nk-menu-item type="label">Danger</nk-menu-item>
|
|
6
6
|
// <nk-menu-item icon="🗑️" danger value="delete">Delete</nk-menu-item>
|
|
7
7
|
// <nk-menu-item type="switch" icon="🔡" value="small" checked>Small text</nk-menu-item>
|
|
8
|
+
// <nk-menu-item type="check" icon="◉" value="open" checked>Status: Open</nk-menu-item>
|
|
8
9
|
// → <div class="nk-menu-item danger"><span class="m-icon">🗑️</span>Delete<span class="m-shortcut">…</span></div>
|
|
9
10
|
// A switch item carries a .nk-switch on the right, as "Small text" does in
|
|
10
|
-
// Notion's page menu
|
|
11
|
-
//
|
|
11
|
+
// Notion's page menu; a check item a ✓ where the shortcut stands, as in a
|
|
12
|
+
// filter menu. A click on either flips `checked` and fires nk-change
|
|
13
|
+
// { value, checked } instead of nk-select, so the menu around it stays open.
|
|
12
14
|
class NkMenuItem extends NkElement {
|
|
13
15
|
static get observedAttributes() { return ['icon', 'shortcut', 'danger', 'value', 'type', 'disabled', 'checked']; }
|
|
14
16
|
|
|
@@ -24,7 +26,8 @@ class NkMenuItem extends NkElement {
|
|
|
24
26
|
el.appendChild(document.createElement('slot'));
|
|
25
27
|
} else {
|
|
26
28
|
const isSwitch = type === 'switch';
|
|
27
|
-
|
|
29
|
+
this._check = type === 'check';
|
|
30
|
+
el = this.createElement('div', ['nk-menu-item'], { role: isSwitch || this._check ? 'menuitemcheckbox' : 'menuitem', tabindex: '-1' });
|
|
28
31
|
this._icon = this.createElement('span', ['m-icon']);
|
|
29
32
|
const iconSlot = this.createElement('slot', [], { name: 'icon' });
|
|
30
33
|
iconSlot.appendChild(this._icon);
|
|
@@ -49,10 +52,11 @@ class NkMenuItem extends NkElement {
|
|
|
49
52
|
this._shortcut.style.display = sc ? '' : 'none';
|
|
50
53
|
this._el.classList.toggle('danger', this.getBoolAttr('danger'));
|
|
51
54
|
this._el.setAttribute('aria-disabled', this.getBoolAttr('disabled') ? 'true' : 'false');
|
|
52
|
-
if (this._switch) {
|
|
55
|
+
if (this._switch || this._check) {
|
|
53
56
|
const on = this.getBoolAttr('checked') ? 'true' : 'false';
|
|
54
57
|
this._el.setAttribute('aria-checked', on);
|
|
55
|
-
this._switch.setAttribute('aria-checked', on);
|
|
58
|
+
if (this._switch) this._switch.setAttribute('aria-checked', on);
|
|
59
|
+
if (this._check) { this._shortcut.textContent = on === 'true' ? '✓' : ''; this._shortcut.style.display = ''; }
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -65,7 +69,7 @@ class NkMenuItem extends NkElement {
|
|
|
65
69
|
|
|
66
70
|
select() {
|
|
67
71
|
if (!this._el.classList.contains('nk-menu-item') || this.getBoolAttr('disabled')) return;
|
|
68
|
-
if (this._switch) {
|
|
72
|
+
if (this._switch || this._check) {
|
|
69
73
|
this.checked = !this.checked;
|
|
70
74
|
this.emit('nk-change', { value: this.value, checked: this.checked, item: this });
|
|
71
75
|
return;
|
|
@@ -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-menu>
|
|
@@ -9,13 +10,79 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
9
10
|
// </nk-menu>
|
|
10
11
|
// → <div class="nk-pop nk-menu" role="menu">…</div>
|
|
11
12
|
// ↑↓ move between items, Enter selects, nk-select bubbles from the item.
|
|
13
|
+
//
|
|
14
|
+
// <nk-menu floating sheet> is a menu over the page (NotionKit 1.7.0): it
|
|
15
|
+
// fades in like the palette when `open` is set and is a bottom sheet on a
|
|
16
|
+
// phone. The host becomes the fixed box you position – show(anchor) puts it
|
|
17
|
+
// under the anchor, right edges aligned (align="start": left edges). A tap
|
|
18
|
+
// outside closes it and reaches nothing else; Escape and a chosen item
|
|
19
|
+
// close it too, a switch row does not. Opened from the keyboard, focus moves
|
|
20
|
+
// to the first item; closing returns it to where it was.
|
|
21
|
+
const floatSheet = new CSSStyleSheet();
|
|
22
|
+
floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
|
|
23
|
+
|
|
24
|
+
/** The border box of an element, or of the first descendant with one (display: contents hosts). */
|
|
25
|
+
function boxOf(el) {
|
|
26
|
+
if (el.getClientRects().length) return el.getBoundingClientRect();
|
|
27
|
+
for (const child of [...(el.shadowRoot?.children ?? []), ...el.children]) {
|
|
28
|
+
const r = boxOf(child);
|
|
29
|
+
if (r.width || r.height) return r;
|
|
30
|
+
}
|
|
31
|
+
return el.getBoundingClientRect();
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
class NkMenu extends NkElement {
|
|
35
|
+
static get hostStyles() { return floatSheet; }
|
|
36
|
+
static get observedAttributes() { return ['floating', 'sheet', 'open', 'align']; }
|
|
37
|
+
|
|
13
38
|
render() {
|
|
14
39
|
this._box = this.createElement('div', ['nk-pop', 'nk-menu'], { role: 'menu' });
|
|
15
40
|
this._box.appendChild(document.createElement('slot'));
|
|
16
41
|
this._wrapper.appendChild(this._box);
|
|
42
|
+
this._syncMode();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_syncMode() {
|
|
46
|
+
this._box.classList.toggle('floating', this.getBoolAttr('floating'));
|
|
47
|
+
this._box.classList.toggle('sheet', this.getBoolAttr('sheet'));
|
|
48
|
+
this._box.classList.toggle('open', this.getBoolAttr('open'));
|
|
17
49
|
}
|
|
18
50
|
|
|
51
|
+
onAttributeChanged(name) {
|
|
52
|
+
this._syncMode();
|
|
53
|
+
if (name !== 'open') return;
|
|
54
|
+
const open = this.getBoolAttr('open');
|
|
55
|
+
// aria-expanded belongs on the control itself, inside a host such as <nk-btn>.
|
|
56
|
+
if (this._anchor) (this._anchor.shadowRoot?.querySelector('button, a[href]') ?? this._anchor).setAttribute('aria-expanded', String(open));
|
|
57
|
+
if (open) {
|
|
58
|
+
this._returnFocus = deepActiveElement();
|
|
59
|
+
if (this._returnFocus?.matches(':focus-visible')) requestAnimationFrame(() => this.focusFirst());
|
|
60
|
+
document.addEventListener('click', this._onOutside, true);
|
|
61
|
+
} else {
|
|
62
|
+
document.removeEventListener('click', this._onOutside, true);
|
|
63
|
+
const back = this._returnFocus;
|
|
64
|
+
this._returnFocus = null;
|
|
65
|
+
if (back?.isConnected && this.contains(document.activeElement)) back.focus({ preventScroll: true });
|
|
66
|
+
}
|
|
67
|
+
this.emit('nk-toggle', { open });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Opens under `anchor` (any element, display: contents hosts included). */
|
|
71
|
+
show(anchor) {
|
|
72
|
+
if (anchor) {
|
|
73
|
+
this._anchor = anchor;
|
|
74
|
+
const r = boxOf(anchor);
|
|
75
|
+
this.style.top = `${r.bottom + 6}px`;
|
|
76
|
+
if (this.getAttribute('align') === 'start') { this.style.left = `${r.left}px`; this.style.right = ''; }
|
|
77
|
+
else { this.style.right = `${Math.max(8, innerWidth - r.right)}px`; this.style.left = ''; }
|
|
78
|
+
}
|
|
79
|
+
this.setBoolAttr('open', true);
|
|
80
|
+
}
|
|
81
|
+
close() { this.setBoolAttr('open', false); }
|
|
82
|
+
toggle(anchor) { this.getBoolAttr('open') ? this.close() : this.show(anchor); }
|
|
83
|
+
get open() { return this.getBoolAttr('open'); }
|
|
84
|
+
set open(v) { this.setBoolAttr('open', v); }
|
|
85
|
+
|
|
19
86
|
get items() { return [...this.querySelectorAll(':scope > nk-menu-item')].filter(i => i.selectable); }
|
|
20
87
|
|
|
21
88
|
setupEvents() {
|
|
@@ -33,9 +100,27 @@ class NkMenu extends NkElement {
|
|
|
33
100
|
}
|
|
34
101
|
};
|
|
35
102
|
this.addEventListener('keydown', this._onKey);
|
|
103
|
+
// Floating: outside taps close and go nowhere else; Escape and a chosen item close.
|
|
104
|
+
this._onOutside = (e) => {
|
|
105
|
+
const path = e.composedPath();
|
|
106
|
+
if (path.includes(this) || (this._anchor && path.includes(this._anchor))) return;
|
|
107
|
+
e.preventDefault(); e.stopPropagation();
|
|
108
|
+
this.close();
|
|
109
|
+
};
|
|
110
|
+
// Captured, so a menu inside a modal or sheet closes before they do.
|
|
111
|
+
this._onEscape = (e) => { if (e.key === 'Escape' && this.getBoolAttr('floating') && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
|
|
112
|
+
this._onSelect = () => { if (this.getBoolAttr('floating')) this.close(); };
|
|
113
|
+
document.addEventListener('keydown', this._onEscape, true);
|
|
114
|
+
this.addEventListener('nk-select', this._onSelect);
|
|
115
|
+
if (this.getBoolAttr('open')) document.addEventListener('click', this._onOutside, true);
|
|
36
116
|
}
|
|
37
117
|
|
|
38
|
-
teardownEvents() {
|
|
118
|
+
teardownEvents() {
|
|
119
|
+
this.removeEventListener('keydown', this._onKey);
|
|
120
|
+
document.removeEventListener('keydown', this._onEscape, true);
|
|
121
|
+
this.removeEventListener('nk-select', this._onSelect);
|
|
122
|
+
document.removeEventListener('click', this._onOutside, true);
|
|
123
|
+
}
|
|
39
124
|
|
|
40
125
|
/** Focuses the first item (called by a popover when it opens). */
|
|
41
126
|
focusFirst() { this.items[0]?.focus(); }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NkElement } from './base.js';
|
|
2
|
-
import { l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-
|
|
2
|
+
import { l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-BNAChOXO.js';
|
|
3
3
|
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
4
4
|
|
|
5
5
|
// <nk-modal id="settings">
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NkElement } from './base.js';
|
|
2
|
+
import { d as deepActiveElement, l as lockScroll, i as inertOutside, u as unlockScroll } from './shared/focus-BNAChOXO.js';
|
|
3
|
+
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
4
|
+
|
|
5
|
+
// <nk-peek id="peek" label="Database Table-View">
|
|
6
|
+
// <nk-btn slot="actions" variant="topbar" aria-label="Open as page"><svg …></svg></nk-btn>
|
|
7
|
+
// <nk-page-title>🗃️ Database Table-View</nk-page-title>
|
|
8
|
+
// <nk-props>…</nk-props>
|
|
9
|
+
// <div class="nk-prose">…</div>
|
|
10
|
+
// <nk-comments>…</nk-comments>
|
|
11
|
+
// </nk-peek>
|
|
12
|
+
// → <div class="nk-peek-backdrop open"><aside class="nk-peek" role="dialog">
|
|
13
|
+
// <div class="pk-bar"><button class="nk-topbar-btn">»</button>…actions…</div>
|
|
14
|
+
// <div class="pk-body">…</div></aside></div>
|
|
15
|
+
//
|
|
16
|
+
// Notion's side peek (NotionKit 1.8.0): a row beside the table, which stays
|
|
17
|
+
// usable – no scrim, nothing inert. show() slides it in and moves focus to
|
|
18
|
+
// it; », Escape and a click elsewhere on the page close it, and Escape and »
|
|
19
|
+
// hand focus back. A click that calls show() again – on another row – only
|
|
20
|
+
// swaps the content, and clicks inside other overlays (a menu opened from
|
|
21
|
+
// the peek) leave it open. Below 860px it is a bottom sheet over a dimmed
|
|
22
|
+
// page; there it is modal: the page is inert and scroll-locked, the dimmed
|
|
23
|
+
// page takes the tap that closes it. Put it directly under <body>.
|
|
24
|
+
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'];
|
|
27
|
+
const phone = () => matchMedia('(max-width: 860px)').matches;
|
|
28
|
+
|
|
29
|
+
class NkPeek extends NkElement {
|
|
30
|
+
static get observedAttributes() { return ['open', 'label', 'close-label']; }
|
|
31
|
+
|
|
32
|
+
render() {
|
|
33
|
+
this._backdrop = this.createElement('div', ['nk-peek-backdrop']);
|
|
34
|
+
this._box = this.createElement('aside', ['nk-peek'], { role: 'dialog', tabindex: '-1' });
|
|
35
|
+
const bar = this.createElement('div', ['pk-bar']);
|
|
36
|
+
this._close = this.createElement('button', ['nk-topbar-btn'], { type: 'button' });
|
|
37
|
+
this._close.innerHTML = CLOSE_ICON;
|
|
38
|
+
bar.append(this._close, this.createElement('slot', [], { name: 'actions' }));
|
|
39
|
+
const body = this.createElement('div', ['pk-body']);
|
|
40
|
+
body.appendChild(document.createElement('slot'));
|
|
41
|
+
this._box.append(bar, body);
|
|
42
|
+
this._backdrop.appendChild(this._box);
|
|
43
|
+
this._wrapper.appendChild(this._backdrop);
|
|
44
|
+
this._syncLabels();
|
|
45
|
+
this._syncOpen();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_syncLabels() {
|
|
49
|
+
const label = this.getAttribute('label');
|
|
50
|
+
if (label) this._box.setAttribute('aria-label', label); else this._box.removeAttribute('aria-label');
|
|
51
|
+
this._close.setAttribute('aria-label', this.getAttribute('close-label') || 'Close');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_syncOpen() {
|
|
55
|
+
const open = this.getBoolAttr('open');
|
|
56
|
+
this._backdrop.classList.toggle('open', open);
|
|
57
|
+
if (open === this._wasOpen) return;
|
|
58
|
+
this._wasOpen = open;
|
|
59
|
+
if (open) {
|
|
60
|
+
this._returnFocus = deepActiveElement();
|
|
61
|
+
if (phone()) {
|
|
62
|
+
this._modal = true;
|
|
63
|
+
this._box.setAttribute('aria-modal', 'true');
|
|
64
|
+
lockScroll();
|
|
65
|
+
this._undoInert = inertOutside(this);
|
|
66
|
+
}
|
|
67
|
+
requestAnimationFrame(() => this._box.focus({ preventScroll: true }));
|
|
68
|
+
} else {
|
|
69
|
+
if (this._modal) { this._modal = false; this._box.removeAttribute('aria-modal'); unlockScroll(); this._undoInert?.(); this._undoInert = null; }
|
|
70
|
+
const active = deepActiveElement();
|
|
71
|
+
const inside = active && (this.contains(active) || this._shadow.contains(active) || active === document.body);
|
|
72
|
+
const back = this._returnFocus;
|
|
73
|
+
this._returnFocus = null;
|
|
74
|
+
if (inside && back?.isConnected && typeof back.focus === 'function') back.focus({ preventScroll: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setupEvents() {
|
|
79
|
+
this._onClose = () => this.close();
|
|
80
|
+
this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
|
|
81
|
+
this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
|
|
82
|
+
// Bubble phase, after the page's own handlers: a click that showed the
|
|
83
|
+
// peek again (another row) must not close it.
|
|
84
|
+
this._onOutside = (e) => {
|
|
85
|
+
if (!this.getBoolAttr('open') || this._shownAt >= e.timeStamp) return;
|
|
86
|
+
const path = e.composedPath();
|
|
87
|
+
if (path.includes(this)) return;
|
|
88
|
+
if (path.some(n => n.nodeType === 1 && (OVERLAYS.has(n.localName) || OVERLAY_CLASSES.some(c => n.classList.contains(c))))) return;
|
|
89
|
+
this.close();
|
|
90
|
+
};
|
|
91
|
+
this._close.addEventListener('click', this._onClose);
|
|
92
|
+
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
93
|
+
document.addEventListener('keydown', this._onKey);
|
|
94
|
+
document.addEventListener('click', this._onOutside);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
teardownEvents() {
|
|
98
|
+
this._close?.removeEventListener('click', this._onClose);
|
|
99
|
+
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
100
|
+
document.removeEventListener('keydown', this._onKey);
|
|
101
|
+
document.removeEventListener('click', this._onOutside);
|
|
102
|
+
if (this._modal) { this._modal = false; unlockScroll(); this._undoInert?.(); this._undoInert = null; }
|
|
103
|
+
this._wasOpen = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
onAttributeChanged(name) {
|
|
107
|
+
if (name !== 'open') { this._syncLabels(); return; }
|
|
108
|
+
this._syncOpen();
|
|
109
|
+
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
show() { this._shownAt = performance.now(); this.setBoolAttr('open', true); }
|
|
113
|
+
close() { this.setBoolAttr('open', false); }
|
|
114
|
+
toggle() { this.getBoolAttr('open') ? this.close() : this.show(); }
|
|
115
|
+
|
|
116
|
+
get open() { return this.getBoolAttr('open'); }
|
|
117
|
+
set open(v) { v ? this.show() : this.close(); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
customElements.define('nk-peek', NkPeek);
|
|
121
|
+
|
|
122
|
+
export { NkPeek };
|
|
@@ -7,6 +7,9 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
7
7
|
// → <div class="nk-segmented">…</div> with .active on the chosen button.
|
|
8
8
|
// The buttons stay in the light DOM (their look comes from the ::slotted
|
|
9
9
|
// twins); the element sets type/role, moves .active and submits the value.
|
|
10
|
+
// With `scroll` the chosen option is kept in view – after the first layout,
|
|
11
|
+
// on every change and when the row's width changes – by scrolling the row
|
|
12
|
+
// alone, never the page, by a relative distance, so right-to-left works too.
|
|
10
13
|
const KEYS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
11
14
|
|
|
12
15
|
class NkSegmented extends NkFormElement {
|
|
@@ -50,6 +53,19 @@ class NkSegmented extends NkFormElement {
|
|
|
50
53
|
}
|
|
51
54
|
this._value = value;
|
|
52
55
|
this.setFormValue(value);
|
|
56
|
+
this._reveal(this._revealed ? 'smooth' : 'instant');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Scrolls the row the least distance that shows the chosen button whole. */
|
|
60
|
+
_reveal(behavior) {
|
|
61
|
+
if (!this.getBoolAttr('scroll') || !this._box?.clientWidth) return;
|
|
62
|
+
const btn = this.buttons.find(b => b.classList.contains('active'));
|
|
63
|
+
if (!btn) return;
|
|
64
|
+
const row = this._box.getBoundingClientRect(), r = btn.getBoundingClientRect(), pad = 8;
|
|
65
|
+
const delta = r.left < row.left ? r.left - row.left - pad : r.right > row.right ? r.right - row.right + pad : 0;
|
|
66
|
+
const smooth = behavior === 'smooth' && !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
67
|
+
if (delta) this._box.scrollBy({ left: delta, behavior: smooth ? 'smooth' : 'instant' });
|
|
68
|
+
this._revealed = true;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
setupEvents() {
|
|
@@ -73,6 +89,8 @@ class NkSegmented extends NkFormElement {
|
|
|
73
89
|
this._slot.addEventListener('slotchange', this._onSlot);
|
|
74
90
|
this.addEventListener('click', this._onClick);
|
|
75
91
|
this.addEventListener('keydown', this._onKey);
|
|
92
|
+
this._resize = new ResizeObserver(() => this._reveal('instant'));
|
|
93
|
+
this._resize.observe(this._box);
|
|
76
94
|
this._sync();
|
|
77
95
|
}
|
|
78
96
|
|
|
@@ -80,6 +98,7 @@ class NkSegmented extends NkFormElement {
|
|
|
80
98
|
this._slot?.removeEventListener('slotchange', this._onSlot);
|
|
81
99
|
this.removeEventListener('click', this._onClick);
|
|
82
100
|
this.removeEventListener('keydown', this._onKey);
|
|
101
|
+
this._resize?.disconnect();
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
_choose(value) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { NkElement } from './base.js';
|
|
2
|
+
import { d as deepActiveElement, l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-BNAChOXO.js';
|
|
3
|
+
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
4
|
+
|
|
5
|
+
// <nk-sheet id="more" title="More">
|
|
6
|
+
// <nk-tree manual>
|
|
7
|
+
// <nk-section-label>Favourites</nk-section-label>
|
|
8
|
+
// <nk-tree-item icon="🚀">NotionKit MVP</nk-tree-item>
|
|
9
|
+
// </nk-tree>
|
|
10
|
+
// </nk-sheet>
|
|
11
|
+
// → <div class="nk-sheet-backdrop open"><div class="nk-sheet" role="dialog">
|
|
12
|
+
// <div class="sh-grabber"></div><div class="sh-title">More</div>…</div></div>
|
|
13
|
+
//
|
|
14
|
+
// A bottom sheet (NotionKit 1.7.0) with nk-modal's contract: show(), close()
|
|
15
|
+
// and toggle(), Escape and the backdrop close it, focus moves in and back
|
|
16
|
+
// out, the page behind is scroll-locked and inert. It lies above the tab
|
|
17
|
+
// bar; rows inside are 40px high. `title` is the heading shown under the
|
|
18
|
+
// grabber and the dialog's name, never a tooltip – see NkElement.takeTitle().
|
|
19
|
+
// Choosing a row does not close the sheet – the app decides (nk-select
|
|
20
|
+
// bubbles out). Put the element directly under <body>: a transformed
|
|
21
|
+
// ancestor would trap the fixed backdrop.
|
|
22
|
+
class NkSheet extends NkElement {
|
|
23
|
+
static get observedAttributes() { return ['open', 'title']; }
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
this.takeTitle();
|
|
27
|
+
this._backdrop = this.createElement('div', ['nk-sheet-backdrop']);
|
|
28
|
+
this._backdrop.inert = true;
|
|
29
|
+
this._box = this.createElement('div', ['nk-sheet'], { role: 'dialog', 'aria-modal': 'true', tabindex: '-1' });
|
|
30
|
+
this._heading = this.createElement('div', ['sh-title'], { id: 'title' });
|
|
31
|
+
this._box.append(this.createElement('div', ['sh-grabber']), this._heading, document.createElement('slot'));
|
|
32
|
+
this._backdrop.appendChild(this._box);
|
|
33
|
+
this._wrapper.appendChild(this._backdrop);
|
|
34
|
+
this._syncTitle();
|
|
35
|
+
this._syncOpen();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_syncTitle() {
|
|
39
|
+
const text = this._titleText || '';
|
|
40
|
+
this._heading.textContent = text;
|
|
41
|
+
this._heading.hidden = !text;
|
|
42
|
+
if (text) this._box.setAttribute('aria-labelledby', 'title');
|
|
43
|
+
else this._box.removeAttribute('aria-labelledby');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_syncOpen() {
|
|
47
|
+
const open = this.getBoolAttr('open');
|
|
48
|
+
this._backdrop.classList.toggle('open', open);
|
|
49
|
+
this._backdrop.inert = !open;
|
|
50
|
+
if (open === this._wasOpen) return;
|
|
51
|
+
this._wasOpen = open;
|
|
52
|
+
if (open) {
|
|
53
|
+
this._returnFocus = deepActiveElement();
|
|
54
|
+
lockScroll();
|
|
55
|
+
this._undoInert = inertOutside(this);
|
|
56
|
+
requestAnimationFrame(() => (firstFocusable(this) || this._box).focus({ preventScroll: true }));
|
|
57
|
+
} else {
|
|
58
|
+
unlockScroll();
|
|
59
|
+
this._undoInert?.(); this._undoInert = null;
|
|
60
|
+
const back = this._returnFocus;
|
|
61
|
+
if (back && typeof back.focus === 'function' && back.isConnected) back.focus({ preventScroll: true });
|
|
62
|
+
this._returnFocus = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setupEvents() {
|
|
67
|
+
this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
|
|
68
|
+
this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
|
|
69
|
+
this._backdrop.addEventListener('click', this._onBackdrop);
|
|
70
|
+
document.addEventListener('keydown', this._onKey);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
teardownEvents() {
|
|
74
|
+
this._backdrop?.removeEventListener('click', this._onBackdrop);
|
|
75
|
+
document.removeEventListener('keydown', this._onKey);
|
|
76
|
+
if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onAttributeChanged(name, oldValue, value) {
|
|
80
|
+
if (name === 'title') { if (this.takeTitle(value)) this._syncTitle(); return; }
|
|
81
|
+
this._syncOpen();
|
|
82
|
+
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
show() { this.setBoolAttr('open', true); }
|
|
86
|
+
close() { this.setBoolAttr('open', false); }
|
|
87
|
+
toggle() { this.setBoolAttr('open', !this.getBoolAttr('open')); }
|
|
88
|
+
|
|
89
|
+
get open() { return this.getBoolAttr('open'); }
|
|
90
|
+
set open(v) { this.setBoolAttr('open', v); }
|
|
91
|
+
get title() { return this._titleText ?? ''; }
|
|
92
|
+
set title(v) { this._titleText = v == null ? '' : String(v); if (this._initialized) this._syncTitle(); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
customElements.define('nk-sheet', NkSheet);
|
|
96
|
+
|
|
97
|
+
export { NkSheet };
|
|
@@ -11,42 +11,12 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
|
11
11
|
//
|
|
12
12
|
// The host is display:contents so the <aside> is a direct flex child of
|
|
13
13
|
// .nk-app, exactly like the class markup. Below 860px notionkit.css hides the
|
|
14
|
-
// aside; `open` brings it back as
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// value is held for the whole duration, so closing animates too) and
|
|
20
|
-
// @starting-style gives the first frame after display: none its start
|
|
21
|
-
// values. `position: fixed` lives on the plain .nk-sidebar, not on the open
|
|
22
|
-
// state – while closing, the aside is still displayed and must not fall
|
|
23
|
-
// back into the flow. Browsers without either feature switch hard, as
|
|
24
|
-
// before; prefers-reduced-motion switches hard on purpose.
|
|
25
|
-
const drawerSheet = new CSSStyleSheet();
|
|
26
|
-
drawerSheet.replaceSync(`
|
|
27
|
-
.nk-sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 59; background: var(--nk-scrim-soft); }
|
|
28
|
-
@media (max-width: 860px) {
|
|
29
|
-
.nk-sidebar {
|
|
30
|
-
position: fixed; inset: 0 auto 0 0; z-index: 60;
|
|
31
|
-
transform: translateX(-100%);
|
|
32
|
-
transition: background .25s ease, transform .24s cubic-bezier(.2, .8, .25, 1), display .24s;
|
|
33
|
-
transition-behavior: allow-discrete;
|
|
34
|
-
}
|
|
35
|
-
:host([open]) .nk-sidebar { display: flex; transform: none; }
|
|
36
|
-
@starting-style { :host([open]) .nk-sidebar { transform: translateX(-100%); } }
|
|
37
|
-
.nk-sidebar-backdrop {
|
|
38
|
-
opacity: 0;
|
|
39
|
-
transition: opacity .24s ease, display .24s;
|
|
40
|
-
transition-behavior: allow-discrete;
|
|
41
|
-
}
|
|
42
|
-
:host([open]) .nk-sidebar-backdrop { display: block; opacity: 1; }
|
|
43
|
-
@starting-style { :host([open]) .nk-sidebar-backdrop { opacity: 0; } }
|
|
44
|
-
}
|
|
45
|
-
@media (prefers-reduced-motion: reduce) { .nk-sidebar, .nk-sidebar-backdrop { transition: none; } }
|
|
46
|
-
`);
|
|
47
|
-
|
|
14
|
+
// aside; `open` brings it back as a drawer over the page. The drawer is the
|
|
15
|
+
// stylesheet's since NotionKit 1.7.0 (.nk-sidebar.open, .nk-sidebar-backdrop
|
|
16
|
+
// .open) – the sidebar slides and the scrim fades by the same rules as the
|
|
17
|
+
// class markup; the element toggles the two classes. <nk-btn variant="sidebar">
|
|
18
|
+
// in the topbar is the ☰ that opens it.
|
|
48
19
|
class NkSidebar extends NkElement {
|
|
49
|
-
static get hostStyles() { return drawerSheet; }
|
|
50
20
|
static get observedAttributes() { return ['open']; }
|
|
51
21
|
|
|
52
22
|
render() {
|
|
@@ -63,6 +33,13 @@ class NkSidebar extends NkElement {
|
|
|
63
33
|
this._wrapper.appendChild(this._backdrop);
|
|
64
34
|
this._wrapper.appendChild(this._aside);
|
|
65
35
|
this._syncFooter();
|
|
36
|
+
this._syncOpen();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_syncOpen() {
|
|
40
|
+
const open = this.getBoolAttr('open');
|
|
41
|
+
this._aside.classList.toggle('open', open);
|
|
42
|
+
this._backdrop.classList.toggle('open', open);
|
|
66
43
|
}
|
|
67
44
|
|
|
68
45
|
_syncFooter() {
|
|
@@ -89,7 +66,9 @@ class NkSidebar extends NkElement {
|
|
|
89
66
|
}
|
|
90
67
|
|
|
91
68
|
onAttributeChanged(name) {
|
|
92
|
-
if (name
|
|
69
|
+
if (name !== 'open') return;
|
|
70
|
+
this._syncOpen();
|
|
71
|
+
this.emit('nk-toggle', { open: this.getBoolAttr('open') });
|
|
93
72
|
}
|
|
94
73
|
|
|
95
74
|
show() { this.setBoolAttr('open', true); }
|