@jungherz-de/notionkit-elements 1.9.0 β†’ 1.10.1

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 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.9.0-2383e2?style=flat-square" alt="Version"></a>
4
+ <a href="https://www.npmjs.com/package/@jungherz-de/notionkit-elements"><img src="https://img.shields.io/badge/version-1.10.1-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-85-529cca?style=flat-square" alt="85 Elements"></a>
6
+ <a href="#"><img src="https://img.shields.io/badge/elements-87-529cca?style=flat-square" alt="87 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.9.0-lightgrey?style=flat-square" alt="Changelog"></a>
8
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.10.1-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
- 85 vanilla JavaScript custom elements wrapping NotionKit's calm, document-centric workspace look.<br>
14
+ 87 vanilla JavaScript custom elements wrapping NotionKit's calm, document-centric workspace look.<br>
15
15
  Shadow DOM &middot; Native form participation &middot; Pixel parity with the class markup &middot; 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 **85 Web Components** – from buttons, form controls and the date picker 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 – a calendar among them – with Notion’s toolbar and AI thread – that encapsulate the class markup of the foundation into simple, declarative custom elements.
31
+ NotionKit Elements is the companion library to [NotionKit CSS](https://github.com/JUNGHERZ/NotionKit). It provides **87 Web Components** – from buttons, form controls and the date picker over the sidebar, page tree and page shell with its properties to the settings modal, the bottom sheet, the side peek, dialogs, tooltips, ⌘K palette, database views – a calendar among them – 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
- | 🧩 **85 Elements** | Forms with copy field, image picker and date 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 & calendar with toolbar and filter pills, comments, AI thread |
59
+ | 🧩 **87 Elements** | Forms with copy field, image picker and date picker, content blocks with bookmarks, app shell & tree with the ☰ drawer and the collapsing sidebar, page shell with properties, panels, avatars and steps, overlays (modal, sheet, dialog, resizable side peek, tooltip, ⌘K palette, menus that float in and become sheets on a phone, popover, emoji picker, toast), database table, board, list & calendar 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** | 248 KB minified / 49 KB gzipped (IIFE, foundation sheet included), no external dependencies |
62
+ | πŸͺΆ **Lightweight** | 265 KB minified / 52 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.9.0/notionkit.min.css">
75
- <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.9.0/dist/notionkit-elements.min.js"></script>
74
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit@1.10.1/notionkit.min.css">
75
+ <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.10.1/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,7 +102,7 @@ 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.9.0`; 1.9.0 brings the date picker and the calendar view, 1.8.0 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.
105
+ Two prerequisites, always: `notionkit.css` on the document (the peer dependency, `>= 1.10.1`; 1.10.1 lets a disabled button show its tooltip, 1.10.0 brings the collapsing sidebar, the resizable side peek, the dialog and the tooltip, 1.9.0 the date picker and the calendar view, 1.8.0 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
 
@@ -112,7 +112,7 @@ Two prerequisites, always: `notionkit.css` on the document (the peer dependency,
112
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-peek` `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-dialog` `nk-peek` `nk-tooltip` `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-calendar-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.
@@ -8,15 +8,22 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
8
8
  // </nk-app>
9
9
  // β†’ <div class="nk-app"><aside class="nk-sidebar">…</aside><main class="nk-main">…</main></div>
10
10
  // The outermost element of a workspace app: a full-height flex row.
11
+ // `peek-inset` (NotionKit 1.10.0) gives the main column a right padding as
12
+ // wide as the side peek – <nk-peek inset> sets it while it is open.
11
13
  class NkApp extends NkElement {
14
+ static get observedAttributes() { return ['peek-inset']; }
15
+
12
16
  render() {
13
- const app = this.createElement('div', ['nk-app']);
17
+ const app = this._app = this.createElement('div', ['nk-app']);
18
+ app.classList.toggle('peek-inset', this.getBoolAttr('peek-inset'));
14
19
  app.appendChild(this.createElement('slot', [], { name: 'sidebar' }));
15
20
  const main = this.createElement('main', ['nk-main']);
16
21
  main.appendChild(document.createElement('slot'));
17
22
  app.appendChild(main);
18
23
  this._wrapper.appendChild(app);
19
24
  }
25
+
26
+ onAttributeChanged() { this._app.classList.toggle('peek-inset', this.getBoolAttr('peek-inset')); }
20
27
  }
21
28
 
22
29
  customElements.define('nk-app', NkApp);
@@ -6,7 +6,8 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
6
6
  // <nk-btn variant="topbar">⭐</nk-btn> β†’ <button class="nk-topbar-btn">
7
7
  // <nk-btn variant="tool" active>Filter</nk-btn> β†’ <button class="nk-db-tool active"> (slot="tools" of nk-database)
8
8
  // <nk-btn variant="sidebar" aria-label="Menu">☰</nk-btn> β†’ <button class="nk-topbar-btn nk-sidebar-toggle">:
9
- // shown on phones only, it opens the page's <nk-sidebar> as a drawer.
9
+ // on a phone it opens the page's <nk-sidebar> as a drawer; on the desktop
10
+ // it shows while that sidebar is collapsed (.collapsed) and expands it.
10
11
  //
11
12
  // Modifier classes become attributes. A slotted <svg> is sized by the
12
13
  // `.nk-btn ::slotted(svg)` twin; pass the icon itself, never wrapped.
@@ -52,11 +53,17 @@ class NkBtn extends NkElement {
52
53
 
53
54
  /** The sidebar the ☰ opens: the one in its own app, else the page's. */
54
55
  _sidebar() { return this.closest('nk-app')?.querySelector('nk-sidebar') ?? document.querySelector('nk-sidebar'); }
56
+ _sidebarCollapsed() { return this.getAttribute('variant') === 'sidebar' && !!this._sidebar()?.hasAttribute('collapsed'); }
55
57
 
56
58
  setupEvents() {
57
59
  this._onClick = (e) => {
58
60
  if (this.getBoolAttr('disabled')) { e.preventDefault(); e.stopPropagation(); return; }
59
- if (this.getAttribute('variant') === 'sidebar') { this._sidebar()?.toggle?.(); return; }
61
+ if (this.getAttribute('variant') === 'sidebar') {
62
+ const sidebar = this._sidebar();
63
+ if (sidebar?.hasAttribute('collapsed') && !matchMedia('(max-width: 860px)').matches) sidebar.expand?.();
64
+ else sidebar?.toggle?.();
65
+ return;
66
+ }
60
67
  // A button inside a shadow root is not a submit button of the outer
61
68
  // form; forward the intent to the form the host sits in.
62
69
  const type = this.getAttribute('type');
@@ -69,13 +76,20 @@ class NkBtn extends NkElement {
69
76
  this._onToggle = (e) => {
70
77
  if (this.getAttribute('variant') === 'sidebar' && e.target === this._sidebar()) this._btn.setAttribute('aria-expanded', e.detail.open ? 'true' : 'false');
71
78
  };
79
+ // On the desktop the ☰ shows while its sidebar is collapsed.
80
+ this._onCollapse = (e) => {
81
+ if (this.getAttribute('variant') === 'sidebar' && e.target === this._sidebar()) this._btn.classList.toggle('collapsed', e.detail.collapsed);
82
+ };
72
83
  this._btn.addEventListener('click', this._onClick);
73
84
  document.addEventListener('nk-toggle', this._onToggle);
85
+ document.addEventListener('nk-collapse', this._onCollapse);
86
+ if (this.getAttribute('variant') === 'sidebar') this._btn.className = this._computeClasses().join(' ');
74
87
  }
75
88
 
76
89
  teardownEvents() {
77
90
  this._btn?.removeEventListener('click', this._onClick);
78
91
  document.removeEventListener('nk-toggle', this._onToggle);
92
+ document.removeEventListener('nk-collapse', this._onCollapse);
79
93
  }
80
94
 
81
95
  onAttributeChanged(name) {
@@ -104,7 +118,7 @@ class NkBtn extends NkElement {
104
118
  const variant = this.getAttribute('variant');
105
119
  if (variant === 'topbar') return ['nk-topbar-btn'];
106
120
  if (variant === 'share') return ['nk-topbar-btn', 'nk-share-btn'];
107
- if (variant === 'sidebar') return ['nk-topbar-btn', 'nk-sidebar-toggle'];
121
+ if (variant === 'sidebar') return this._sidebarCollapsed() ? ['nk-topbar-btn', 'nk-sidebar-toggle', 'collapsed'] : ['nk-topbar-btn', 'nk-sidebar-toggle'];
108
122
  if (variant === 'tool') return this.getBoolAttr('active') ? ['nk-db-tool', 'active'] : ['nk-db-tool'];
109
123
  const classes = ['nk-btn'];
110
124
  if (VARIANTS.includes(variant)) classes.push(variant);
@@ -1,6 +1,6 @@
1
1
  import { NkFormElement } from './base.js';
2
2
  import { p as parseDay, i as isoOf, C as CHEVRONS, r as resolveLocale, f as firstWeekday, m as monthGrid, w as weekLabel, a as weekdayNames, b as isoWeek, s as stepMonth, c as addDays } from './shared/dates-a32DcW1l.js';
3
- import { p as placeUnder } from './shared/floating-B8CwbXnV.js';
3
+ import { p as placeUnder } from './shared/floating-CxHxy-Cb.js';
4
4
  import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
5
5
  import '@jungherz-de/notionkit/notionkit-styles.js';
6
6
 
@@ -0,0 +1,150 @@
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-dialog id="trash" title="Move β€œRoadmap” to trash?" alert>
6
+ // The page and its sub-pages can be restored from Trash for 30 days.
7
+ // <nk-btn slot="actions" variant="secondary" value="">Cancel</nk-btn>
8
+ // <nk-btn slot="actions" variant="danger-solid" value="trash">Move to trash</nk-btn>
9
+ // </nk-dialog>
10
+ // β†’ <div class="nk-dialog-backdrop open"><div class="nk-dialog" role="alertdialog">
11
+ // <div class="dl-title">…</div><div class="dl-body">…</div><div class="dl-actions">…</div></div></div>
12
+ //
13
+ // A question or a short form (NotionKit 1.10.0) with the contract of
14
+ // <nk-modal> and <nk-sheet>: show(), close(), toggle(), nk-toggle; Escape –
15
+ // captured, so it closes before a peek, a menu or a modal behind it – and
16
+ // the backdrop close it; focus moves in, to an [autofocus] element or the
17
+ // first field or button, and back out; the page behind is inert and
18
+ // scroll-locked. It lies above the modal and the sheet, so a question can
19
+ // come from either. 440px, `wide` 560px; below 860px a bottom sheet with the
20
+ // buttons stacked, the confirming one on top.
21
+ // A button in slot="actions" with a `value` closes it with that value; so
22
+ // does the submit of a <form method="dialog"> inside, with its submitter's
23
+ // value. Before it closes, nk-close { value } fires and can be cancelled –
24
+ // the place to check an input dialog; Escape and the backdrop close with ''.
25
+ // `returnValue` keeps the last value. `alert` makes it an alertdialog, named
26
+ // and described by its title and text. `title` is the heading, never a
27
+ // tooltip – see NkElement.takeTitle(). Put it directly under <body>.
28
+ class NkDialog extends NkElement {
29
+ static get observedAttributes() { return ['open', 'title', 'wide', 'alert']; }
30
+
31
+ render() {
32
+ this.takeTitle();
33
+ this.returnValue = '';
34
+ this._backdrop = this.createElement('div', ['nk-dialog-backdrop']);
35
+ this._backdrop.inert = true;
36
+ this._box = this.createElement('div', ['nk-dialog'], { 'aria-modal': 'true', tabindex: '-1' });
37
+ this._heading = this.createElement('div', ['dl-title'], { id: 'title' });
38
+ const body = this.createElement('div', ['dl-body'], { id: 'body' });
39
+ body.appendChild(document.createElement('slot'));
40
+ this._actions = this.createElement('div', ['dl-actions']);
41
+ this._actionsSlot = this.createElement('slot', [], { name: 'actions' });
42
+ this._actions.appendChild(this._actionsSlot);
43
+ this._box.append(this._heading, body, this._actions);
44
+ this._backdrop.appendChild(this._box);
45
+ this._wrapper.appendChild(this._backdrop);
46
+ this._syncTitle();
47
+ this._syncMode();
48
+ this._syncOpen();
49
+ }
50
+
51
+ _syncTitle() {
52
+ const text = this._titleText || '';
53
+ this._heading.textContent = text;
54
+ this._heading.hidden = !text;
55
+ if (text) this._box.setAttribute('aria-labelledby', 'title');
56
+ else this._box.removeAttribute('aria-labelledby');
57
+ }
58
+
59
+ _syncMode() {
60
+ const alert = this.getBoolAttr('alert');
61
+ this._box.setAttribute('role', alert ? 'alertdialog' : 'dialog');
62
+ if (alert) this._box.setAttribute('aria-describedby', 'body'); else this._box.removeAttribute('aria-describedby');
63
+ this._box.classList.toggle('wide', this.getBoolAttr('wide'));
64
+ }
65
+
66
+ _syncOpen() {
67
+ const open = this.getBoolAttr('open');
68
+ this._backdrop.classList.toggle('open', open);
69
+ this._backdrop.inert = !open;
70
+ if (open === this._wasOpen) return;
71
+ this._wasOpen = open;
72
+ if (open) {
73
+ this._returnFocus = this._from ?? deepActiveElement();
74
+ this._from = null;
75
+ // An overlay behind – a modal – made this host inert; the dialog lifts that while it is open.
76
+ this._wasInert = this.inert;
77
+ this.inert = false;
78
+ lockScroll();
79
+ this._undoInert = inertOutside(this);
80
+ requestAnimationFrame(() => (this.querySelector('[autofocus]') || firstFocusable(this) || this._box).focus({ preventScroll: true }));
81
+ } else {
82
+ unlockScroll();
83
+ this._undoInert?.(); this._undoInert = null;
84
+ this.inert = this._wasInert;
85
+ const back = this._returnFocus;
86
+ this._returnFocus = null;
87
+ if (back?.isConnected && typeof back.focus === 'function') back.focus({ preventScroll: true });
88
+ }
89
+ }
90
+
91
+ setupEvents() {
92
+ this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(''); };
93
+ this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(''); } };
94
+ // A button with a value closes it; a submit button is the form's.
95
+ this._onClick = (e) => {
96
+ const button = e.composedPath().find(n => n instanceof Element && n !== this && this.contains(n) && n.hasAttribute('value') && (n.localName === 'nk-btn' || n.localName === 'button'));
97
+ if (!button || button.getAttribute('type') === 'submit') return;
98
+ this.close(button.getAttribute('value'));
99
+ };
100
+ this._onSubmit = (e) => {
101
+ if ((e.target.getAttribute('method') || '').toLowerCase() !== 'dialog') return;
102
+ e.preventDefault();
103
+ this.close(e.submitter?.value || e.submitter?.getAttribute?.('value') || 'submit');
104
+ };
105
+ this._onSlot = () => { this._actions.hidden = !this._actionsSlot.assignedElements().length; };
106
+ this._backdrop.addEventListener('click', this._onBackdrop);
107
+ this.addEventListener('click', this._onClick);
108
+ this.addEventListener('submit', this._onSubmit);
109
+ this._actionsSlot.addEventListener('slotchange', this._onSlot);
110
+ document.addEventListener('keydown', this._onKey, true);
111
+ this._onSlot();
112
+ }
113
+
114
+ teardownEvents() {
115
+ this._backdrop?.removeEventListener('click', this._onBackdrop);
116
+ this.removeEventListener('click', this._onClick);
117
+ this.removeEventListener('submit', this._onSubmit);
118
+ this._actionsSlot?.removeEventListener('slotchange', this._onSlot);
119
+ document.removeEventListener('keydown', this._onKey, true);
120
+ if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
121
+ }
122
+
123
+ onAttributeChanged(name, oldValue, value) {
124
+ if (name === 'title') { if (this.takeTitle(value)) this._syncTitle(); return; }
125
+ if (name !== 'open') { this._syncMode(); return; }
126
+ this._syncOpen();
127
+ this.emit('nk-toggle', { open: this.getBoolAttr('open') });
128
+ }
129
+
130
+ /** Opens it; focus goes back to `from` when it closes (default: what had focus – pass the β‹― of a menu that closes). */
131
+ show(from) { this.returnValue = ''; this._from = from ?? null; this.setBoolAttr('open', true); }
132
+ /** Closes with `value`, unless an nk-close listener cancels it. Returns whether it closed. */
133
+ close(value = '') {
134
+ if (!this.getBoolAttr('open')) return false;
135
+ if (!this.emit('nk-close', { value })) return false;
136
+ this.returnValue = value;
137
+ this.setBoolAttr('open', false);
138
+ return true;
139
+ }
140
+ toggle() { this.getBoolAttr('open') ? this.close('') : this.show(); }
141
+
142
+ get open() { return this.getBoolAttr('open'); }
143
+ set open(v) { v ? this.show() : this.close(''); }
144
+ get title() { return this._titleText ?? ''; }
145
+ set title(v) { this._titleText = v == null ? '' : String(v); if (this._initialized) this._syncTitle(); }
146
+ }
147
+
148
+ customElements.define('nk-dialog', NkDialog);
149
+
150
+ export { NkDialog };
@@ -1,6 +1,6 @@
1
1
  import { NkElement } from './base.js';
2
2
  import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
3
- import { p as placeUnder } from './shared/floating-B8CwbXnV.js';
3
+ import { p as placeUnder } from './shared/floating-CxHxy-Cb.js';
4
4
  import '@jungherz-de/notionkit/notionkit-styles.js';
5
5
 
6
6
  // <nk-menu>
@@ -21,17 +21,28 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
21
21
  // the peek) leave it open. Below 860px it is a bottom sheet over a dimmed
22
22
  // page; there it is modal: the page is inert and scroll-locked, the dimmed
23
23
  // page takes the tap that closes it. Put it directly under <body>.
24
+ //
25
+ // `resizable` (NotionKit 1.10.0): the left edge takes the pointer – and the
26
+ // arrow keys, 16px a step – to make the peek wider or narrower, as in
27
+ // Notion: from `min` (380) to `max` (all but 320px of the window). The width
28
+ // is the token --nk-peek-width, set on :root; `width` (px) sets it too, and
29
+ // nk-resize { width } fires when a drag or a key ends – the moment to keep
30
+ // it. resize-label names the edge (default "Resize").
31
+ // `inset`: while it is open on the desktop, the page's <nk-app> gets
32
+ // `peek-inset` – its main column makes room instead of lying under the peek.
24
33
  const CLOSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 17 5-5-5-5M13 17l5-5-5-5"/></svg>';
25
- const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-cmdk', 'nk-toast']);
26
- const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-cmdk-backdrop'];
34
+ const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-dialog', 'nk-cmdk', 'nk-toast']);
35
+ const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-dialog-backdrop', 'nk-cmdk-backdrop'];
27
36
  const phone = () => matchMedia('(max-width: 860px)').matches;
28
37
 
29
38
  class NkPeek extends NkElement {
30
- static get observedAttributes() { return ['open', 'label', 'close-label']; }
39
+ static get observedAttributes() { return ['open', 'label', 'close-label', 'resizable', 'width', 'min', 'max', 'resize-label', 'inset']; }
31
40
 
32
41
  render() {
33
42
  this._backdrop = this.createElement('div', ['nk-peek-backdrop']);
34
43
  this._box = this.createElement('aside', ['nk-peek'], { role: 'dialog', tabindex: '-1' });
44
+ this._handle = this.createElement('div', ['pk-resize'], { role: 'separator', 'aria-orientation': 'vertical', tabindex: '0' });
45
+ this._box.appendChild(this._handle);
35
46
  const bar = this.createElement('div', ['pk-bar']);
36
47
  this._close = this.createElement('button', ['nk-topbar-btn'], { type: 'button' });
37
48
  this._close.innerHTML = CLOSE_ICON;
@@ -42,9 +53,41 @@ class NkPeek extends NkElement {
42
53
  this._backdrop.appendChild(this._box);
43
54
  this._wrapper.appendChild(this._backdrop);
44
55
  this._syncLabels();
56
+ this._syncResize();
57
+ if (this.hasAttribute('width')) this._setWidth(Number(this.getAttribute('width')));
45
58
  this._syncOpen();
46
59
  }
47
60
 
61
+ // ── width ──
62
+ get _min() { return Number(this.getAttribute('min')) || 380; }
63
+ get _max() { return Number(this.getAttribute('max')) || Math.max(this._min, innerWidth - 320); }
64
+ /** The peek's width in px – the token --nk-peek-width. */
65
+ get width() { return parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nk-peek-width')) || 560; }
66
+ set width(v) { this._setWidth(Number(v)); }
67
+
68
+ _setWidth(w) {
69
+ if (!Number.isFinite(w)) return;
70
+ w = Math.round(Math.min(Math.max(w, this._min), Math.max(this._min, this._max)));
71
+ document.documentElement.style.setProperty('--nk-peek-width', `${w}px`);
72
+ this._handle.setAttribute('aria-valuenow', w);
73
+ }
74
+
75
+ _syncResize() {
76
+ this._handle.hidden = !this.getBoolAttr('resizable');
77
+ this._handle.setAttribute('aria-label', this.getAttribute('resize-label') || 'Resize');
78
+ this._handle.setAttribute('aria-valuemin', this._min);
79
+ this._handle.setAttribute('aria-valuenow', Math.round(this.width));
80
+ }
81
+
82
+ /** The app whose main column makes room: the peek's own, else the page's. */
83
+ _app() { return this.closest('nk-app') ?? document.querySelector('nk-app'); }
84
+ _syncInset() {
85
+ const on = this.getBoolAttr('inset') && this.getBoolAttr('open') && !phone();
86
+ if (on) this._app()?.setAttribute('peek-inset', '');
87
+ else if (this._insetApp) this._app()?.removeAttribute('peek-inset');
88
+ this._insetApp = on;
89
+ }
90
+
48
91
  _syncLabels() {
49
92
  const label = this.getAttribute('label');
50
93
  if (label) this._box.setAttribute('aria-label', label); else this._box.removeAttribute('aria-label');
@@ -54,6 +97,7 @@ class NkPeek extends NkElement {
54
97
  _syncOpen() {
55
98
  const open = this.getBoolAttr('open');
56
99
  this._backdrop.classList.toggle('open', open);
100
+ this._syncInset();
57
101
  if (open === this._wasOpen) return;
58
102
  this._wasOpen = open;
59
103
  if (open) {
@@ -88,23 +132,54 @@ class NkPeek extends NkElement {
88
132
  if (path.some(n => n.nodeType === 1 && (OVERLAYS.has(n.localName) || OVERLAY_CLASSES.some(c => n.classList.contains(c))))) return;
89
133
  this.close();
90
134
  };
135
+ // The left edge: a drag with pointer capture, or the arrow keys.
136
+ const resized = () => this.emit('nk-resize', { width: Math.round(this.width) });
137
+ this._onResizeStart = (e) => {
138
+ if (e.button !== 0) return;
139
+ e.preventDefault();
140
+ this._handle.setPointerCapture(e.pointerId);
141
+ this._handle.classList.add('active');
142
+ };
143
+ this._onResizeMove = (e) => { if (this._handle.hasPointerCapture(e.pointerId)) this._setWidth(innerWidth - e.clientX); };
144
+ this._onResizeEnd = () => { this._handle.classList.remove('active'); resized(); };
145
+ this._onResizeKey = (e) => {
146
+ const step = { ArrowLeft: 16, ArrowRight: -16 }[e.key];
147
+ if (!step) return;
148
+ e.preventDefault();
149
+ this._setWidth(this.width + step);
150
+ resized();
151
+ };
152
+ this._onViewport = () => this._syncInset();
91
153
  this._close.addEventListener('click', this._onClose);
92
154
  this._backdrop.addEventListener('click', this._onBackdrop);
155
+ this._handle.addEventListener('pointerdown', this._onResizeStart);
156
+ this._handle.addEventListener('pointermove', this._onResizeMove);
157
+ this._handle.addEventListener('lostpointercapture', this._onResizeEnd);
158
+ this._handle.addEventListener('keydown', this._onResizeKey);
93
159
  document.addEventListener('keydown', this._onKey);
94
160
  document.addEventListener('click', this._onOutside);
161
+ addEventListener('resize', this._onViewport);
95
162
  }
96
163
 
97
164
  teardownEvents() {
98
165
  this._close?.removeEventListener('click', this._onClose);
99
166
  this._backdrop?.removeEventListener('click', this._onBackdrop);
167
+ this._handle?.removeEventListener('pointerdown', this._onResizeStart);
168
+ this._handle?.removeEventListener('pointermove', this._onResizeMove);
169
+ this._handle?.removeEventListener('lostpointercapture', this._onResizeEnd);
170
+ this._handle?.removeEventListener('keydown', this._onResizeKey);
100
171
  document.removeEventListener('keydown', this._onKey);
101
172
  document.removeEventListener('click', this._onOutside);
173
+ removeEventListener('resize', this._onViewport);
174
+ if (this._insetApp) { this._app()?.removeAttribute('peek-inset'); this._insetApp = false; }
102
175
  if (this._modal) { this._modal = false; unlockScroll(); this._undoInert?.(); this._undoInert = null; }
103
176
  this._wasOpen = false;
104
177
  }
105
178
 
106
- onAttributeChanged(name) {
107
- if (name !== 'open') { this._syncLabels(); return; }
179
+ onAttributeChanged(name, _old, value) {
180
+ if (name === 'width') { this._setWidth(Number(value)); return; }
181
+ if (name === 'inset') { this._syncInset(); return; }
182
+ if (name !== 'open') { this._syncLabels(); this._syncResize(); return; }
108
183
  this._syncOpen();
109
184
  this.emit('nk-toggle', { open: this.getBoolAttr('open') });
110
185
  }
@@ -1,4 +1,5 @@
1
1
  import { NkElement } from './base.js';
2
+ import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
2
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
3
4
 
4
5
  // <nk-sidebar slot="sidebar">
@@ -16,13 +17,28 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
16
17
  // .open) – the sidebar slides and the scrim fades by the same rules as the
17
18
  // class markup; the element toggles the two classes. <nk-btn variant="sidebar">
18
19
  // in the topbar is the ☰ that opens it.
20
+ //
21
+ // `collapsible` (NotionKit 1.10.0): on the desktop Notion's Β« beside the
22
+ // workspace row collapses the sidebar – it slides out to the left, the main
23
+ // column takes the width, nothing in it takes focus – and the topbar's ☰
24
+ // brings it back; ⌘\ or Ctrl+\ toggles it (on a phone the drawer).
25
+ // `collapsed` is the state, collapse() / expand() / toggleCollapsed() the
26
+ // API, nk-collapse { collapsed } the moment to keep it. collapse-label names
27
+ // the Β« (default "Close sidebar") and is its tooltip for <nk-tooltip>.
28
+ const COLLAPSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m18 17-5-5 5-5M11 17l-5-5 5-5"/></svg>';
29
+ const phone = () => matchMedia('(max-width: 860px)').matches;
30
+
19
31
  class NkSidebar extends NkElement {
20
- static get observedAttributes() { return ['open']; }
32
+ static get observedAttributes() { return ['open', 'collapsible', 'collapsed', 'collapse-label']; }
21
33
 
22
34
  render() {
23
35
  this._backdrop = this.createElement('div', ['nk-sidebar-backdrop']);
24
36
  this._aside = this.createElement('aside', ['nk-sidebar']);
25
- this._aside.appendChild(this.createElement('slot', [], { name: 'workspace' }));
37
+ const head = this.createElement('div', ['nk-sidebar-head']);
38
+ this._collapse = this.createElement('button', ['nk-sidebar-collapse'], { type: 'button', 'data-tooltip-key': '⌘\\' });
39
+ this._collapse.innerHTML = COLLAPSE_ICON;
40
+ head.append(this.createElement('slot', [], { name: 'workspace' }), this._collapse);
41
+ this._aside.appendChild(head);
26
42
  const scroll = this.createElement('div', ['nk-sidebar-scroll']);
27
43
  scroll.appendChild(document.createElement('slot'));
28
44
  this._aside.appendChild(scroll);
@@ -34,6 +50,21 @@ class NkSidebar extends NkElement {
34
50
  this._wrapper.appendChild(this._aside);
35
51
  this._syncFooter();
36
52
  this._syncOpen();
53
+ this._syncCollapse();
54
+ }
55
+
56
+ _syncCollapse() {
57
+ const label = this.getAttribute('collapse-label') || 'Close sidebar';
58
+ this._collapse.hidden = !this.getBoolAttr('collapsible');
59
+ this._collapse.setAttribute('aria-label', label);
60
+ this._collapse.dataset.tooltip = label;
61
+ this._aside.classList.toggle('collapsed', this.getBoolAttr('collapsed'));
62
+ }
63
+
64
+ /** The ☰ that brings the sidebar back: the one in its own app, else the page's. */
65
+ _toggleButton() {
66
+ const sel = 'nk-btn[variant="sidebar"]';
67
+ return this.closest('nk-app')?.querySelector(sel) ?? document.querySelector(sel);
37
68
  }
38
69
 
39
70
  _syncOpen() {
@@ -53,22 +84,47 @@ class NkSidebar extends NkElement {
53
84
  setupEvents() {
54
85
  this._onSlot = () => this._syncFooter();
55
86
  this._onBackdrop = () => this.close();
56
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
87
+ this._onKey = (e) => {
88
+ if (e.key === 'Escape' && this.getBoolAttr('open')) this.close();
89
+ // Notion's ⌘\: the collapse on the desktop, the drawer on a phone.
90
+ if (e.key === '\\' && (e.metaKey || e.ctrlKey) && this.getBoolAttr('collapsible') && !e.defaultPrevented) {
91
+ e.preventDefault();
92
+ phone() ? this.toggle() : this.toggleCollapsed();
93
+ }
94
+ };
95
+ this._onCollapse = () => this.collapse();
57
96
  this._footerSlot.addEventListener('slotchange', this._onSlot);
58
97
  this._backdrop.addEventListener('click', this._onBackdrop);
98
+ this._collapse.addEventListener('click', this._onCollapse);
59
99
  document.addEventListener('keydown', this._onKey);
60
100
  }
61
101
 
62
102
  teardownEvents() {
63
103
  this._footerSlot?.removeEventListener('slotchange', this._onSlot);
64
104
  this._backdrop?.removeEventListener('click', this._onBackdrop);
105
+ this._collapse?.removeEventListener('click', this._onCollapse);
65
106
  document.removeEventListener('keydown', this._onKey);
66
107
  }
67
108
 
68
109
  onAttributeChanged(name) {
69
- if (name !== 'open') return;
70
- this._syncOpen();
71
- this.emit('nk-toggle', { open: this.getBoolAttr('open') });
110
+ if (name === 'open') {
111
+ this._syncOpen();
112
+ this.emit('nk-toggle', { open: this.getBoolAttr('open') });
113
+ return;
114
+ }
115
+ // Focus follows to the control that is left: the ☰ after collapsing, the « after expanding.
116
+ const active = deepActiveElement();
117
+ const fromSidebar = active && (this.contains(active) || this._shadow.contains(active));
118
+ const fromToggle = active?.closest?.('.nk-sidebar-toggle');
119
+ this._syncCollapse();
120
+ if (name !== 'collapsed') return;
121
+ const collapsed = this.getBoolAttr('collapsed');
122
+ this.emit('nk-collapse', { collapsed });
123
+ // Two frames later: with reduced motion every nk- element transitions for
124
+ // .01ms, the inherited visibility included, so the Β« is focusable only then.
125
+ const later = fn => requestAnimationFrame(() => requestAnimationFrame(fn));
126
+ if (collapsed && fromSidebar) later(() => this._toggleButton()?.focus({ preventScroll: true }));
127
+ if (!collapsed && fromToggle && !this._collapse.hidden) later(() => this._collapse.focus({ preventScroll: true }));
72
128
  }
73
129
 
74
130
  show() { this.setBoolAttr('open', true); }
@@ -77,6 +133,12 @@ class NkSidebar extends NkElement {
77
133
 
78
134
  get open() { return this.getBoolAttr('open'); }
79
135
  set open(v) { this.setBoolAttr('open', v); }
136
+
137
+ collapse() { this.setBoolAttr('collapsed', true); }
138
+ expand() { this.setBoolAttr('collapsed', false); }
139
+ toggleCollapsed() { this.setBoolAttr('collapsed', !this.getBoolAttr('collapsed')); }
140
+ get collapsed() { return this.getBoolAttr('collapsed'); }
141
+ set collapsed(v) { this.setBoolAttr('collapsed', v); }
80
142
  }
81
143
 
82
144
  customElements.define('nk-sidebar', NkSidebar);