@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jungherz-de/notionkit-elements",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Vanilla JavaScript Web Components wrapping the NotionKit CSS foundation – <nk-*> custom elements with Shadow DOM, automatic theme sync and native form participation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jungherz GmbH <info@jungherz.com> (https://www.jungherz.com)",
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"dark-mode"
|
|
60
60
|
],
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"@jungherz-de/notionkit": ">=1.
|
|
62
|
+
"@jungherz-de/notionkit": ">=1.8.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@jungherz-de/notionkit": "^1.
|
|
65
|
+
"@jungherz-de/notionkit": "^1.8.0",
|
|
66
|
+
"@playwright/test": "^1.50.0",
|
|
66
67
|
"@rollup/plugin-node-resolve": "^15.0.0",
|
|
67
68
|
"@rollup/plugin-terser": "^0.4.0",
|
|
68
|
-
"
|
|
69
|
-
"@playwright/test": "^1.50.0",
|
|
69
|
+
"pixelmatch": "^7.0.0",
|
|
70
70
|
"pngjs": "^7.0.0",
|
|
71
|
-
"
|
|
71
|
+
"rollup": "^4.0.0"
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NkElement } from '../../base.js';
|
|
2
|
+
|
|
3
|
+
// <nk-bookmark href="https://notionkit.jungherz.com" title="NotionKit" desc="…"
|
|
4
|
+
// favicon="favicon.svg" cover="og.jpg"></nk-bookmark>
|
|
5
|
+
// → <a class="nk-bookmark" href="…" target="_blank" rel="noopener">
|
|
6
|
+
// <span class="bm-text"><span class="bm-title">…</span><span class="bm-desc">…</span>
|
|
7
|
+
// <span class="bm-url"><img class="bm-favicon"><span>…</span></span></span>
|
|
8
|
+
// <span class="bm-cover"><img></span></a>
|
|
9
|
+
// Notion's link block (NotionKit 1.8.0), filled from what you know of the
|
|
10
|
+
// page: og:title, og:description, og:image. `title` is the heading, never a
|
|
11
|
+
// tooltip – see NkElement.takeTitle(); without it the host name stands in.
|
|
12
|
+
// The address shown is `href`, or `url` where it should read differently. A
|
|
13
|
+
// part without a value is left out, not hidden – the stylesheet gives the
|
|
14
|
+
// description its own display. Opens in a new tab unless `target` says so.
|
|
15
|
+
class NkBookmark extends NkElement {
|
|
16
|
+
static get observedAttributes() { return ['href', 'title', 'desc', 'favicon', 'cover', 'target', 'url']; }
|
|
17
|
+
|
|
18
|
+
render() {
|
|
19
|
+
this.takeTitle();
|
|
20
|
+
this._a = this.createElement('a', ['nk-bookmark']);
|
|
21
|
+
this._wrapper.appendChild(this._a);
|
|
22
|
+
this._sync();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_sync() {
|
|
26
|
+
if (!this._a) return;
|
|
27
|
+
const href = this.getAttribute('href') || '';
|
|
28
|
+
this._a.setAttribute('href', href);
|
|
29
|
+
const target = this.getAttribute('target') || '_blank';
|
|
30
|
+
this._a.setAttribute('target', target);
|
|
31
|
+
if (target === '_blank') this._a.setAttribute('rel', 'noopener'); else this._a.removeAttribute('rel');
|
|
32
|
+
let host = href;
|
|
33
|
+
try { host = new URL(href, location.href).host; } catch { /* keep the text */ }
|
|
34
|
+
|
|
35
|
+
const text = this.createElement('span', ['bm-text']);
|
|
36
|
+
const title = this.createElement('span', ['bm-title']);
|
|
37
|
+
title.textContent = this._titleText || host;
|
|
38
|
+
text.appendChild(title);
|
|
39
|
+
const desc = this.getAttribute('desc');
|
|
40
|
+
if (desc) { const d = this.createElement('span', ['bm-desc']); d.textContent = desc; text.appendChild(d); }
|
|
41
|
+
const url = this.createElement('span', ['bm-url']);
|
|
42
|
+
const favicon = this.getAttribute('favicon');
|
|
43
|
+
if (favicon) url.appendChild(this.createElement('img', ['bm-favicon'], { src: favicon, alt: '' }));
|
|
44
|
+
const address = document.createElement('span');
|
|
45
|
+
address.textContent = this.getAttribute('url') || href;
|
|
46
|
+
url.appendChild(address);
|
|
47
|
+
text.appendChild(url);
|
|
48
|
+
const parts = [text];
|
|
49
|
+
const cover = this.getAttribute('cover');
|
|
50
|
+
if (cover) { const c = this.createElement('span', ['bm-cover']); c.appendChild(this.createElement('img', [], { src: cover, alt: '' })); parts.push(c); }
|
|
51
|
+
this._a.replaceChildren(...parts);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onAttributeChanged(name, _old, value) {
|
|
55
|
+
if (name === 'title' && !this.takeTitle(value)) return;
|
|
56
|
+
this._sync();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
focus(o) { this._a?.focus(o); }
|
|
60
|
+
get title() { return this._titleText ?? ''; }
|
|
61
|
+
set title(v) { this._titleText = v == null ? '' : String(v); this._sync(); }
|
|
62
|
+
get href() { return this.getAttribute('href') || ''; }
|
|
63
|
+
set href(v) { this.setAttribute('href', v); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
customElements.define('nk-bookmark', NkBookmark);
|
|
67
|
+
export { NkBookmark };
|
|
@@ -6,16 +6,24 @@ import { NkElement } from '../../base.js';
|
|
|
6
6
|
// </nk-database>
|
|
7
7
|
// db.columns = [{ key: 'name', label: 'Name', type: 'text', icon: '📄', title: true }, { key: 'status', type: 'select', options: [...] }, …];
|
|
8
8
|
// db.rows = [{ id: 1, icon: '🧭', name: '…', status: 'done', … }];
|
|
9
|
-
//
|
|
9
|
+
// <nk-btn slot="tools" variant="tool">Filter</nk-btn> … sit right of the tabs,
|
|
10
|
+
// <nk-filter-bar slot="filters"> under them (NotionKit 1.7.0's toolbar).
|
|
11
|
+
// → <div class="nk-database"><div class="nk-db-toolbar"><div class="nk-db-tabs">…</div>
|
|
12
|
+
// <div class="tools">…</div></div>…filters…active view…</div>
|
|
10
13
|
// The tabs come from the child views; data is pushed into every view.
|
|
11
14
|
class NkDatabase extends NkElement {
|
|
12
15
|
static get observedAttributes() { return ['view', 'add-view']; }
|
|
13
16
|
|
|
14
17
|
render() {
|
|
15
18
|
this._box = this.createElement('div', ['nk-database']);
|
|
19
|
+
this._toolbar = this.createElement('div', ['nk-db-toolbar']);
|
|
16
20
|
this._tabs = this.createElement('div', ['nk-db-tabs'], { role: 'tablist' });
|
|
21
|
+
const tools = this.createElement('div', ['tools']);
|
|
22
|
+
this._toolsSlot = this.createElement('slot', [], { name: 'tools' });
|
|
23
|
+
tools.appendChild(this._toolsSlot);
|
|
24
|
+
this._toolbar.append(this._tabs, tools);
|
|
17
25
|
this._slot = document.createElement('slot');
|
|
18
|
-
this._box.append(this.
|
|
26
|
+
this._box.append(this._toolbar, this.createElement('slot', [], { name: 'filters' }), this._slot);
|
|
19
27
|
this._wrapper.appendChild(this._box);
|
|
20
28
|
this._columns = []; this._rows = [];
|
|
21
29
|
this._sync();
|
|
@@ -43,12 +51,11 @@ class NkDatabase extends NkElement {
|
|
|
43
51
|
v.setData?.(this._columns, this._rows);
|
|
44
52
|
}
|
|
45
53
|
if (this.getBoolAttr('add-view')) {
|
|
46
|
-
const add = this.createElement('span', ['nk-db-tab'], { role: 'button', 'data-add': '', tabindex: '0' });
|
|
47
|
-
add.style.color = 'var(--nk-text-tertiary)';
|
|
54
|
+
const add = this.createElement('span', ['nk-db-tab', 'add'], { role: 'button', 'data-add': '', tabindex: '0' });
|
|
48
55
|
add.textContent = '+';
|
|
49
56
|
this._tabs.appendChild(add);
|
|
50
57
|
}
|
|
51
|
-
this.
|
|
58
|
+
this._toolbar.hidden = !views.length && !this._toolsSlot.assignedElements().length;
|
|
52
59
|
this._current = current;
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -70,6 +77,7 @@ class NkDatabase extends NkElement {
|
|
|
70
77
|
this._tabs.addEventListener('click', this._onClick);
|
|
71
78
|
this._tabs.addEventListener('keydown', this._onKey);
|
|
72
79
|
this._slot.addEventListener('slotchange', this._onSlot);
|
|
80
|
+
this._toolsSlot.addEventListener('slotchange', this._onSlot);
|
|
73
81
|
this._sync();
|
|
74
82
|
}
|
|
75
83
|
|
|
@@ -77,6 +85,7 @@ class NkDatabase extends NkElement {
|
|
|
77
85
|
this._tabs?.removeEventListener('click', this._onClick);
|
|
78
86
|
this._tabs?.removeEventListener('keydown', this._onKey);
|
|
79
87
|
this._slot?.removeEventListener('slotchange', this._onSlot);
|
|
88
|
+
this._toolsSlot?.removeEventListener('slotchange', this._onSlot);
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
onAttributeChanged(name) {
|
|
@@ -1,60 +1,76 @@
|
|
|
1
1
|
import { NkElement } from '../../base.js';
|
|
2
2
|
|
|
3
|
-
// <nk-filter-bar
|
|
4
|
-
// bar.filters = [{ key: 'status',
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// <nk-filter-bar add no-filter no-sort></nk-filter-bar>
|
|
4
|
+
// bar.filters = [{ key: 'status', value: 'done', op: 'is-not', label: 'Status: Open' }];
|
|
5
|
+
// The filters in effect as NotionKit's filter pills (1.7.0), no inline style:
|
|
6
|
+
// → <div class="nk-filter-row">[Filter][Sort]
|
|
7
|
+
// <span class="nk-filter-pill active"><button>Status: Open</button><button class="fp-remove">×</button></span>…
|
|
8
|
+
// <button class="nk-filter-pill add">+ Filter</button> …slot… [search]</div>
|
|
9
|
+
// Filter and Sort are .nk-db-tool buttons (hide them with no-filter / no-sort
|
|
10
|
+
// when the database toolbar carries its own); they fire nk-action. A pill's
|
|
11
|
+
// label fires nk-action { action: 'edit', index, filter }, the add pill
|
|
12
|
+
// nk-action { action: 'add' } – each with the clicked button as `anchor`, to
|
|
13
|
+
// open a menu under it. × removes the filter; every change to filters or
|
|
14
|
+
// search fires nk-change { filters, search }. In slot="filters" of
|
|
15
|
+
// <nk-database> the row sits right under the view tabs. The texts are
|
|
16
|
+
// attributes, for other languages: filter-label, sort-label, add-label,
|
|
17
|
+
// remove-label (the × reads "<remove-label>: <pill>") and placeholder.
|
|
8
18
|
class NkFilterBar extends NkElement {
|
|
9
|
-
static get observedAttributes() { return ['search', 'placeholder', 'no-filter', 'no-sort', 'value']; }
|
|
19
|
+
static get observedAttributes() { return ['search', 'placeholder', 'no-filter', 'no-sort', 'value', 'add', 'add-label', 'filter-label', 'sort-label', 'remove-label']; }
|
|
10
20
|
|
|
11
21
|
render() {
|
|
12
|
-
this.
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
this._sortBtn.textContent = '↕ Sort';
|
|
18
|
-
this._chips = document.createElement('span');
|
|
19
|
-
this._chips.style.cssText = 'display:inline-flex;gap:4px;flex-wrap:wrap';
|
|
22
|
+
this._row = this.createElement('div', ['nk-filter-row']);
|
|
23
|
+
this._filterBtn = this.createElement('button', ['nk-db-tool'], { type: 'button', 'data-action': 'filter' });
|
|
24
|
+
this._sortBtn = this.createElement('button', ['nk-db-tool'], { type: 'button', 'data-action': 'sort' });
|
|
25
|
+
this._pillStart = document.createComment('pills');
|
|
26
|
+
this._addBtn = this.createElement('button', ['nk-filter-pill', 'add'], { type: 'button', 'data-action': 'add' });
|
|
20
27
|
this._input = this.createElement('input', ['nk-input'], { type: 'search', autocomplete: 'off' });
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
this._wrapper.appendChild(this._bar);
|
|
28
|
+
this._row.append(this._filterBtn, this._sortBtn, this._pillStart, this._addBtn, document.createElement('slot'), this._input);
|
|
29
|
+
this._wrapper.appendChild(this._row);
|
|
24
30
|
this._filters = [];
|
|
25
31
|
this._sync();
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
_sync() {
|
|
29
|
-
this._filterBtn.
|
|
30
|
-
this.
|
|
31
|
-
this.
|
|
35
|
+
this._filterBtn.textContent = this.getAttribute('filter-label') || 'Filter';
|
|
36
|
+
this._filterBtn.hidden = this.getBoolAttr('no-filter');
|
|
37
|
+
this._filterBtn.classList.toggle('active', this._filters.length > 0);
|
|
38
|
+
this._sortBtn.textContent = this.getAttribute('sort-label') || 'Sort';
|
|
39
|
+
this._sortBtn.hidden = this.getBoolAttr('no-sort');
|
|
40
|
+
this._addBtn.textContent = this.getAttribute('add-label') || '+ Filter';
|
|
41
|
+
this._addBtn.hidden = !this.getBoolAttr('add');
|
|
42
|
+
this._input.hidden = !this.getBoolAttr('search');
|
|
32
43
|
this._input.placeholder = this.getAttribute('placeholder') || 'Search …';
|
|
33
44
|
if (this.hasAttribute('value') && this._input.value !== this.getAttribute('value')) this._input.value = this.getAttribute('value');
|
|
34
|
-
this.
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
45
|
+
for (const old of this._row.querySelectorAll('[data-pill]')) old.remove();
|
|
46
|
+
const removeLabel = this.getAttribute('remove-label') || 'Remove filter';
|
|
47
|
+
this._pillStart.after(...this._filters.map((f, i) => {
|
|
48
|
+
const pill = this.createElement('span', ['nk-filter-pill', 'active'], { 'data-pill': i });
|
|
49
|
+
const label = this.createElement('button', [], { type: 'button', 'data-edit': i });
|
|
50
|
+
label.textContent = f.label ?? `${f.key}: ${f.value}`;
|
|
51
|
+
const remove = this.createElement('button', ['fp-remove'], { type: 'button', 'data-remove': i, 'aria-label': `${removeLabel}: ${label.textContent}` });
|
|
52
|
+
remove.textContent = '×';
|
|
53
|
+
pill.append(label, remove);
|
|
54
|
+
return pill;
|
|
55
|
+
}));
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
setupEvents() {
|
|
45
59
|
this._onClick = (e) => {
|
|
60
|
+
const remove = e.target.closest('[data-remove]');
|
|
61
|
+
if (remove) { this._filters.splice(Number(remove.dataset.remove), 1); this._sync(); this._emitChange(); return; }
|
|
62
|
+
const edit = e.target.closest('[data-edit]');
|
|
63
|
+
if (edit) { const index = Number(edit.dataset.edit); this.emit('nk-action', { action: 'edit', index, filter: this._filters[index], anchor: edit }); return; }
|
|
46
64
|
const btn = e.target.closest('[data-action]');
|
|
47
|
-
if (btn)
|
|
48
|
-
const chip = e.target.closest('[data-index]');
|
|
49
|
-
if (chip) { this._filters.splice(Number(chip.dataset.index), 1); this._sync(); this._emitChange(); }
|
|
65
|
+
if (btn) this.emit('nk-action', { action: btn.dataset.action, anchor: btn });
|
|
50
66
|
};
|
|
51
67
|
this._onInput = () => { clearTimeout(this._debounce); this._debounce = setTimeout(() => this._emitChange(), 150); };
|
|
52
|
-
this.
|
|
68
|
+
this._row.addEventListener('click', this._onClick);
|
|
53
69
|
this._input.addEventListener('input', this._onInput);
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
teardownEvents() {
|
|
57
|
-
this.
|
|
73
|
+
this._row?.removeEventListener('click', this._onClick);
|
|
58
74
|
this._input?.removeEventListener('input', this._onInput);
|
|
59
75
|
clearTimeout(this._debounce);
|
|
60
76
|
}
|
|
@@ -63,13 +79,18 @@ class NkFilterBar extends NkElement {
|
|
|
63
79
|
onAttributeChanged() { this._sync(); }
|
|
64
80
|
|
|
65
81
|
get filters() { return this._filters; }
|
|
66
|
-
set filters(v) { this._filters = Array.isArray(v) ? v : []; this._sync(); }
|
|
82
|
+
set filters(v) { this._filters = Array.isArray(v) ? v : []; if (this._row) this._sync(); }
|
|
67
83
|
get value() { return this._input?.value ?? ''; }
|
|
68
84
|
set value(v) { if (this._input) this._input.value = v ?? ''; }
|
|
69
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Applies filters and search to rows: a filter keeps rows where row[key]
|
|
87
|
+
* equals value – or differs from it with op: 'is-not'; the search looks
|
|
88
|
+
* for the text in every string field (a person's name).
|
|
89
|
+
*/
|
|
70
90
|
apply(rows) {
|
|
71
91
|
const q = this.value.trim().toLowerCase();
|
|
72
|
-
return rows.filter(r => this._filters.every(f => r[f.key]
|
|
92
|
+
return rows.filter(r => this._filters.every(f => (f.op === 'is-not' ? r[f.key] !== f.value : r[f.key] === f.value))
|
|
93
|
+
&& (!q || Object.values(r).some(v => String(typeof v === 'object' && v ? v.name ?? '' : v ?? '').toLowerCase().includes(q))));
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
|
|
@@ -3,13 +3,16 @@ import { NkElement } from '../../base.js';
|
|
|
3
3
|
// <nk-btn variant="primary" small>Save</nk-btn> → <button class="nk-btn primary small">
|
|
4
4
|
// <nk-btn href="/docs" variant="secondary">Docs</nk-btn> → <a class="nk-btn secondary">
|
|
5
5
|
// <nk-btn variant="topbar">⭐</nk-btn> → <button class="nk-topbar-btn">
|
|
6
|
+
// <nk-btn variant="tool" active>Filter</nk-btn> → <button class="nk-db-tool active"> (slot="tools" of nk-database)
|
|
7
|
+
// <nk-btn variant="sidebar" aria-label="Menu">☰</nk-btn> → <button class="nk-topbar-btn nk-sidebar-toggle">:
|
|
8
|
+
// shown on phones only, it opens the page's <nk-sidebar> as a drawer.
|
|
6
9
|
//
|
|
7
10
|
// Modifier classes become attributes. A slotted <svg> is sized by the
|
|
8
11
|
// `.nk-btn ::slotted(svg)` twin; pass the icon itself, never wrapped.
|
|
9
12
|
const VARIANTS = ['primary', 'secondary', 'danger', 'danger-solid', 'topbar', 'share'];
|
|
10
13
|
|
|
11
14
|
class NkBtn extends NkElement {
|
|
12
|
-
static get observedAttributes() { return ['variant', 'small', 'disabled', 'type', 'href', 'title', 'aria-label']; }
|
|
15
|
+
static get observedAttributes() { return ['variant', 'small', 'disabled', 'type', 'href', 'title', 'aria-label', 'aria-haspopup', 'active']; }
|
|
13
16
|
|
|
14
17
|
render() {
|
|
15
18
|
this._build();
|
|
@@ -37,13 +40,22 @@ class NkBtn extends NkElement {
|
|
|
37
40
|
else el.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
|
38
41
|
const title = this.getAttribute('title');
|
|
39
42
|
title ? el.setAttribute('title', title) : el.removeAttribute('title');
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
// The accessible name and the popup hint belong on the button itself.
|
|
44
|
+
for (const name of ['aria-label', 'aria-haspopup']) {
|
|
45
|
+
const v = this.getAttribute(name);
|
|
46
|
+
v ? el.setAttribute(name, v) : el.removeAttribute(name);
|
|
47
|
+
}
|
|
48
|
+
if (this.getAttribute('variant') === 'sidebar') el.setAttribute('aria-expanded', this._sidebar()?.open ? 'true' : 'false');
|
|
49
|
+
else el.removeAttribute('aria-expanded');
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/** The sidebar the ☰ opens: the one in its own app, else the page's. */
|
|
53
|
+
_sidebar() { return this.closest('nk-app')?.querySelector('nk-sidebar') ?? document.querySelector('nk-sidebar'); }
|
|
54
|
+
|
|
44
55
|
setupEvents() {
|
|
45
56
|
this._onClick = (e) => {
|
|
46
57
|
if (this.getBoolAttr('disabled')) { e.preventDefault(); e.stopPropagation(); return; }
|
|
58
|
+
if (this.getAttribute('variant') === 'sidebar') { this._sidebar()?.toggle?.(); return; }
|
|
47
59
|
// A button inside a shadow root is not a submit button of the outer
|
|
48
60
|
// form; forward the intent to the form the host sits in.
|
|
49
61
|
const type = this.getAttribute('type');
|
|
@@ -52,11 +64,17 @@ class NkBtn extends NkElement {
|
|
|
52
64
|
if (form) type === 'submit' ? form.requestSubmit() : form.reset();
|
|
53
65
|
}
|
|
54
66
|
};
|
|
67
|
+
// The ☰ says whether its drawer is open, however the drawer closed.
|
|
68
|
+
this._onToggle = (e) => {
|
|
69
|
+
if (this.getAttribute('variant') === 'sidebar' && e.target === this._sidebar()) this._btn.setAttribute('aria-expanded', e.detail.open ? 'true' : 'false');
|
|
70
|
+
};
|
|
55
71
|
this._btn.addEventListener('click', this._onClick);
|
|
72
|
+
document.addEventListener('nk-toggle', this._onToggle);
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
teardownEvents() {
|
|
59
76
|
this._btn?.removeEventListener('click', this._onClick);
|
|
77
|
+
document.removeEventListener('nk-toggle', this._onToggle);
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
onAttributeChanged(name) {
|
|
@@ -66,7 +84,11 @@ class NkBtn extends NkElement {
|
|
|
66
84
|
this._build();
|
|
67
85
|
break;
|
|
68
86
|
case 'variant':
|
|
87
|
+
this._btn.className = this._computeClasses().join(' ');
|
|
88
|
+
this._applyState(this._btn);
|
|
89
|
+
break;
|
|
69
90
|
case 'small':
|
|
91
|
+
case 'active':
|
|
70
92
|
this._btn.className = this._computeClasses().join(' ');
|
|
71
93
|
break;
|
|
72
94
|
case 'type':
|
|
@@ -81,6 +103,8 @@ class NkBtn extends NkElement {
|
|
|
81
103
|
const variant = this.getAttribute('variant');
|
|
82
104
|
if (variant === 'topbar') return ['nk-topbar-btn'];
|
|
83
105
|
if (variant === 'share') return ['nk-topbar-btn', 'nk-share-btn'];
|
|
106
|
+
if (variant === 'sidebar') return ['nk-topbar-btn', 'nk-sidebar-toggle'];
|
|
107
|
+
if (variant === 'tool') return this.getBoolAttr('active') ? ['nk-db-tool', 'active'] : ['nk-db-tool'];
|
|
84
108
|
const classes = ['nk-btn'];
|
|
85
109
|
if (VARIANTS.includes(variant)) classes.push(variant);
|
|
86
110
|
if (this.getBoolAttr('small')) classes.push('small');
|
|
@@ -92,6 +116,8 @@ class NkBtn extends NkElement {
|
|
|
92
116
|
|
|
93
117
|
get variant() { return this.getAttribute('variant'); }
|
|
94
118
|
set variant(v) { v ? this.setAttribute('variant', v) : this.removeAttribute('variant'); }
|
|
119
|
+
get active() { return this.getBoolAttr('active'); }
|
|
120
|
+
set active(v) { this.setBoolAttr('active', v); }
|
|
95
121
|
get small() { return this.getBoolAttr('small'); }
|
|
96
122
|
set small(v) { this.setBoolAttr('small', v); }
|
|
97
123
|
get disabled() { return this.getBoolAttr('disabled'); }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { NkElement } from '../../base.js';
|
|
2
|
+
|
|
3
|
+
// <nk-copy-field value="https://monahilft.notionkit.app"></nk-copy-field>
|
|
4
|
+
// <nk-copy-field value="ntn_4f2…" secret mono></nk-copy-field>
|
|
5
|
+
// → <div class="nk-copy-field mono"><span class="cf-value">…</span>
|
|
6
|
+
// <button class="cf-btn">Show</button><button class="cf-btn">Copy</button></div>
|
|
7
|
+
// A value to take along – a link, an address, a key (NotionKit 1.8.0). Copy
|
|
8
|
+
// writes it to the clipboard and says "Copied" in green for a moment; where
|
|
9
|
+
// the clipboard is not allowed, the value is shown and selected instead, for
|
|
10
|
+
// ⌘C. `secret` masks it behind a Show/Hide toggle; Copy still copies the
|
|
11
|
+
// real value. `value` set as a property is not reflected, so a key never
|
|
12
|
+
// lands in the markup. Fires nk-action { action: 'copy', value, ok }. The
|
|
13
|
+
// texts are attributes: copy-label, copied-label, show-label, hide-label.
|
|
14
|
+
class NkCopyField extends NkElement {
|
|
15
|
+
static get observedAttributes() { return ['value', 'mono', 'wrap', 'wide', 'secret', 'copy-label', 'copied-label', 'show-label', 'hide-label']; }
|
|
16
|
+
|
|
17
|
+
render() {
|
|
18
|
+
this._val ??= this.getAttribute('value') ?? '';
|
|
19
|
+
this._box = this.createElement('div', ['nk-copy-field']);
|
|
20
|
+
this._text = this.createElement('span', ['cf-value']);
|
|
21
|
+
this._show = this.createElement('button', ['cf-btn'], { type: 'button' });
|
|
22
|
+
this._copy = this.createElement('button', ['cf-btn'], { type: 'button' });
|
|
23
|
+
this._box.append(this._text, this._copy);
|
|
24
|
+
this._wrapper.appendChild(this._box);
|
|
25
|
+
this._sync();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_sync() {
|
|
29
|
+
if (!this._box) return;
|
|
30
|
+
for (const m of ['mono', 'wrap', 'wide']) this._box.classList.toggle(m, this.getBoolAttr(m));
|
|
31
|
+
const secret = this.getBoolAttr('secret');
|
|
32
|
+
this._text.textContent = secret && !this._revealed ? '•'.repeat(Math.min(this._val.length, 24)) : this._val;
|
|
33
|
+
// Not `hidden`: .cf-btn sets no display of its own that could lose to it,
|
|
34
|
+
// but a button that is not there cannot be tabbed to either.
|
|
35
|
+
if (secret && !this._show.isConnected) this._copy.before(this._show);
|
|
36
|
+
if (!secret) this._show.remove();
|
|
37
|
+
this._show.textContent = this._revealed ? (this.getAttribute('hide-label') || 'Hide') : (this.getAttribute('show-label') || 'Show');
|
|
38
|
+
this._show.setAttribute('aria-pressed', String(!!this._revealed));
|
|
39
|
+
this._copy.textContent = this._copied ? (this.getAttribute('copied-label') || 'Copied') : (this.getAttribute('copy-label') || 'Copy');
|
|
40
|
+
this._copy.classList.toggle('copied', !!this._copied);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setupEvents() {
|
|
44
|
+
this._onCopy = () => this.copy();
|
|
45
|
+
this._onShow = () => { this._revealed = !this._revealed; this._sync(); };
|
|
46
|
+
this._copy.addEventListener('click', this._onCopy);
|
|
47
|
+
this._show.addEventListener('click', this._onShow);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
teardownEvents() {
|
|
51
|
+
this._copy?.removeEventListener('click', this._onCopy);
|
|
52
|
+
this._show?.removeEventListener('click', this._onShow);
|
|
53
|
+
clearTimeout(this._timer);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onAttributeChanged(name, _old, value) {
|
|
57
|
+
if (name === 'value') this._val = value ?? '';
|
|
58
|
+
this._sync();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Copies the value; resolves to whether the clipboard took it. */
|
|
62
|
+
async copy() {
|
|
63
|
+
const value = this._val;
|
|
64
|
+
let ok = true;
|
|
65
|
+
try { await navigator.clipboard.writeText(value); } catch { ok = false; }
|
|
66
|
+
if (ok) {
|
|
67
|
+
this._copied = true;
|
|
68
|
+
clearTimeout(this._timer);
|
|
69
|
+
this._timer = setTimeout(() => { this._copied = false; this._sync(); }, 1500);
|
|
70
|
+
} else {
|
|
71
|
+
this._revealed = true;
|
|
72
|
+
}
|
|
73
|
+
this._sync();
|
|
74
|
+
if (!ok) getSelection()?.selectAllChildren(this._text);
|
|
75
|
+
this.emit('nk-action', { action: 'copy', value, ok });
|
|
76
|
+
return ok;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get value() { return this._val ?? this.getAttribute('value') ?? ''; }
|
|
80
|
+
set value(v) { this._val = v == null ? '' : String(v); this._sync(); }
|
|
81
|
+
get secret() { return this.getBoolAttr('secret'); }
|
|
82
|
+
set secret(v) { this.setBoolAttr('secret', v); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
customElements.define('nk-copy-field', NkCopyField);
|
|
86
|
+
export { NkCopyField };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { NkElement } from '../../base.js';
|
|
2
|
+
|
|
3
|
+
// <nk-image-picker initials="MK" label="Profile picture"></nk-image-picker>
|
|
4
|
+
// <nk-image-picker square initials="M" max="256" type="image/png"></nk-image-picker>
|
|
5
|
+
// → <div class="nk-profile-row"><div class="big-avatar">MK | <img></div>
|
|
6
|
+
// <div class="pr-actions"><button class="nk-btn secondary small">Upload image</button>
|
|
7
|
+
// <button class="nk-btn secondary small pr-remove">Remove</button><input type="file" hidden></div></div>
|
|
8
|
+
//
|
|
9
|
+
// A picture for a person or a workspace (NotionKit 1.8.0): the profile row
|
|
10
|
+
// with its actions, round – or `square`, a workspace icon. Ported from
|
|
11
|
+
// GlassKit Elements' glk-image-picker: the file goes through
|
|
12
|
+
// createImageBitmap with imageOrientation 'from-image' – the EXIF rotation
|
|
13
|
+
// of a phone photo without a library – onto a canvas no larger than `max`
|
|
14
|
+
// (default 512), out as `type` (default image/jpeg, on white; image/png
|
|
15
|
+
// keeps transparency) at `quality` (0.82). The file input is hidden and
|
|
16
|
+
// opened from a real button, and cleared after every pick, so the same file
|
|
17
|
+
// can be chosen again. Not form-associated: listen to nk-change and upload
|
|
18
|
+
// the data URL. `src` set as a property is not reflected, so megabytes of
|
|
19
|
+
// data URL never land in the DOM.
|
|
20
|
+
// Events: nk-change { dataUrl, width, height, size } after a pick, and on
|
|
21
|
+
// remove with an empty dataUrl; nk-error { message, name } when the
|
|
22
|
+
// file cannot be decoded (HEIC outside Safari, a broken file).
|
|
23
|
+
// Texts: choose-label, change-label, remove-label; label names the group.
|
|
24
|
+
|
|
25
|
+
function dataUrlBytes(url) {
|
|
26
|
+
const b64 = url.slice(url.indexOf(',') + 1);
|
|
27
|
+
const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
|
|
28
|
+
return Math.floor((b64.length * 3) / 4) - padding;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class NkImagePicker extends NkElement {
|
|
32
|
+
static get observedAttributes() { return ['src', 'initials', 'square', 'label', 'max', 'type', 'quality', 'accept', 'choose-label', 'change-label', 'remove-label']; }
|
|
33
|
+
|
|
34
|
+
render() {
|
|
35
|
+
this._src ??= this.getAttribute('src') || '';
|
|
36
|
+
this._row = this.createElement('div', ['nk-profile-row'], { role: 'group' });
|
|
37
|
+
this._avatar = this.createElement('div', ['big-avatar']);
|
|
38
|
+
const actions = this.createElement('div', ['pr-actions']);
|
|
39
|
+
this._choose = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button' });
|
|
40
|
+
this._remove = this.createElement('button', ['nk-btn', 'secondary', 'small', 'pr-remove'], { type: 'button' });
|
|
41
|
+
this._input = this.createElement('input', [], { type: 'file', tabindex: '-1' });
|
|
42
|
+
this._input.hidden = true;
|
|
43
|
+
actions.append(this._choose, this._remove, this._input);
|
|
44
|
+
this._row.append(this._avatar, actions);
|
|
45
|
+
this._wrapper.appendChild(this._row);
|
|
46
|
+
this._sync();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get _max() { return Math.max(1, Math.floor(Number(this.getAttribute('max'))) || 512); }
|
|
50
|
+
get _type() { return this.getAttribute('type') || 'image/jpeg'; }
|
|
51
|
+
get _quality() { const q = Number(this.getAttribute('quality')); return this.hasAttribute('quality') && q >= 0 && q <= 1 ? q : 0.82; }
|
|
52
|
+
|
|
53
|
+
_sync() {
|
|
54
|
+
if (!this._row) return;
|
|
55
|
+
this._avatar.classList.toggle('square', this.getBoolAttr('square'));
|
|
56
|
+
if (!this._src) {
|
|
57
|
+
this._img = null;
|
|
58
|
+
this._avatar.textContent = this.getAttribute('initials') || '';
|
|
59
|
+
} else if (!this._img || this._img.getAttribute('src') !== this._src) {
|
|
60
|
+
this._img = this.createElement('img', [], { src: this._src, alt: '' });
|
|
61
|
+
this._avatar.replaceChildren(this._img);
|
|
62
|
+
}
|
|
63
|
+
const label = this.getAttribute('label');
|
|
64
|
+
if (label) this._row.setAttribute('aria-label', label); else this._row.removeAttribute('aria-label');
|
|
65
|
+
this._choose.textContent = this._src ? (this.getAttribute('change-label') || 'Change image') : (this.getAttribute('choose-label') || 'Upload image');
|
|
66
|
+
this._remove.textContent = this.getAttribute('remove-label') || 'Remove';
|
|
67
|
+
this._remove.hidden = !this._src;
|
|
68
|
+
this._input.accept = this.getAttribute('accept') || 'image/*';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async _pick(file) {
|
|
72
|
+
if (!file) return;
|
|
73
|
+
try {
|
|
74
|
+
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
|
75
|
+
const scale = Math.min(1, this._max / Math.max(bitmap.width, bitmap.height));
|
|
76
|
+
const canvas = document.createElement('canvas');
|
|
77
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
78
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
79
|
+
const ctx = canvas.getContext('2d');
|
|
80
|
+
if (this._type === 'image/jpeg') {
|
|
81
|
+
// JPEG has no alpha; without this a transparent PNG comes out on black.
|
|
82
|
+
ctx.fillStyle = '#fff';
|
|
83
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
84
|
+
}
|
|
85
|
+
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
86
|
+
bitmap.close();
|
|
87
|
+
const dataUrl = canvas.toDataURL(this._type, this._quality);
|
|
88
|
+
this.src = dataUrl;
|
|
89
|
+
this.emit('nk-change', { dataUrl, width: canvas.width, height: canvas.height, size: dataUrlBytes(dataUrl) });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.emit('nk-error', { message: error?.message || String(error), name: file.name });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setupEvents() {
|
|
96
|
+
this._onChoose = () => this._input.click();
|
|
97
|
+
this._onChange = (e) => { const file = e.target.files?.[0]; e.target.value = ''; this._pick(file); };
|
|
98
|
+
this._onRemove = () => {
|
|
99
|
+
this.src = '';
|
|
100
|
+
this._choose.focus(); // the remove button just went away under the focus
|
|
101
|
+
this.emit('nk-change', { dataUrl: '', width: 0, height: 0, size: 0 });
|
|
102
|
+
};
|
|
103
|
+
this._choose.addEventListener('click', this._onChoose);
|
|
104
|
+
this._input.addEventListener('change', this._onChange);
|
|
105
|
+
this._remove.addEventListener('click', this._onRemove);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
teardownEvents() {
|
|
109
|
+
this._choose?.removeEventListener('click', this._onChoose);
|
|
110
|
+
this._input?.removeEventListener('change', this._onChange);
|
|
111
|
+
this._remove?.removeEventListener('click', this._onRemove);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onAttributeChanged(name, _old, value) {
|
|
115
|
+
if (name === 'src') this._src = value || '';
|
|
116
|
+
this._sync();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Opens the file dialog, as the button does. */
|
|
120
|
+
choose() { this._input?.click(); }
|
|
121
|
+
get src() { return this._src ?? this.getAttribute('src') ?? ''; }
|
|
122
|
+
set src(v) { this._src = v || ''; this._sync(); }
|
|
123
|
+
get square() { return this.getBoolAttr('square'); }
|
|
124
|
+
set square(v) { this.setBoolAttr('square', v); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
customElements.define('nk-image-picker', NkImagePicker);
|
|
128
|
+
export { NkImagePicker };
|