@jungherz-de/notionkit-elements 1.6.0 β†’ 1.7.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 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.6.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.7.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-77-529cca?style=flat-square" alt="77 Elements"></a>
6
+ <a href="#"><img src="https://img.shields.io/badge/elements-79-529cca?style=flat-square" alt="79 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.6.0-lightgrey?style=flat-square" alt="Changelog"></a>
8
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.7.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
- 77 vanilla JavaScript custom elements wrapping NotionKit's calm, document-centric workspace look.<br>
14
+ 79 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 **77 Web Components** – from buttons and form controls over the sidebar, page tree and page shell with its properties to the settings modal, ⌘K palette, database views 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 **79 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, ⌘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
- | 🧩 **77 Elements** | Forms, content blocks, app shell & tree, page shell with properties, panels and avatars, overlays (modal, ⌘K palette, menus, popover, emoji picker, toast), database table, board & list, comments, AI thread |
59
+ | 🧩 **79 Elements** | Forms, content blocks, app shell & tree with the ☰ drawer, page shell with properties, panels, avatars and steps, overlays (modal, sheet, ⌘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** | 197 KB minified / 39 KB gzipped (IIFE, foundation sheet included), no external dependencies |
62
+ | πŸͺΆ **Lightweight** | 211 KB minified / 41 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.6.0/notionkit.min.css">
75
- <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.6.0/dist/notionkit-elements.min.js"></script>
74
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit@1.7.1/notionkit.min.css">
75
+ <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.7.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,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.6.0`; 1.6.0 brings 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.7.1`; 1.7.0 brings 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
 
@@ -111,8 +111,8 @@ Two prerequisites, always: `notionkit.css` on the document (the peer dependency,
111
111
  | Forms & controls | `nk-btn` `nk-input` `nk-textarea` `nk-select` `nk-switch` `nk-check` `nk-radio` `nk-slider` `nk-field` `nk-fields` |
112
112
  | Content | `nk-tag` `nk-progress` `nk-callout` `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
- | 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-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-settings-pane` `nk-settings-user` `nk-cmdk` `nk-menu` `nk-menu-item` `nk-pop` `nk-emoji-picker` `nk-toast` |
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` |
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-cmdk`, `nk-toast`) go directly under `<body>`; they lock scroll, make the rest of the page `inert` and return focus.
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`; they and the floating menu 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
@@ -4,13 +4,16 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
4
4
  // <nk-btn variant="primary" small>Save</nk-btn> β†’ <button class="nk-btn primary small">
5
5
  // <nk-btn href="/docs" variant="secondary">Docs</nk-btn> β†’ <a class="nk-btn secondary">
6
6
  // <nk-btn variant="topbar">⭐</nk-btn> β†’ <button class="nk-topbar-btn">
7
+ // <nk-btn variant="tool" active>Filter</nk-btn> β†’ <button class="nk-db-tool active"> (slot="tools" of nk-database)
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.
7
10
  //
8
11
  // Modifier classes become attributes. A slotted <svg> is sized by the
9
12
  // `.nk-btn ::slotted(svg)` twin; pass the icon itself, never wrapped.
10
13
  const VARIANTS = ['primary', 'secondary', 'danger', 'danger-solid', 'topbar', 'share'];
11
14
 
12
15
  class NkBtn extends NkElement {
13
- static get observedAttributes() { return ['variant', 'small', 'disabled', 'type', 'href', 'title', 'aria-label']; }
16
+ static get observedAttributes() { return ['variant', 'small', 'disabled', 'type', 'href', 'title', 'aria-label', 'aria-haspopup', 'active']; }
14
17
 
15
18
  render() {
16
19
  this._build();
@@ -38,13 +41,22 @@ class NkBtn extends NkElement {
38
41
  else el.setAttribute('aria-disabled', disabled ? 'true' : 'false');
39
42
  const title = this.getAttribute('title');
40
43
  title ? el.setAttribute('title', title) : el.removeAttribute('title');
41
- const label = this.getAttribute('aria-label');
42
- label ? el.setAttribute('aria-label', label) : el.removeAttribute('aria-label');
44
+ // The accessible name and the popup hint belong on the button itself.
45
+ for (const name of ['aria-label', 'aria-haspopup']) {
46
+ const v = this.getAttribute(name);
47
+ v ? el.setAttribute(name, v) : el.removeAttribute(name);
48
+ }
49
+ if (this.getAttribute('variant') === 'sidebar') el.setAttribute('aria-expanded', this._sidebar()?.open ? 'true' : 'false');
50
+ else el.removeAttribute('aria-expanded');
43
51
  }
44
52
 
53
+ /** The sidebar the ☰ opens: the one in its own app, else the page's. */
54
+ _sidebar() { return this.closest('nk-app')?.querySelector('nk-sidebar') ?? document.querySelector('nk-sidebar'); }
55
+
45
56
  setupEvents() {
46
57
  this._onClick = (e) => {
47
58
  if (this.getBoolAttr('disabled')) { e.preventDefault(); e.stopPropagation(); return; }
59
+ if (this.getAttribute('variant') === 'sidebar') { this._sidebar()?.toggle?.(); return; }
48
60
  // A button inside a shadow root is not a submit button of the outer
49
61
  // form; forward the intent to the form the host sits in.
50
62
  const type = this.getAttribute('type');
@@ -53,11 +65,17 @@ class NkBtn extends NkElement {
53
65
  if (form) type === 'submit' ? form.requestSubmit() : form.reset();
54
66
  }
55
67
  };
68
+ // The ☰ says whether its drawer is open, however the drawer closed.
69
+ this._onToggle = (e) => {
70
+ if (this.getAttribute('variant') === 'sidebar' && e.target === this._sidebar()) this._btn.setAttribute('aria-expanded', e.detail.open ? 'true' : 'false');
71
+ };
56
72
  this._btn.addEventListener('click', this._onClick);
73
+ document.addEventListener('nk-toggle', this._onToggle);
57
74
  }
58
75
 
59
76
  teardownEvents() {
60
77
  this._btn?.removeEventListener('click', this._onClick);
78
+ document.removeEventListener('nk-toggle', this._onToggle);
61
79
  }
62
80
 
63
81
  onAttributeChanged(name) {
@@ -67,7 +85,11 @@ class NkBtn extends NkElement {
67
85
  this._build();
68
86
  break;
69
87
  case 'variant':
88
+ this._btn.className = this._computeClasses().join(' ');
89
+ this._applyState(this._btn);
90
+ break;
70
91
  case 'small':
92
+ case 'active':
71
93
  this._btn.className = this._computeClasses().join(' ');
72
94
  break;
73
95
  case 'type':
@@ -82,6 +104,8 @@ class NkBtn extends NkElement {
82
104
  const variant = this.getAttribute('variant');
83
105
  if (variant === 'topbar') return ['nk-topbar-btn'];
84
106
  if (variant === 'share') return ['nk-topbar-btn', 'nk-share-btn'];
107
+ if (variant === 'sidebar') return ['nk-topbar-btn', 'nk-sidebar-toggle'];
108
+ if (variant === 'tool') return this.getBoolAttr('active') ? ['nk-db-tool', 'active'] : ['nk-db-tool'];
85
109
  const classes = ['nk-btn'];
86
110
  if (VARIANTS.includes(variant)) classes.push(variant);
87
111
  if (this.getBoolAttr('small')) classes.push('small');
@@ -93,6 +117,8 @@ class NkBtn extends NkElement {
93
117
 
94
118
  get variant() { return this.getAttribute('variant'); }
95
119
  set variant(v) { v ? this.setAttribute('variant', v) : this.removeAttribute('variant'); }
120
+ get active() { return this.getBoolAttr('active'); }
121
+ set active(v) { this.setBoolAttr('active', v); }
96
122
  get small() { return this.getBoolAttr('small'); }
97
123
  set small(v) { this.setBoolAttr('small', v); }
98
124
  get disabled() { return this.getBoolAttr('disabled'); }
@@ -1,5 +1,5 @@
1
1
  import { NkElement } from './base.js';
2
- import { l as lockScroll, i as inertOutside, u as unlockScroll } from './shared/focus-DXrK9oLe.js';
2
+ import { l as lockScroll, i as inertOutside, u as unlockScroll } from './shared/focus-BNAChOXO.js';
3
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
4
 
5
5
  // Subsequence scoring for the command palette: every query character must
@@ -7,16 +7,24 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
7
7
  // </nk-database>
8
8
  // db.columns = [{ key: 'name', label: 'Name', type: 'text', icon: 'πŸ“„', title: true }, { key: 'status', type: 'select', options: [...] }, …];
9
9
  // db.rows = [{ id: 1, icon: '🧭', name: '…', status: 'done', … }];
10
- // β†’ <div class="nk-database"><div class="nk-db-tabs">…</div>…active view…</div>
10
+ // <nk-btn slot="tools" variant="tool">Filter</nk-btn> … sit right of the tabs,
11
+ // <nk-filter-bar slot="filters"> under them (NotionKit 1.7.0's toolbar).
12
+ // β†’ <div class="nk-database"><div class="nk-db-toolbar"><div class="nk-db-tabs">…</div>
13
+ // <div class="tools">…</div></div>…filters…active view…</div>
11
14
  // The tabs come from the child views; data is pushed into every view.
12
15
  class NkDatabase extends NkElement {
13
16
  static get observedAttributes() { return ['view', 'add-view']; }
14
17
 
15
18
  render() {
16
19
  this._box = this.createElement('div', ['nk-database']);
20
+ this._toolbar = this.createElement('div', ['nk-db-toolbar']);
17
21
  this._tabs = this.createElement('div', ['nk-db-tabs'], { role: 'tablist' });
22
+ const tools = this.createElement('div', ['tools']);
23
+ this._toolsSlot = this.createElement('slot', [], { name: 'tools' });
24
+ tools.appendChild(this._toolsSlot);
25
+ this._toolbar.append(this._tabs, tools);
18
26
  this._slot = document.createElement('slot');
19
- this._box.append(this._tabs, this._slot);
27
+ this._box.append(this._toolbar, this.createElement('slot', [], { name: 'filters' }), this._slot);
20
28
  this._wrapper.appendChild(this._box);
21
29
  this._columns = []; this._rows = [];
22
30
  this._sync();
@@ -44,12 +52,11 @@ class NkDatabase extends NkElement {
44
52
  v.setData?.(this._columns, this._rows);
45
53
  }
46
54
  if (this.getBoolAttr('add-view')) {
47
- const add = this.createElement('span', ['nk-db-tab'], { role: 'button', 'data-add': '', tabindex: '0' });
48
- add.style.color = 'var(--nk-text-tertiary)';
55
+ const add = this.createElement('span', ['nk-db-tab', 'add'], { role: 'button', 'data-add': '', tabindex: '0' });
49
56
  add.textContent = 'οΌ‹';
50
57
  this._tabs.appendChild(add);
51
58
  }
52
- this._tabs.style.display = views.length ? '' : 'none';
59
+ this._toolbar.hidden = !views.length && !this._toolsSlot.assignedElements().length;
53
60
  this._current = current;
54
61
  }
55
62
 
@@ -71,6 +78,7 @@ class NkDatabase extends NkElement {
71
78
  this._tabs.addEventListener('click', this._onClick);
72
79
  this._tabs.addEventListener('keydown', this._onKey);
73
80
  this._slot.addEventListener('slotchange', this._onSlot);
81
+ this._toolsSlot.addEventListener('slotchange', this._onSlot);
74
82
  this._sync();
75
83
  }
76
84
 
@@ -78,6 +86,7 @@ class NkDatabase extends NkElement {
78
86
  this._tabs?.removeEventListener('click', this._onClick);
79
87
  this._tabs?.removeEventListener('keydown', this._onKey);
80
88
  this._slot?.removeEventListener('slotchange', this._onSlot);
89
+ this._toolsSlot?.removeEventListener('slotchange', this._onSlot);
81
90
  }
82
91
 
83
92
  onAttributeChanged(name) {
@@ -1,61 +1,77 @@
1
1
  import { NkElement } from './base.js';
2
2
  import '@jungherz-de/notionkit/notionkit-styles.js';
3
3
 
4
- // <nk-filter-bar search placeholder="Search rows …"></nk-filter-bar>
5
- // bar.filters = [{ key: 'status', label: 'Status: Done', value: 'done' }];
6
- // Composed from existing classes (nk-btn secondary small, nk-input, nk-tag):
7
- // filter / sort buttons fire nk-action, chips remove on Γ—, search and chip
8
- // changes fire nk-change { filters, search }. The wrapper is layout only.
4
+ // <nk-filter-bar add no-filter no-sort></nk-filter-bar>
5
+ // bar.filters = [{ key: 'status', value: 'done', op: 'is-not', label: 'Status: Open' }];
6
+ // The filters in effect as NotionKit's filter pills (1.7.0), no inline style:
7
+ // β†’ <div class="nk-filter-row">[Filter][Sort]
8
+ // <span class="nk-filter-pill active"><button>Status: Open</button><button class="fp-remove">Γ—</button></span>…
9
+ // <button class="nk-filter-pill add">οΌ‹ Filter</button> …slot… [search]</div>
10
+ // Filter and Sort are .nk-db-tool buttons (hide them with no-filter / no-sort
11
+ // when the database toolbar carries its own); they fire nk-action. A pill's
12
+ // label fires nk-action { action: 'edit', index, filter }, the add pill
13
+ // nk-action { action: 'add' } – each with the clicked button as `anchor`, to
14
+ // open a menu under it. Γ— removes the filter; every change to filters or
15
+ // search fires nk-change { filters, search }. In slot="filters" of
16
+ // <nk-database> the row sits right under the view tabs. The texts are
17
+ // attributes, for other languages: filter-label, sort-label, add-label,
18
+ // remove-label (the Γ— reads "<remove-label>: <pill>") and placeholder.
9
19
  class NkFilterBar extends NkElement {
10
- static get observedAttributes() { return ['search', 'placeholder', 'no-filter', 'no-sort', 'value']; }
20
+ static get observedAttributes() { return ['search', 'placeholder', 'no-filter', 'no-sort', 'value', 'add', 'add-label', 'filter-label', 'sort-label', 'remove-label']; }
11
21
 
12
22
  render() {
13
- this._bar = document.createElement('div');
14
- this._bar.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0';
15
- this._filterBtn = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button', 'data-action': 'filter' });
16
- this._filterBtn.textContent = '⚲ Filter';
17
- this._sortBtn = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button', 'data-action': 'sort' });
18
- this._sortBtn.textContent = '↕ Sort';
19
- this._chips = document.createElement('span');
20
- this._chips.style.cssText = 'display:inline-flex;gap:4px;flex-wrap:wrap';
23
+ this._row = this.createElement('div', ['nk-filter-row']);
24
+ this._filterBtn = this.createElement('button', ['nk-db-tool'], { type: 'button', 'data-action': 'filter' });
25
+ this._sortBtn = this.createElement('button', ['nk-db-tool'], { type: 'button', 'data-action': 'sort' });
26
+ this._pillStart = document.createComment('pills');
27
+ this._addBtn = this.createElement('button', ['nk-filter-pill', 'add'], { type: 'button', 'data-action': 'add' });
21
28
  this._input = this.createElement('input', ['nk-input'], { type: 'search', autocomplete: 'off' });
22
- this._input.style.marginLeft = 'auto';
23
- this._bar.append(this._filterBtn, this._sortBtn, this._chips, document.createElement('slot'), this._input);
24
- this._wrapper.appendChild(this._bar);
29
+ this._row.append(this._filterBtn, this._sortBtn, this._pillStart, this._addBtn, document.createElement('slot'), this._input);
30
+ this._wrapper.appendChild(this._row);
25
31
  this._filters = [];
26
32
  this._sync();
27
33
  }
28
34
 
29
35
  _sync() {
30
- this._filterBtn.style.display = this.getBoolAttr('no-filter') ? 'none' : '';
31
- this._sortBtn.style.display = this.getBoolAttr('no-sort') ? 'none' : '';
32
- this._input.style.display = this.getBoolAttr('search') ? '' : 'none';
36
+ this._filterBtn.textContent = this.getAttribute('filter-label') || 'Filter';
37
+ this._filterBtn.hidden = this.getBoolAttr('no-filter');
38
+ this._filterBtn.classList.toggle('active', this._filters.length > 0);
39
+ this._sortBtn.textContent = this.getAttribute('sort-label') || 'Sort';
40
+ this._sortBtn.hidden = this.getBoolAttr('no-sort');
41
+ this._addBtn.textContent = this.getAttribute('add-label') || 'οΌ‹ Filter';
42
+ this._addBtn.hidden = !this.getBoolAttr('add');
43
+ this._input.hidden = !this.getBoolAttr('search');
33
44
  this._input.placeholder = this.getAttribute('placeholder') || 'Search …';
34
45
  if (this.hasAttribute('value') && this._input.value !== this.getAttribute('value')) this._input.value = this.getAttribute('value');
35
- this._chips.replaceChildren();
36
- this._filters.forEach((f, i) => {
37
- const chip = this.createElement('span', ['nk-tag', f.color || 'blue'], { 'data-index': i });
38
- chip.style.cursor = 'pointer';
39
- chip.textContent = `${f.label ?? `${f.key}: ${f.value}`} Γ—`;
40
- chip.title = 'Remove filter';
41
- this._chips.appendChild(chip);
42
- });
46
+ for (const old of this._row.querySelectorAll('[data-pill]')) old.remove();
47
+ const removeLabel = this.getAttribute('remove-label') || 'Remove filter';
48
+ this._pillStart.after(...this._filters.map((f, i) => {
49
+ const pill = this.createElement('span', ['nk-filter-pill', 'active'], { 'data-pill': i });
50
+ const label = this.createElement('button', [], { type: 'button', 'data-edit': i });
51
+ label.textContent = f.label ?? `${f.key}: ${f.value}`;
52
+ const remove = this.createElement('button', ['fp-remove'], { type: 'button', 'data-remove': i, 'aria-label': `${removeLabel}: ${label.textContent}` });
53
+ remove.textContent = 'Γ—';
54
+ pill.append(label, remove);
55
+ return pill;
56
+ }));
43
57
  }
44
58
 
45
59
  setupEvents() {
46
60
  this._onClick = (e) => {
61
+ const remove = e.target.closest('[data-remove]');
62
+ if (remove) { this._filters.splice(Number(remove.dataset.remove), 1); this._sync(); this._emitChange(); return; }
63
+ const edit = e.target.closest('[data-edit]');
64
+ if (edit) { const index = Number(edit.dataset.edit); this.emit('nk-action', { action: 'edit', index, filter: this._filters[index], anchor: edit }); return; }
47
65
  const btn = e.target.closest('[data-action]');
48
- if (btn) { this.emit('nk-action', { action: btn.dataset.action }); return; }
49
- const chip = e.target.closest('[data-index]');
50
- if (chip) { this._filters.splice(Number(chip.dataset.index), 1); this._sync(); this._emitChange(); }
66
+ if (btn) this.emit('nk-action', { action: btn.dataset.action, anchor: btn });
51
67
  };
52
68
  this._onInput = () => { clearTimeout(this._debounce); this._debounce = setTimeout(() => this._emitChange(), 150); };
53
- this._bar.addEventListener('click', this._onClick);
69
+ this._row.addEventListener('click', this._onClick);
54
70
  this._input.addEventListener('input', this._onInput);
55
71
  }
56
72
 
57
73
  teardownEvents() {
58
- this._bar?.removeEventListener('click', this._onClick);
74
+ this._row?.removeEventListener('click', this._onClick);
59
75
  this._input?.removeEventListener('input', this._onInput);
60
76
  clearTimeout(this._debounce);
61
77
  }
@@ -64,13 +80,18 @@ class NkFilterBar extends NkElement {
64
80
  onAttributeChanged() { this._sync(); }
65
81
 
66
82
  get filters() { return this._filters; }
67
- set filters(v) { this._filters = Array.isArray(v) ? v : []; this._sync(); }
83
+ set filters(v) { this._filters = Array.isArray(v) ? v : []; if (this._row) this._sync(); }
68
84
  get value() { return this._input?.value ?? ''; }
69
85
  set value(v) { if (this._input) this._input.value = v ?? ''; }
70
- /** Applies filters + search to rows: equality per filter key, substring search over every string field. */
86
+ /**
87
+ * Applies filters and search to rows: a filter keeps rows where row[key]
88
+ * equals value – or differs from it with op: 'is-not'; the search looks
89
+ * for the text in every string field (a person's name).
90
+ */
71
91
  apply(rows) {
72
92
  const q = this.value.trim().toLowerCase();
73
- return rows.filter(r => this._filters.every(f => r[f.key] === f.value) && (!q || Object.values(r).some(v => String(typeof v === 'object' && v ? v.name ?? '' : v ?? '').toLowerCase().includes(q))));
93
+ return rows.filter(r => this._filters.every(f => (f.op === 'is-not' ? r[f.key] !== f.value : r[f.key] === f.value))
94
+ && (!q || Object.values(r).some(v => String(typeof v === 'object' && v ? v.name ?? '' : v ?? '').toLowerCase().includes(q))));
74
95
  }
75
96
  }
76
97
 
@@ -5,10 +5,12 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
5
5
  // <nk-menu-item type="separator"></nk-menu-item> <nk-menu-item type="label">Danger</nk-menu-item>
6
6
  // <nk-menu-item icon="πŸ—‘οΈ" danger value="delete">Delete</nk-menu-item>
7
7
  // <nk-menu-item type="switch" icon="πŸ”‘" value="small" checked>Small text</nk-menu-item>
8
+ // <nk-menu-item type="check" icon="β—‰" value="open" checked>Status: Open</nk-menu-item>
8
9
  // β†’ <div class="nk-menu-item danger"><span class="m-icon">πŸ—‘οΈ</span>Delete<span class="m-shortcut">…</span></div>
9
10
  // A switch item carries a .nk-switch on the right, as "Small text" does in
10
- // Notion's page menu: a click flips `checked` and fires nk-change { value,
11
- // checked } instead of nk-select, so the menu around it stays open.
11
+ // Notion's page menu; a check item a βœ“ where the shortcut stands, as in a
12
+ // filter menu. A click on either flips `checked` and fires nk-change
13
+ // { value, checked } instead of nk-select, so the menu around it stays open.
12
14
  class NkMenuItem extends NkElement {
13
15
  static get observedAttributes() { return ['icon', 'shortcut', 'danger', 'value', 'type', 'disabled', 'checked']; }
14
16
 
@@ -24,7 +26,8 @@ class NkMenuItem extends NkElement {
24
26
  el.appendChild(document.createElement('slot'));
25
27
  } else {
26
28
  const isSwitch = type === 'switch';
27
- el = this.createElement('div', ['nk-menu-item'], { role: isSwitch ? 'menuitemcheckbox' : 'menuitem', tabindex: '-1' });
29
+ this._check = type === 'check';
30
+ el = this.createElement('div', ['nk-menu-item'], { role: isSwitch || this._check ? 'menuitemcheckbox' : 'menuitem', tabindex: '-1' });
28
31
  this._icon = this.createElement('span', ['m-icon']);
29
32
  const iconSlot = this.createElement('slot', [], { name: 'icon' });
30
33
  iconSlot.appendChild(this._icon);
@@ -49,10 +52,11 @@ class NkMenuItem extends NkElement {
49
52
  this._shortcut.style.display = sc ? '' : 'none';
50
53
  this._el.classList.toggle('danger', this.getBoolAttr('danger'));
51
54
  this._el.setAttribute('aria-disabled', this.getBoolAttr('disabled') ? 'true' : 'false');
52
- if (this._switch) {
55
+ if (this._switch || this._check) {
53
56
  const on = this.getBoolAttr('checked') ? 'true' : 'false';
54
57
  this._el.setAttribute('aria-checked', on);
55
- this._switch.setAttribute('aria-checked', on);
58
+ if (this._switch) this._switch.setAttribute('aria-checked', on);
59
+ if (this._check) { this._shortcut.textContent = on === 'true' ? 'βœ“' : ''; this._shortcut.style.display = ''; }
56
60
  }
57
61
  }
58
62
 
@@ -65,7 +69,7 @@ class NkMenuItem extends NkElement {
65
69
 
66
70
  select() {
67
71
  if (!this._el.classList.contains('nk-menu-item') || this.getBoolAttr('disabled')) return;
68
- if (this._switch) {
72
+ if (this._switch || this._check) {
69
73
  this.checked = !this.checked;
70
74
  this.emit('nk-change', { value: this.value, checked: this.checked, item: this });
71
75
  return;
@@ -1,4 +1,5 @@
1
1
  import { NkElement } from './base.js';
2
+ import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
2
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
3
4
 
4
5
  // <nk-menu>
@@ -9,13 +10,79 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
9
10
  // </nk-menu>
10
11
  // β†’ <div class="nk-pop nk-menu" role="menu">…</div>
11
12
  // ↑↓ move between items, Enter selects, nk-select bubbles from the item.
13
+ //
14
+ // <nk-menu floating sheet> is a menu over the page (NotionKit 1.7.0): it
15
+ // fades in like the palette when `open` is set and is a bottom sheet on a
16
+ // phone. The host becomes the fixed box you position – show(anchor) puts it
17
+ // under the anchor, right edges aligned (align="start": left edges). A tap
18
+ // outside closes it and reaches nothing else; Escape and a chosen item
19
+ // close it too, a switch row does not. Opened from the keyboard, focus moves
20
+ // to the first item; closing returns it to where it was.
21
+ const floatSheet = new CSSStyleSheet();
22
+ floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
23
+
24
+ /** The border box of an element, or of the first descendant with one (display: contents hosts). */
25
+ function boxOf(el) {
26
+ if (el.getClientRects().length) return el.getBoundingClientRect();
27
+ for (const child of [...(el.shadowRoot?.children ?? []), ...el.children]) {
28
+ const r = boxOf(child);
29
+ if (r.width || r.height) return r;
30
+ }
31
+ return el.getBoundingClientRect();
32
+ }
33
+
12
34
  class NkMenu extends NkElement {
35
+ static get hostStyles() { return floatSheet; }
36
+ static get observedAttributes() { return ['floating', 'sheet', 'open', 'align']; }
37
+
13
38
  render() {
14
39
  this._box = this.createElement('div', ['nk-pop', 'nk-menu'], { role: 'menu' });
15
40
  this._box.appendChild(document.createElement('slot'));
16
41
  this._wrapper.appendChild(this._box);
42
+ this._syncMode();
43
+ }
44
+
45
+ _syncMode() {
46
+ this._box.classList.toggle('floating', this.getBoolAttr('floating'));
47
+ this._box.classList.toggle('sheet', this.getBoolAttr('sheet'));
48
+ this._box.classList.toggle('open', this.getBoolAttr('open'));
17
49
  }
18
50
 
51
+ onAttributeChanged(name) {
52
+ this._syncMode();
53
+ if (name !== 'open') return;
54
+ const open = this.getBoolAttr('open');
55
+ // aria-expanded belongs on the control itself, inside a host such as <nk-btn>.
56
+ if (this._anchor) (this._anchor.shadowRoot?.querySelector('button, a[href]') ?? this._anchor).setAttribute('aria-expanded', String(open));
57
+ if (open) {
58
+ this._returnFocus = deepActiveElement();
59
+ if (this._returnFocus?.matches(':focus-visible')) requestAnimationFrame(() => this.focusFirst());
60
+ document.addEventListener('click', this._onOutside, true);
61
+ } else {
62
+ document.removeEventListener('click', this._onOutside, true);
63
+ const back = this._returnFocus;
64
+ this._returnFocus = null;
65
+ if (back?.isConnected && this.contains(document.activeElement)) back.focus({ preventScroll: true });
66
+ }
67
+ this.emit('nk-toggle', { open });
68
+ }
69
+
70
+ /** Opens under `anchor` (any element, display: contents hosts included). */
71
+ show(anchor) {
72
+ if (anchor) {
73
+ this._anchor = anchor;
74
+ const r = boxOf(anchor);
75
+ this.style.top = `${r.bottom + 6}px`;
76
+ if (this.getAttribute('align') === 'start') { this.style.left = `${r.left}px`; this.style.right = ''; }
77
+ else { this.style.right = `${Math.max(8, innerWidth - r.right)}px`; this.style.left = ''; }
78
+ }
79
+ this.setBoolAttr('open', true);
80
+ }
81
+ close() { this.setBoolAttr('open', false); }
82
+ toggle(anchor) { this.getBoolAttr('open') ? this.close() : this.show(anchor); }
83
+ get open() { return this.getBoolAttr('open'); }
84
+ set open(v) { this.setBoolAttr('open', v); }
85
+
19
86
  get items() { return [...this.querySelectorAll(':scope > nk-menu-item')].filter(i => i.selectable); }
20
87
 
21
88
  setupEvents() {
@@ -33,9 +100,27 @@ class NkMenu extends NkElement {
33
100
  }
34
101
  };
35
102
  this.addEventListener('keydown', this._onKey);
103
+ // Floating: outside taps close and go nowhere else; Escape and a chosen item close.
104
+ this._onOutside = (e) => {
105
+ const path = e.composedPath();
106
+ if (path.includes(this) || (this._anchor && path.includes(this._anchor))) return;
107
+ e.preventDefault(); e.stopPropagation();
108
+ this.close();
109
+ };
110
+ // Captured, so a menu inside a modal or sheet closes before they do.
111
+ this._onEscape = (e) => { if (e.key === 'Escape' && this.getBoolAttr('floating') && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
112
+ this._onSelect = () => { if (this.getBoolAttr('floating')) this.close(); };
113
+ document.addEventListener('keydown', this._onEscape, true);
114
+ this.addEventListener('nk-select', this._onSelect);
115
+ if (this.getBoolAttr('open')) document.addEventListener('click', this._onOutside, true);
36
116
  }
37
117
 
38
- teardownEvents() { this.removeEventListener('keydown', this._onKey); }
118
+ teardownEvents() {
119
+ this.removeEventListener('keydown', this._onKey);
120
+ document.removeEventListener('keydown', this._onEscape, true);
121
+ this.removeEventListener('nk-select', this._onSelect);
122
+ document.removeEventListener('click', this._onOutside, true);
123
+ }
39
124
 
40
125
  /** Focuses the first item (called by a popover when it opens). */
41
126
  focusFirst() { this.items[0]?.focus(); }
@@ -1,5 +1,5 @@
1
1
  import { NkElement } from './base.js';
2
- import { l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-DXrK9oLe.js';
2
+ import { l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-BNAChOXO.js';
3
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
4
 
5
5
  // <nk-modal id="settings">
@@ -7,6 +7,9 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
7
7
  // β†’ <div class="nk-segmented">…</div> with .active on the chosen button.
8
8
  // The buttons stay in the light DOM (their look comes from the ::slotted
9
9
  // twins); the element sets type/role, moves .active and submits the value.
10
+ // With `scroll` the chosen option is kept in view – after the first layout,
11
+ // on every change and when the row's width changes – by scrolling the row
12
+ // alone, never the page, by a relative distance, so right-to-left works too.
10
13
  const KEYS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
11
14
 
12
15
  class NkSegmented extends NkFormElement {
@@ -50,6 +53,19 @@ class NkSegmented extends NkFormElement {
50
53
  }
51
54
  this._value = value;
52
55
  this.setFormValue(value);
56
+ this._reveal(this._revealed ? 'smooth' : 'instant');
57
+ }
58
+
59
+ /** Scrolls the row the least distance that shows the chosen button whole. */
60
+ _reveal(behavior) {
61
+ if (!this.getBoolAttr('scroll') || !this._box?.clientWidth) return;
62
+ const btn = this.buttons.find(b => b.classList.contains('active'));
63
+ if (!btn) return;
64
+ const row = this._box.getBoundingClientRect(), r = btn.getBoundingClientRect(), pad = 8;
65
+ const delta = r.left < row.left ? r.left - row.left - pad : r.right > row.right ? r.right - row.right + pad : 0;
66
+ const smooth = behavior === 'smooth' && !matchMedia('(prefers-reduced-motion: reduce)').matches;
67
+ if (delta) this._box.scrollBy({ left: delta, behavior: smooth ? 'smooth' : 'instant' });
68
+ this._revealed = true;
53
69
  }
54
70
 
55
71
  setupEvents() {
@@ -73,6 +89,8 @@ class NkSegmented extends NkFormElement {
73
89
  this._slot.addEventListener('slotchange', this._onSlot);
74
90
  this.addEventListener('click', this._onClick);
75
91
  this.addEventListener('keydown', this._onKey);
92
+ this._resize = new ResizeObserver(() => this._reveal('instant'));
93
+ this._resize.observe(this._box);
76
94
  this._sync();
77
95
  }
78
96
 
@@ -80,6 +98,7 @@ class NkSegmented extends NkFormElement {
80
98
  this._slot?.removeEventListener('slotchange', this._onSlot);
81
99
  this.removeEventListener('click', this._onClick);
82
100
  this.removeEventListener('keydown', this._onKey);
101
+ this._resize?.disconnect();
83
102
  }
84
103
 
85
104
  _choose(value) {