@jungherz-de/notionkit-elements 1.7.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 +14 -14
- package/dist/components/nk-bookmark.js +69 -0
- package/dist/components/nk-copy-field.js +88 -0
- package/dist/components/nk-image-picker.js +130 -0
- package/dist/components/nk-peek.js +122 -0
- package/dist/notionkit-elements.esm.js +395 -3
- package/dist/notionkit-elements.js +398 -2
- package/dist/notionkit-elements.min.js +1 -1
- package/package.json +3 -3
- package/src/components/content/nk-bookmark.js +67 -0
- 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-peek.js +120 -0
- package/src/index.js +6 -0
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
<h1 align="center">π§© NotionKit Elements</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="https://www.npmjs.com/package/@jungherz-de/notionkit-elements"><img src="https://img.shields.io/badge/version-1.
|
|
4
|
+
<a href="https://www.npmjs.com/package/@jungherz-de/notionkit-elements"><img src="https://img.shields.io/badge/version-1.8.0-2383e2?style=flat-square" alt="Version"></a>
|
|
5
5
|
<a href="#"><img src="https://img.shields.io/badge/vanilla_JS-no_dependencies-448361?style=flat-square" alt="Vanilla JS"></a>
|
|
6
|
-
<a href="#"><img src="https://img.shields.io/badge/elements-
|
|
6
|
+
<a href="#"><img src="https://img.shields.io/badge/elements-83-529cca?style=flat-square" alt="83 Elements"></a>
|
|
7
7
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-9065b0?style=flat-square" alt="MIT License"></a>
|
|
8
|
-
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.
|
|
8
|
+
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.8.0-lightgrey?style=flat-square" alt="Changelog"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/@jungherz-de/notionkit-elements"><img src="https://img.shields.io/badge/npm-%40jungherz--de%2Fnotionkit--elements-cb3837?style=flat-square&logo=npm" alt="npm"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<strong>Drop-in Web Components for <a href="https://github.com/JUNGHERZ/NotionKit">NotionKit CSS</a></strong><br>
|
|
14
|
-
|
|
14
|
+
83 vanilla JavaScript custom elements wrapping NotionKit's calm, document-centric workspace look.<br>
|
|
15
15
|
Shadow DOM · Native form participation · Pixel parity with the class markup · Zero dependencies.
|
|
16
16
|
</p>
|
|
17
17
|
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
## β¨ What is NotionKit Elements?
|
|
30
30
|
|
|
31
|
-
NotionKit Elements is the companion library to [NotionKit CSS](https://github.com/JUNGHERZ/NotionKit). It provides **
|
|
31
|
+
NotionKit Elements is the companion library to [NotionKit CSS](https://github.com/JUNGHERZ/NotionKit). It provides **83 Web Components** β from buttons and form controls over the sidebar, page tree and page shell with its properties to the settings modal, the bottom sheet, the side peek, βK palette, database views with Notionβs toolbar and AI thread β that encapsulate the class markup of the foundation into simple, declarative custom elements.
|
|
32
32
|
|
|
33
33
|
It is the **app layer** of the NotionKit family β three layers, one design language: [NotionKit](https://notionkit.jungherz.com) is the pure-CSS foundation, NotionKit Elements wraps it into web components for application UIs, and [NotionKit Web](https://notionkit-web.jungherz.com) is the Astro template for complete websites on top of the same foundation. The word stem stays the same across the layers: `.nk-callout` becomes `<nk-callout>`, a modifier class becomes an attribute, a state class becomes a boolean attribute.
|
|
34
34
|
|
|
@@ -56,10 +56,10 @@ It is the **app layer** of the NotionKit family β three layers, one design lan
|
|
|
56
56
|
|---|---|
|
|
57
57
|
| π― **Pixel parity** | Every element renders identically to its class markup β verified by a Playwright pixel test in light and dark; the reference app differs by 0.00 % from the class version |
|
|
58
58
|
| π **Shadow DOM** | Each root adopts the NotionKit *components* sheet via `adoptedStyleSheets`; tokens are inherited from the document, so one `:root { --nk-accent: β¦ }` re-brands everything |
|
|
59
|
-
| π§© **
|
|
59
|
+
| π§© **83 Elements** | Forms with copy field and image picker, content blocks with bookmarks, app shell & tree with the β° drawer, page shell with properties, panels, avatars and steps, overlays (modal, sheet, side peek, βK palette, menus that float in and become sheets on a phone, popover, emoji picker, toast), database table, board & list with toolbar and filter pills, comments, AI thread |
|
|
60
60
|
| ποΈ **Form participation** | Input, textarea, select, switch, check, radio, slider, segmented and model card work natively with `<form>` via `ElementInternals`; radios group across shadow roots |
|
|
61
61
|
| π **Theme sync** | One observer mirrors `data-theme` on `<html>` into every element |
|
|
62
|
-
| πͺΆ **Lightweight** |
|
|
62
|
+
| πͺΆ **Lightweight** | 225 KB minified / 44 KB gzipped (IIFE, foundation sheet included), no external dependencies |
|
|
63
63
|
| π¦ **Four bundle shapes** | IIFE, minified IIFE, ESM, and per-component ESM entries on a stable `base.js`, which import NotionKit's sheet instead of carrying a copy |
|
|
64
64
|
| π€ **AI-ready** | `SKILL.md` with copy-paste markup, six app skeletons and the rules β an agent given only that file built a working app in the release test |
|
|
65
65
|
|
|
@@ -71,8 +71,8 @@ It is the **app layer** of the NotionKit family β three layers, one design lan
|
|
|
71
71
|
<!DOCTYPE html>
|
|
72
72
|
<html lang="en" data-theme="light">
|
|
73
73
|
<head>
|
|
74
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit@1.
|
|
75
|
-
<script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.
|
|
74
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit@1.8.0/notionkit.min.css">
|
|
75
|
+
<script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.8.0/dist/notionkit-elements.min.js"></script>
|
|
76
76
|
</head>
|
|
77
77
|
<body class="nk-body">
|
|
78
78
|
<nk-field label="Display name" desc="Shown next to your comments">
|
|
@@ -102,17 +102,17 @@ The per-component files import NotionKit's sheet as `@jungherz-de/notionkit/noti
|
|
|
102
102
|
|
|
103
103
|
Do not mix the full bundle with the per-component files: each brings its own `NkElement`. With the bundle, take `componentsSheet` and `NkElement` from the bundle (`NotionKitElements.componentsSheet` from the `<script>` build).
|
|
104
104
|
|
|
105
|
-
Two prerequisites, always: `notionkit.css` on the document (the peer dependency, `>= 1.
|
|
105
|
+
Two prerequisites, always: `notionkit.css` on the document (the peer dependency, `>= 1.8.0`; 1.8.0 brings the side peek, the bookmark, the copy field and pictures on the profile row, 1.7.0 the sheet, menus that float in and become sheets on a phone, the sidebar drawer, the database toolbar with filter pills and the steps, 1.7.1 minified files that carry all of it, 1.6.0 page properties, page options, list view, panels, prose and the avatar, 1.5.1 the safe areas, 1.5.0 the Notion-2025 tokens, the nine tag colours and the filled inputs; from 1.5.0 on, Elements and the foundation share one version number β the elements are built and tested against the same release) and `class="nk-body"` on `<body>`. The elements ship no visual CSS of their own β every rule comes from the foundation. The bundle injects the design tokens once as `@layer notionkit-defaults`, so any plain `:root { --nk-accent: β¦ }` of yours re-brands every element in both themes.
|
|
106
106
|
|
|
107
107
|
## Elements
|
|
108
108
|
|
|
109
109
|
| Group | Elements |
|
|
110
110
|
|---|---|
|
|
111
|
-
| Forms & controls | `nk-btn` `nk-input` `nk-textarea` `nk-select` `nk-switch` `nk-check` `nk-radio` `nk-slider` `nk-field` `nk-fields` |
|
|
112
|
-
| Content | `nk-tag` `nk-progress` `nk-callout` `nk-divider` `nk-heading` `nk-toggle` `nk-todo` `nk-kbd` `nk-code` `nk-quote` |
|
|
111
|
+
| Forms & controls | `nk-btn` `nk-input` `nk-textarea` `nk-select` `nk-switch` `nk-check` `nk-radio` `nk-slider` `nk-field` `nk-fields` `nk-copy-field` `nk-image-picker` |
|
|
112
|
+
| Content | `nk-tag` `nk-progress` `nk-callout` `nk-bookmark` `nk-divider` `nk-heading` `nk-toggle` `nk-todo` `nk-kbd` `nk-code` `nk-quote` |
|
|
113
113
|
| App shell & navigation | `nk-app` `nk-sidebar` `nk-workspace-switcher` `nk-section-label` `nk-tree` `nk-tree-item` `nk-topbar` `nk-breadcrumb` `nk-theme-toggle` `nk-tab-bar` `nk-tab-bar-item` |
|
|
114
114
|
| Page shell & blocks | `nk-page` `nk-page-cover` `nk-page-title` `nk-page-actions` `nk-props` `nk-prop` `nk-panels` `nk-panel` `nk-avatar` `nk-block-host` `nk-banner` `nk-empty` `nk-skeleton` `nk-synced` `nk-tabs` `nk-tab` `nk-segmented` `nk-steps` `nk-stats` `nk-stat` `nk-avatar-group` `nk-mention` `nk-template-btn` `nk-model-card` `nk-profile-row` `nk-danger-zone` `nk-member-list` `nk-member-row` |
|
|
115
|
-
| Overlays | `nk-modal` `nk-sheet` `nk-settings-pane` `nk-settings-user` `nk-cmdk` `nk-menu` `nk-menu-item` `nk-pop` `nk-emoji-picker` `nk-toast` |
|
|
115
|
+
| Overlays | `nk-modal` `nk-sheet` `nk-peek` `nk-settings-pane` `nk-settings-user` `nk-cmdk` `nk-menu` `nk-menu-item` `nk-pop` `nk-emoji-picker` `nk-toast` |
|
|
116
116
|
| Data & collaboration | `nk-database` `nk-table-view` `nk-board-view` `nk-list-view` `nk-filter-bar` `nk-comments` `nk-comment` `nk-ai-thread` `nk-ai-msg` `nk-ai-input-row` (+ the exported `renderPropertyCell()`) |
|
|
117
117
|
|
|
118
118
|
Static content goes in through attributes and slots, dynamic data through properties (`tree.data`, `database.rows`, `palette.commands`). Interactions fire custom events with fixed names: `nk-select`, `nk-change`, `nk-view-change`, `nk-command`, `nk-toggle`, `nk-submit`, `nk-action`. No fetching, no two-way binding β the elements render what they get.
|
|
@@ -124,7 +124,7 @@ Static content goes in through attributes and slots, dynamic data through proper
|
|
|
124
124
|
- Form controls are form-associated custom elements: `FormData`, `reset`, `required` and `<fieldset disabled>` just work. `<nk-radio>`s with the same `name` form a real group across shadow roots, with one tab stop and arrow keys.
|
|
125
125
|
- Elements that copy light-DOM children (`nk-select` options, breadcrumb crumbs) watch them; `element.refresh()` is the escape hatch. Listeners are re-armed on every connect, so a moved element keeps working.
|
|
126
126
|
- `::slotted()` matches only the assigned node: pass icons directly (`<span slot="icon">π</span>`), never wrapped. For the same reason there is no `<nk-prose>`: rendered Markdown goes into a light-DOM `<div class="nk-prose">`, which reads the same rules as the editor adapter.
|
|
127
|
-
- Overlays (`nk-modal`, `nk-sheet`, `nk-cmdk`, `nk-toast`, a floating `nk-menu`) go directly under `<body>`. The modal, the sheet and the palette lock scroll and make the rest of the page `inert
|
|
127
|
+
- Overlays (`nk-modal`, `nk-sheet`, `nk-peek`, `nk-cmdk`, `nk-toast`, a floating `nk-menu`) go directly under `<body>`. The modal, the sheet and the palette lock scroll and make the rest of the page `inert` β the side peek only as a sheet on a phone, beside the page it leaves it usable; all of them return focus to the control that had it.
|
|
128
128
|
- The editor stays an adapter: `nk-block-host` is the shell, `docs-editor.js` the TipTap recipe (used in the demo app). `<nk-editor>` follows in v1.1 as an optional import, never in the core bundle.
|
|
129
129
|
|
|
130
130
|
## Development
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { NkElement } from './base.js';
|
|
2
|
+
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
3
|
+
|
|
4
|
+
// <nk-bookmark href="https://notionkit.jungherz.com" title="NotionKit" desc="β¦"
|
|
5
|
+
// favicon="favicon.svg" cover="og.jpg"></nk-bookmark>
|
|
6
|
+
// β <a class="nk-bookmark" href="β¦" target="_blank" rel="noopener">
|
|
7
|
+
// <span class="bm-text"><span class="bm-title">β¦</span><span class="bm-desc">β¦</span>
|
|
8
|
+
// <span class="bm-url"><img class="bm-favicon"><span>β¦</span></span></span>
|
|
9
|
+
// <span class="bm-cover"><img></span></a>
|
|
10
|
+
// Notion's link block (NotionKit 1.8.0), filled from what you know of the
|
|
11
|
+
// page: og:title, og:description, og:image. `title` is the heading, never a
|
|
12
|
+
// tooltip β see NkElement.takeTitle(); without it the host name stands in.
|
|
13
|
+
// The address shown is `href`, or `url` where it should read differently. A
|
|
14
|
+
// part without a value is left out, not hidden β the stylesheet gives the
|
|
15
|
+
// description its own display. Opens in a new tab unless `target` says so.
|
|
16
|
+
class NkBookmark extends NkElement {
|
|
17
|
+
static get observedAttributes() { return ['href', 'title', 'desc', 'favicon', 'cover', 'target', 'url']; }
|
|
18
|
+
|
|
19
|
+
render() {
|
|
20
|
+
this.takeTitle();
|
|
21
|
+
this._a = this.createElement('a', ['nk-bookmark']);
|
|
22
|
+
this._wrapper.appendChild(this._a);
|
|
23
|
+
this._sync();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_sync() {
|
|
27
|
+
if (!this._a) return;
|
|
28
|
+
const href = this.getAttribute('href') || '';
|
|
29
|
+
this._a.setAttribute('href', href);
|
|
30
|
+
const target = this.getAttribute('target') || '_blank';
|
|
31
|
+
this._a.setAttribute('target', target);
|
|
32
|
+
if (target === '_blank') this._a.setAttribute('rel', 'noopener'); else this._a.removeAttribute('rel');
|
|
33
|
+
let host = href;
|
|
34
|
+
try { host = new URL(href, location.href).host; } catch { /* keep the text */ }
|
|
35
|
+
|
|
36
|
+
const text = this.createElement('span', ['bm-text']);
|
|
37
|
+
const title = this.createElement('span', ['bm-title']);
|
|
38
|
+
title.textContent = this._titleText || host;
|
|
39
|
+
text.appendChild(title);
|
|
40
|
+
const desc = this.getAttribute('desc');
|
|
41
|
+
if (desc) { const d = this.createElement('span', ['bm-desc']); d.textContent = desc; text.appendChild(d); }
|
|
42
|
+
const url = this.createElement('span', ['bm-url']);
|
|
43
|
+
const favicon = this.getAttribute('favicon');
|
|
44
|
+
if (favicon) url.appendChild(this.createElement('img', ['bm-favicon'], { src: favicon, alt: '' }));
|
|
45
|
+
const address = document.createElement('span');
|
|
46
|
+
address.textContent = this.getAttribute('url') || href;
|
|
47
|
+
url.appendChild(address);
|
|
48
|
+
text.appendChild(url);
|
|
49
|
+
const parts = [text];
|
|
50
|
+
const cover = this.getAttribute('cover');
|
|
51
|
+
if (cover) { const c = this.createElement('span', ['bm-cover']); c.appendChild(this.createElement('img', [], { src: cover, alt: '' })); parts.push(c); }
|
|
52
|
+
this._a.replaceChildren(...parts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onAttributeChanged(name, _old, value) {
|
|
56
|
+
if (name === 'title' && !this.takeTitle(value)) return;
|
|
57
|
+
this._sync();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
focus(o) { this._a?.focus(o); }
|
|
61
|
+
get title() { return this._titleText ?? ''; }
|
|
62
|
+
set title(v) { this._titleText = v == null ? '' : String(v); this._sync(); }
|
|
63
|
+
get href() { return this.getAttribute('href') || ''; }
|
|
64
|
+
set href(v) { this.setAttribute('href', v); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
customElements.define('nk-bookmark', NkBookmark);
|
|
68
|
+
|
|
69
|
+
export { NkBookmark };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NkElement } from './base.js';
|
|
2
|
+
import '@jungherz-de/notionkit/notionkit-styles.js';
|
|
3
|
+
|
|
4
|
+
// <nk-copy-field value="https://monahilft.notionkit.app"></nk-copy-field>
|
|
5
|
+
// <nk-copy-field value="ntn_4f2β¦" secret mono></nk-copy-field>
|
|
6
|
+
// β <div class="nk-copy-field mono"><span class="cf-value">β¦</span>
|
|
7
|
+
// <button class="cf-btn">Show</button><button class="cf-btn">Copy</button></div>
|
|
8
|
+
// A value to take along β a link, an address, a key (NotionKit 1.8.0). Copy
|
|
9
|
+
// writes it to the clipboard and says "Copied" in green for a moment; where
|
|
10
|
+
// the clipboard is not allowed, the value is shown and selected instead, for
|
|
11
|
+
// βC. `secret` masks it behind a Show/Hide toggle; Copy still copies the
|
|
12
|
+
// real value. `value` set as a property is not reflected, so a key never
|
|
13
|
+
// lands in the markup. Fires nk-action { action: 'copy', value, ok }. The
|
|
14
|
+
// texts are attributes: copy-label, copied-label, show-label, hide-label.
|
|
15
|
+
class NkCopyField extends NkElement {
|
|
16
|
+
static get observedAttributes() { return ['value', 'mono', 'wrap', 'wide', 'secret', 'copy-label', 'copied-label', 'show-label', 'hide-label']; }
|
|
17
|
+
|
|
18
|
+
render() {
|
|
19
|
+
this._val ??= this.getAttribute('value') ?? '';
|
|
20
|
+
this._box = this.createElement('div', ['nk-copy-field']);
|
|
21
|
+
this._text = this.createElement('span', ['cf-value']);
|
|
22
|
+
this._show = this.createElement('button', ['cf-btn'], { type: 'button' });
|
|
23
|
+
this._copy = this.createElement('button', ['cf-btn'], { type: 'button' });
|
|
24
|
+
this._box.append(this._text, this._copy);
|
|
25
|
+
this._wrapper.appendChild(this._box);
|
|
26
|
+
this._sync();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_sync() {
|
|
30
|
+
if (!this._box) return;
|
|
31
|
+
for (const m of ['mono', 'wrap', 'wide']) this._box.classList.toggle(m, this.getBoolAttr(m));
|
|
32
|
+
const secret = this.getBoolAttr('secret');
|
|
33
|
+
this._text.textContent = secret && !this._revealed ? 'β’'.repeat(Math.min(this._val.length, 24)) : this._val;
|
|
34
|
+
// Not `hidden`: .cf-btn sets no display of its own that could lose to it,
|
|
35
|
+
// but a button that is not there cannot be tabbed to either.
|
|
36
|
+
if (secret && !this._show.isConnected) this._copy.before(this._show);
|
|
37
|
+
if (!secret) this._show.remove();
|
|
38
|
+
this._show.textContent = this._revealed ? (this.getAttribute('hide-label') || 'Hide') : (this.getAttribute('show-label') || 'Show');
|
|
39
|
+
this._show.setAttribute('aria-pressed', String(!!this._revealed));
|
|
40
|
+
this._copy.textContent = this._copied ? (this.getAttribute('copied-label') || 'Copied') : (this.getAttribute('copy-label') || 'Copy');
|
|
41
|
+
this._copy.classList.toggle('copied', !!this._copied);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setupEvents() {
|
|
45
|
+
this._onCopy = () => this.copy();
|
|
46
|
+
this._onShow = () => { this._revealed = !this._revealed; this._sync(); };
|
|
47
|
+
this._copy.addEventListener('click', this._onCopy);
|
|
48
|
+
this._show.addEventListener('click', this._onShow);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
teardownEvents() {
|
|
52
|
+
this._copy?.removeEventListener('click', this._onCopy);
|
|
53
|
+
this._show?.removeEventListener('click', this._onShow);
|
|
54
|
+
clearTimeout(this._timer);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onAttributeChanged(name, _old, value) {
|
|
58
|
+
if (name === 'value') this._val = value ?? '';
|
|
59
|
+
this._sync();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Copies the value; resolves to whether the clipboard took it. */
|
|
63
|
+
async copy() {
|
|
64
|
+
const value = this._val;
|
|
65
|
+
let ok = true;
|
|
66
|
+
try { await navigator.clipboard.writeText(value); } catch { ok = false; }
|
|
67
|
+
if (ok) {
|
|
68
|
+
this._copied = true;
|
|
69
|
+
clearTimeout(this._timer);
|
|
70
|
+
this._timer = setTimeout(() => { this._copied = false; this._sync(); }, 1500);
|
|
71
|
+
} else {
|
|
72
|
+
this._revealed = true;
|
|
73
|
+
}
|
|
74
|
+
this._sync();
|
|
75
|
+
if (!ok) getSelection()?.selectAllChildren(this._text);
|
|
76
|
+
this.emit('nk-action', { action: 'copy', value, ok });
|
|
77
|
+
return ok;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get value() { return this._val ?? this.getAttribute('value') ?? ''; }
|
|
81
|
+
set value(v) { this._val = v == null ? '' : String(v); this._sync(); }
|
|
82
|
+
get secret() { return this.getBoolAttr('secret'); }
|
|
83
|
+
set secret(v) { this.setBoolAttr('secret', v); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
customElements.define('nk-copy-field', NkCopyField);
|
|
87
|
+
|
|
88
|
+
export { NkCopyField };
|
|
@@ -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 };
|
|
@@ -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 };
|