@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/notionkit-elements",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Vanilla JavaScript Web Components wrapping the NotionKit CSS foundation – <nk-*> custom elements with Shadow DOM, automatic theme sync and native form participation.",
5
5
  "license": "MIT",
6
6
  "author": "Jungherz GmbH <info@jungherz.com> (https://www.jungherz.com)",
@@ -59,15 +59,15 @@
59
59
  "dark-mode"
60
60
  ],
61
61
  "peerDependencies": {
62
- "@jungherz-de/notionkit": ">=1.6.0"
62
+ "@jungherz-de/notionkit": ">=1.7.1"
63
63
  },
64
64
  "devDependencies": {
65
- "@jungherz-de/notionkit": "^1.6.0",
65
+ "@jungherz-de/notionkit": "^1.7.1",
66
+ "@playwright/test": "^1.50.0",
66
67
  "@rollup/plugin-node-resolve": "^15.0.0",
67
68
  "@rollup/plugin-terser": "^0.4.0",
68
- "rollup": "^4.0.0",
69
- "@playwright/test": "^1.50.0",
69
+ "pixelmatch": "^7.0.0",
70
70
  "pngjs": "^7.0.0",
71
- "pixelmatch": "^7.0.0"
71
+ "rollup": "^4.0.0"
72
72
  }
73
73
  }
@@ -6,16 +6,24 @@ import { NkElement } from '../../base.js';
6
6
  // </nk-database>
7
7
  // db.columns = [{ key: 'name', label: 'Name', type: 'text', icon: '📄', title: true }, { key: 'status', type: 'select', options: [...] }, …];
8
8
  // db.rows = [{ id: 1, icon: '🧭', name: '…', status: 'done', … }];
9
- // → <div class="nk-database"><div class="nk-db-tabs">…</div>…active view…</div>
9
+ // <nk-btn slot="tools" variant="tool">Filter</nk-btn> … sit right of the tabs,
10
+ // <nk-filter-bar slot="filters"> under them (NotionKit 1.7.0's toolbar).
11
+ // → <div class="nk-database"><div class="nk-db-toolbar"><div class="nk-db-tabs">…</div>
12
+ // <div class="tools">…</div></div>…filters…active view…</div>
10
13
  // The tabs come from the child views; data is pushed into every view.
11
14
  class NkDatabase extends NkElement {
12
15
  static get observedAttributes() { return ['view', 'add-view']; }
13
16
 
14
17
  render() {
15
18
  this._box = this.createElement('div', ['nk-database']);
19
+ this._toolbar = this.createElement('div', ['nk-db-toolbar']);
16
20
  this._tabs = this.createElement('div', ['nk-db-tabs'], { role: 'tablist' });
21
+ const tools = this.createElement('div', ['tools']);
22
+ this._toolsSlot = this.createElement('slot', [], { name: 'tools' });
23
+ tools.appendChild(this._toolsSlot);
24
+ this._toolbar.append(this._tabs, tools);
17
25
  this._slot = document.createElement('slot');
18
- this._box.append(this._tabs, this._slot);
26
+ this._box.append(this._toolbar, this.createElement('slot', [], { name: 'filters' }), this._slot);
19
27
  this._wrapper.appendChild(this._box);
20
28
  this._columns = []; this._rows = [];
21
29
  this._sync();
@@ -43,12 +51,11 @@ class NkDatabase extends NkElement {
43
51
  v.setData?.(this._columns, this._rows);
44
52
  }
45
53
  if (this.getBoolAttr('add-view')) {
46
- const add = this.createElement('span', ['nk-db-tab'], { role: 'button', 'data-add': '', tabindex: '0' });
47
- add.style.color = 'var(--nk-text-tertiary)';
54
+ const add = this.createElement('span', ['nk-db-tab', 'add'], { role: 'button', 'data-add': '', tabindex: '0' });
48
55
  add.textContent = '+';
49
56
  this._tabs.appendChild(add);
50
57
  }
51
- this._tabs.style.display = views.length ? '' : 'none';
58
+ this._toolbar.hidden = !views.length && !this._toolsSlot.assignedElements().length;
52
59
  this._current = current;
53
60
  }
54
61
 
@@ -70,6 +77,7 @@ class NkDatabase extends NkElement {
70
77
  this._tabs.addEventListener('click', this._onClick);
71
78
  this._tabs.addEventListener('keydown', this._onKey);
72
79
  this._slot.addEventListener('slotchange', this._onSlot);
80
+ this._toolsSlot.addEventListener('slotchange', this._onSlot);
73
81
  this._sync();
74
82
  }
75
83
 
@@ -77,6 +85,7 @@ class NkDatabase extends NkElement {
77
85
  this._tabs?.removeEventListener('click', this._onClick);
78
86
  this._tabs?.removeEventListener('keydown', this._onKey);
79
87
  this._slot?.removeEventListener('slotchange', this._onSlot);
88
+ this._toolsSlot?.removeEventListener('slotchange', this._onSlot);
80
89
  }
81
90
 
82
91
  onAttributeChanged(name) {
@@ -1,60 +1,76 @@
1
1
  import { NkElement } from '../../base.js';
2
2
 
3
- // <nk-filter-bar search placeholder="Search rows …"></nk-filter-bar>
4
- // bar.filters = [{ key: 'status', label: 'Status: Done', value: 'done' }];
5
- // Composed from existing classes (nk-btn secondary small, nk-input, nk-tag):
6
- // filter / sort buttons fire nk-action, chips remove on ×, search and chip
7
- // changes fire nk-change { filters, search }. The wrapper is layout only.
3
+ // <nk-filter-bar add no-filter no-sort></nk-filter-bar>
4
+ // bar.filters = [{ key: 'status', value: 'done', op: 'is-not', label: 'Status: Open' }];
5
+ // The filters in effect as NotionKit's filter pills (1.7.0), no inline style:
6
+ // → <div class="nk-filter-row">[Filter][Sort]
7
+ // <span class="nk-filter-pill active"><button>Status: Open</button><button class="fp-remove">×</button></span>…
8
+ // <button class="nk-filter-pill add">+ Filter</button> …slot… [search]</div>
9
+ // Filter and Sort are .nk-db-tool buttons (hide them with no-filter / no-sort
10
+ // when the database toolbar carries its own); they fire nk-action. A pill's
11
+ // label fires nk-action { action: 'edit', index, filter }, the add pill
12
+ // nk-action { action: 'add' } – each with the clicked button as `anchor`, to
13
+ // open a menu under it. × removes the filter; every change to filters or
14
+ // search fires nk-change { filters, search }. In slot="filters" of
15
+ // <nk-database> the row sits right under the view tabs. The texts are
16
+ // attributes, for other languages: filter-label, sort-label, add-label,
17
+ // remove-label (the × reads "<remove-label>: <pill>") and placeholder.
8
18
  class NkFilterBar extends NkElement {
9
- static get observedAttributes() { return ['search', 'placeholder', 'no-filter', 'no-sort', 'value']; }
19
+ static get observedAttributes() { return ['search', 'placeholder', 'no-filter', 'no-sort', 'value', 'add', 'add-label', 'filter-label', 'sort-label', 'remove-label']; }
10
20
 
11
21
  render() {
12
- this._bar = document.createElement('div');
13
- this._bar.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0';
14
- this._filterBtn = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button', 'data-action': 'filter' });
15
- this._filterBtn.textContent = '⚲ Filter';
16
- this._sortBtn = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button', 'data-action': 'sort' });
17
- this._sortBtn.textContent = '↕ Sort';
18
- this._chips = document.createElement('span');
19
- this._chips.style.cssText = 'display:inline-flex;gap:4px;flex-wrap:wrap';
22
+ this._row = this.createElement('div', ['nk-filter-row']);
23
+ this._filterBtn = this.createElement('button', ['nk-db-tool'], { type: 'button', 'data-action': 'filter' });
24
+ this._sortBtn = this.createElement('button', ['nk-db-tool'], { type: 'button', 'data-action': 'sort' });
25
+ this._pillStart = document.createComment('pills');
26
+ this._addBtn = this.createElement('button', ['nk-filter-pill', 'add'], { type: 'button', 'data-action': 'add' });
20
27
  this._input = this.createElement('input', ['nk-input'], { type: 'search', autocomplete: 'off' });
21
- this._input.style.marginLeft = 'auto';
22
- this._bar.append(this._filterBtn, this._sortBtn, this._chips, document.createElement('slot'), this._input);
23
- this._wrapper.appendChild(this._bar);
28
+ this._row.append(this._filterBtn, this._sortBtn, this._pillStart, this._addBtn, document.createElement('slot'), this._input);
29
+ this._wrapper.appendChild(this._row);
24
30
  this._filters = [];
25
31
  this._sync();
26
32
  }
27
33
 
28
34
  _sync() {
29
- this._filterBtn.style.display = this.getBoolAttr('no-filter') ? 'none' : '';
30
- this._sortBtn.style.display = this.getBoolAttr('no-sort') ? 'none' : '';
31
- this._input.style.display = this.getBoolAttr('search') ? '' : 'none';
35
+ this._filterBtn.textContent = this.getAttribute('filter-label') || 'Filter';
36
+ this._filterBtn.hidden = this.getBoolAttr('no-filter');
37
+ this._filterBtn.classList.toggle('active', this._filters.length > 0);
38
+ this._sortBtn.textContent = this.getAttribute('sort-label') || 'Sort';
39
+ this._sortBtn.hidden = this.getBoolAttr('no-sort');
40
+ this._addBtn.textContent = this.getAttribute('add-label') || '+ Filter';
41
+ this._addBtn.hidden = !this.getBoolAttr('add');
42
+ this._input.hidden = !this.getBoolAttr('search');
32
43
  this._input.placeholder = this.getAttribute('placeholder') || 'Search …';
33
44
  if (this.hasAttribute('value') && this._input.value !== this.getAttribute('value')) this._input.value = this.getAttribute('value');
34
- this._chips.replaceChildren();
35
- this._filters.forEach((f, i) => {
36
- const chip = this.createElement('span', ['nk-tag', f.color || 'blue'], { 'data-index': i });
37
- chip.style.cursor = 'pointer';
38
- chip.textContent = `${f.label ?? `${f.key}: ${f.value}`} ×`;
39
- chip.title = 'Remove filter';
40
- this._chips.appendChild(chip);
41
- });
45
+ for (const old of this._row.querySelectorAll('[data-pill]')) old.remove();
46
+ const removeLabel = this.getAttribute('remove-label') || 'Remove filter';
47
+ this._pillStart.after(...this._filters.map((f, i) => {
48
+ const pill = this.createElement('span', ['nk-filter-pill', 'active'], { 'data-pill': i });
49
+ const label = this.createElement('button', [], { type: 'button', 'data-edit': i });
50
+ label.textContent = f.label ?? `${f.key}: ${f.value}`;
51
+ const remove = this.createElement('button', ['fp-remove'], { type: 'button', 'data-remove': i, 'aria-label': `${removeLabel}: ${label.textContent}` });
52
+ remove.textContent = '×';
53
+ pill.append(label, remove);
54
+ return pill;
55
+ }));
42
56
  }
43
57
 
44
58
  setupEvents() {
45
59
  this._onClick = (e) => {
60
+ const remove = e.target.closest('[data-remove]');
61
+ if (remove) { this._filters.splice(Number(remove.dataset.remove), 1); this._sync(); this._emitChange(); return; }
62
+ const edit = e.target.closest('[data-edit]');
63
+ if (edit) { const index = Number(edit.dataset.edit); this.emit('nk-action', { action: 'edit', index, filter: this._filters[index], anchor: edit }); return; }
46
64
  const btn = e.target.closest('[data-action]');
47
- if (btn) { this.emit('nk-action', { action: btn.dataset.action }); return; }
48
- const chip = e.target.closest('[data-index]');
49
- if (chip) { this._filters.splice(Number(chip.dataset.index), 1); this._sync(); this._emitChange(); }
65
+ if (btn) this.emit('nk-action', { action: btn.dataset.action, anchor: btn });
50
66
  };
51
67
  this._onInput = () => { clearTimeout(this._debounce); this._debounce = setTimeout(() => this._emitChange(), 150); };
52
- this._bar.addEventListener('click', this._onClick);
68
+ this._row.addEventListener('click', this._onClick);
53
69
  this._input.addEventListener('input', this._onInput);
54
70
  }
55
71
 
56
72
  teardownEvents() {
57
- this._bar?.removeEventListener('click', this._onClick);
73
+ this._row?.removeEventListener('click', this._onClick);
58
74
  this._input?.removeEventListener('input', this._onInput);
59
75
  clearTimeout(this._debounce);
60
76
  }
@@ -63,13 +79,18 @@ class NkFilterBar extends NkElement {
63
79
  onAttributeChanged() { this._sync(); }
64
80
 
65
81
  get filters() { return this._filters; }
66
- set filters(v) { this._filters = Array.isArray(v) ? v : []; this._sync(); }
82
+ set filters(v) { this._filters = Array.isArray(v) ? v : []; if (this._row) this._sync(); }
67
83
  get value() { return this._input?.value ?? ''; }
68
84
  set value(v) { if (this._input) this._input.value = v ?? ''; }
69
- /** Applies filters + search to rows: equality per filter key, substring search over every string field. */
85
+ /**
86
+ * Applies filters and search to rows: a filter keeps rows where row[key]
87
+ * equals value – or differs from it with op: 'is-not'; the search looks
88
+ * for the text in every string field (a person's name).
89
+ */
70
90
  apply(rows) {
71
91
  const q = this.value.trim().toLowerCase();
72
- return rows.filter(r => this._filters.every(f => r[f.key] === f.value) && (!q || Object.values(r).some(v => String(typeof v === 'object' && v ? v.name ?? '' : v ?? '').toLowerCase().includes(q))));
92
+ return rows.filter(r => this._filters.every(f => (f.op === 'is-not' ? r[f.key] !== f.value : r[f.key] === f.value))
93
+ && (!q || Object.values(r).some(v => String(typeof v === 'object' && v ? v.name ?? '' : v ?? '').toLowerCase().includes(q))));
73
94
  }
74
95
  }
75
96
 
@@ -3,13 +3,16 @@ import { NkElement } from '../../base.js';
3
3
  // <nk-btn variant="primary" small>Save</nk-btn> → <button class="nk-btn primary small">
4
4
  // <nk-btn href="/docs" variant="secondary">Docs</nk-btn> → <a class="nk-btn secondary">
5
5
  // <nk-btn variant="topbar">⭐</nk-btn> → <button class="nk-topbar-btn">
6
+ // <nk-btn variant="tool" active>Filter</nk-btn> → <button class="nk-db-tool active"> (slot="tools" of nk-database)
7
+ // <nk-btn variant="sidebar" aria-label="Menu">☰</nk-btn> → <button class="nk-topbar-btn nk-sidebar-toggle">:
8
+ // shown on phones only, it opens the page's <nk-sidebar> as a drawer.
6
9
  //
7
10
  // Modifier classes become attributes. A slotted <svg> is sized by the
8
11
  // `.nk-btn ::slotted(svg)` twin; pass the icon itself, never wrapped.
9
12
  const VARIANTS = ['primary', 'secondary', 'danger', 'danger-solid', 'topbar', 'share'];
10
13
 
11
14
  class NkBtn extends NkElement {
12
- static get observedAttributes() { return ['variant', 'small', 'disabled', 'type', 'href', 'title', 'aria-label']; }
15
+ static get observedAttributes() { return ['variant', 'small', 'disabled', 'type', 'href', 'title', 'aria-label', 'aria-haspopup', 'active']; }
13
16
 
14
17
  render() {
15
18
  this._build();
@@ -37,13 +40,22 @@ class NkBtn extends NkElement {
37
40
  else el.setAttribute('aria-disabled', disabled ? 'true' : 'false');
38
41
  const title = this.getAttribute('title');
39
42
  title ? el.setAttribute('title', title) : el.removeAttribute('title');
40
- const label = this.getAttribute('aria-label');
41
- label ? el.setAttribute('aria-label', label) : el.removeAttribute('aria-label');
43
+ // The accessible name and the popup hint belong on the button itself.
44
+ for (const name of ['aria-label', 'aria-haspopup']) {
45
+ const v = this.getAttribute(name);
46
+ v ? el.setAttribute(name, v) : el.removeAttribute(name);
47
+ }
48
+ if (this.getAttribute('variant') === 'sidebar') el.setAttribute('aria-expanded', this._sidebar()?.open ? 'true' : 'false');
49
+ else el.removeAttribute('aria-expanded');
42
50
  }
43
51
 
52
+ /** The sidebar the ☰ opens: the one in its own app, else the page's. */
53
+ _sidebar() { return this.closest('nk-app')?.querySelector('nk-sidebar') ?? document.querySelector('nk-sidebar'); }
54
+
44
55
  setupEvents() {
45
56
  this._onClick = (e) => {
46
57
  if (this.getBoolAttr('disabled')) { e.preventDefault(); e.stopPropagation(); return; }
58
+ if (this.getAttribute('variant') === 'sidebar') { this._sidebar()?.toggle?.(); return; }
47
59
  // A button inside a shadow root is not a submit button of the outer
48
60
  // form; forward the intent to the form the host sits in.
49
61
  const type = this.getAttribute('type');
@@ -52,11 +64,17 @@ class NkBtn extends NkElement {
52
64
  if (form) type === 'submit' ? form.requestSubmit() : form.reset();
53
65
  }
54
66
  };
67
+ // The ☰ says whether its drawer is open, however the drawer closed.
68
+ this._onToggle = (e) => {
69
+ if (this.getAttribute('variant') === 'sidebar' && e.target === this._sidebar()) this._btn.setAttribute('aria-expanded', e.detail.open ? 'true' : 'false');
70
+ };
55
71
  this._btn.addEventListener('click', this._onClick);
72
+ document.addEventListener('nk-toggle', this._onToggle);
56
73
  }
57
74
 
58
75
  teardownEvents() {
59
76
  this._btn?.removeEventListener('click', this._onClick);
77
+ document.removeEventListener('nk-toggle', this._onToggle);
60
78
  }
61
79
 
62
80
  onAttributeChanged(name) {
@@ -66,7 +84,11 @@ class NkBtn extends NkElement {
66
84
  this._build();
67
85
  break;
68
86
  case 'variant':
87
+ this._btn.className = this._computeClasses().join(' ');
88
+ this._applyState(this._btn);
89
+ break;
69
90
  case 'small':
91
+ case 'active':
70
92
  this._btn.className = this._computeClasses().join(' ');
71
93
  break;
72
94
  case 'type':
@@ -81,6 +103,8 @@ class NkBtn extends NkElement {
81
103
  const variant = this.getAttribute('variant');
82
104
  if (variant === 'topbar') return ['nk-topbar-btn'];
83
105
  if (variant === 'share') return ['nk-topbar-btn', 'nk-share-btn'];
106
+ if (variant === 'sidebar') return ['nk-topbar-btn', 'nk-sidebar-toggle'];
107
+ if (variant === 'tool') return this.getBoolAttr('active') ? ['nk-db-tool', 'active'] : ['nk-db-tool'];
84
108
  const classes = ['nk-btn'];
85
109
  if (VARIANTS.includes(variant)) classes.push(variant);
86
110
  if (this.getBoolAttr('small')) classes.push('small');
@@ -92,6 +116,8 @@ class NkBtn extends NkElement {
92
116
 
93
117
  get variant() { return this.getAttribute('variant'); }
94
118
  set variant(v) { v ? this.setAttribute('variant', v) : this.removeAttribute('variant'); }
119
+ get active() { return this.getBoolAttr('active'); }
120
+ set active(v) { this.setBoolAttr('active', v); }
95
121
  get small() { return this.getBoolAttr('small'); }
96
122
  set small(v) { this.setBoolAttr('small', v); }
97
123
  get disabled() { return this.getBoolAttr('disabled'); }
@@ -4,10 +4,12 @@ import { NkElement } from '../../base.js';
4
4
  // <nk-menu-item type="separator"></nk-menu-item> <nk-menu-item type="label">Danger</nk-menu-item>
5
5
  // <nk-menu-item icon="🗑️" danger value="delete">Delete</nk-menu-item>
6
6
  // <nk-menu-item type="switch" icon="🔡" value="small" checked>Small text</nk-menu-item>
7
+ // <nk-menu-item type="check" icon="◉" value="open" checked>Status: Open</nk-menu-item>
7
8
  // → <div class="nk-menu-item danger"><span class="m-icon">🗑️</span>Delete<span class="m-shortcut">…</span></div>
8
9
  // A switch item carries a .nk-switch on the right, as "Small text" does in
9
- // Notion's page menu: a click flips `checked` and fires nk-change { value,
10
- // checked } instead of nk-select, so the menu around it stays open.
10
+ // Notion's page menu; a check item a ✓ where the shortcut stands, as in a
11
+ // filter menu. A click on either flips `checked` and fires nk-change
12
+ // { value, checked } instead of nk-select, so the menu around it stays open.
11
13
  class NkMenuItem extends NkElement {
12
14
  static get observedAttributes() { return ['icon', 'shortcut', 'danger', 'value', 'type', 'disabled', 'checked']; }
13
15
 
@@ -23,7 +25,8 @@ class NkMenuItem extends NkElement {
23
25
  el.appendChild(document.createElement('slot'));
24
26
  } else {
25
27
  const isSwitch = type === 'switch';
26
- el = this.createElement('div', ['nk-menu-item'], { role: isSwitch ? 'menuitemcheckbox' : 'menuitem', tabindex: '-1' });
28
+ this._check = type === 'check';
29
+ el = this.createElement('div', ['nk-menu-item'], { role: isSwitch || this._check ? 'menuitemcheckbox' : 'menuitem', tabindex: '-1' });
27
30
  this._icon = this.createElement('span', ['m-icon']);
28
31
  const iconSlot = this.createElement('slot', [], { name: 'icon' });
29
32
  iconSlot.appendChild(this._icon);
@@ -48,10 +51,11 @@ class NkMenuItem extends NkElement {
48
51
  this._shortcut.style.display = sc ? '' : 'none';
49
52
  this._el.classList.toggle('danger', this.getBoolAttr('danger'));
50
53
  this._el.setAttribute('aria-disabled', this.getBoolAttr('disabled') ? 'true' : 'false');
51
- if (this._switch) {
54
+ if (this._switch || this._check) {
52
55
  const on = this.getBoolAttr('checked') ? 'true' : 'false';
53
56
  this._el.setAttribute('aria-checked', on);
54
- this._switch.setAttribute('aria-checked', on);
57
+ if (this._switch) this._switch.setAttribute('aria-checked', on);
58
+ if (this._check) { this._shortcut.textContent = on === 'true' ? '✓' : ''; this._shortcut.style.display = ''; }
55
59
  }
56
60
  }
57
61
 
@@ -64,7 +68,7 @@ class NkMenuItem extends NkElement {
64
68
 
65
69
  select() {
66
70
  if (!this._el.classList.contains('nk-menu-item') || this.getBoolAttr('disabled')) return;
67
- if (this._switch) {
71
+ if (this._switch || this._check) {
68
72
  this.checked = !this.checked;
69
73
  this.emit('nk-change', { value: this.value, checked: this.checked, item: this });
70
74
  return;
@@ -1,4 +1,5 @@
1
1
  import { NkElement } from '../../base.js';
2
+ import { deepActiveElement } from '../../util/focus.js';
2
3
 
3
4
  // <nk-menu>
4
5
  // <nk-menu-item type="label">Page</nk-menu-item>
@@ -8,13 +9,79 @@ import { NkElement } from '../../base.js';
8
9
  // </nk-menu>
9
10
  // → <div class="nk-pop nk-menu" role="menu">…</div>
10
11
  // ↑↓ move between items, Enter selects, nk-select bubbles from the item.
12
+ //
13
+ // <nk-menu floating sheet> is a menu over the page (NotionKit 1.7.0): it
14
+ // fades in like the palette when `open` is set and is a bottom sheet on a
15
+ // phone. The host becomes the fixed box you position – show(anchor) puts it
16
+ // under the anchor, right edges aligned (align="start": left edges). A tap
17
+ // outside closes it and reaches nothing else; Escape and a chosen item
18
+ // close it too, a switch row does not. Opened from the keyboard, focus moves
19
+ // to the first item; closing returns it to where it was.
20
+ const floatSheet = new CSSStyleSheet();
21
+ floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
22
+
23
+ /** The border box of an element, or of the first descendant with one (display: contents hosts). */
24
+ function boxOf(el) {
25
+ if (el.getClientRects().length) return el.getBoundingClientRect();
26
+ for (const child of [...(el.shadowRoot?.children ?? []), ...el.children]) {
27
+ const r = boxOf(child);
28
+ if (r.width || r.height) return r;
29
+ }
30
+ return el.getBoundingClientRect();
31
+ }
32
+
11
33
  class NkMenu extends NkElement {
34
+ static get hostStyles() { return floatSheet; }
35
+ static get observedAttributes() { return ['floating', 'sheet', 'open', 'align']; }
36
+
12
37
  render() {
13
38
  this._box = this.createElement('div', ['nk-pop', 'nk-menu'], { role: 'menu' });
14
39
  this._box.appendChild(document.createElement('slot'));
15
40
  this._wrapper.appendChild(this._box);
41
+ this._syncMode();
42
+ }
43
+
44
+ _syncMode() {
45
+ this._box.classList.toggle('floating', this.getBoolAttr('floating'));
46
+ this._box.classList.toggle('sheet', this.getBoolAttr('sheet'));
47
+ this._box.classList.toggle('open', this.getBoolAttr('open'));
16
48
  }
17
49
 
50
+ onAttributeChanged(name) {
51
+ this._syncMode();
52
+ if (name !== 'open') return;
53
+ const open = this.getBoolAttr('open');
54
+ // aria-expanded belongs on the control itself, inside a host such as <nk-btn>.
55
+ if (this._anchor) (this._anchor.shadowRoot?.querySelector('button, a[href]') ?? this._anchor).setAttribute('aria-expanded', String(open));
56
+ if (open) {
57
+ this._returnFocus = deepActiveElement();
58
+ if (this._returnFocus?.matches(':focus-visible')) requestAnimationFrame(() => this.focusFirst());
59
+ document.addEventListener('click', this._onOutside, true);
60
+ } else {
61
+ document.removeEventListener('click', this._onOutside, true);
62
+ const back = this._returnFocus;
63
+ this._returnFocus = null;
64
+ if (back?.isConnected && this.contains(document.activeElement)) back.focus({ preventScroll: true });
65
+ }
66
+ this.emit('nk-toggle', { open });
67
+ }
68
+
69
+ /** Opens under `anchor` (any element, display: contents hosts included). */
70
+ show(anchor) {
71
+ if (anchor) {
72
+ this._anchor = anchor;
73
+ const r = boxOf(anchor);
74
+ this.style.top = `${r.bottom + 6}px`;
75
+ if (this.getAttribute('align') === 'start') { this.style.left = `${r.left}px`; this.style.right = ''; }
76
+ else { this.style.right = `${Math.max(8, innerWidth - r.right)}px`; this.style.left = ''; }
77
+ }
78
+ this.setBoolAttr('open', true);
79
+ }
80
+ close() { this.setBoolAttr('open', false); }
81
+ toggle(anchor) { this.getBoolAttr('open') ? this.close() : this.show(anchor); }
82
+ get open() { return this.getBoolAttr('open'); }
83
+ set open(v) { this.setBoolAttr('open', v); }
84
+
18
85
  get items() { return [...this.querySelectorAll(':scope > nk-menu-item')].filter(i => i.selectable); }
19
86
 
20
87
  setupEvents() {
@@ -32,9 +99,27 @@ class NkMenu extends NkElement {
32
99
  }
33
100
  };
34
101
  this.addEventListener('keydown', this._onKey);
102
+ // Floating: outside taps close and go nowhere else; Escape and a chosen item close.
103
+ this._onOutside = (e) => {
104
+ const path = e.composedPath();
105
+ if (path.includes(this) || (this._anchor && path.includes(this._anchor))) return;
106
+ e.preventDefault(); e.stopPropagation();
107
+ this.close();
108
+ };
109
+ // Captured, so a menu inside a modal or sheet closes before they do.
110
+ this._onEscape = (e) => { if (e.key === 'Escape' && this.getBoolAttr('floating') && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
111
+ this._onSelect = () => { if (this.getBoolAttr('floating')) this.close(); };
112
+ document.addEventListener('keydown', this._onEscape, true);
113
+ this.addEventListener('nk-select', this._onSelect);
114
+ if (this.getBoolAttr('open')) document.addEventListener('click', this._onOutside, true);
35
115
  }
36
116
 
37
- teardownEvents() { this.removeEventListener('keydown', this._onKey); }
117
+ teardownEvents() {
118
+ this.removeEventListener('keydown', this._onKey);
119
+ document.removeEventListener('keydown', this._onEscape, true);
120
+ this.removeEventListener('nk-select', this._onSelect);
121
+ document.removeEventListener('click', this._onOutside, true);
122
+ }
38
123
 
39
124
  /** Focuses the first item (called by a popover when it opens). */
40
125
  focusFirst() { this.items[0]?.focus(); }
@@ -0,0 +1,95 @@
1
+ import { NkElement } from '../../base.js';
2
+ import { lockScroll, unlockScroll, inertOutside, firstFocusable, deepActiveElement } from '../../util/focus.js';
3
+
4
+ // <nk-sheet id="more" title="More">
5
+ // <nk-tree manual>
6
+ // <nk-section-label>Favourites</nk-section-label>
7
+ // <nk-tree-item icon="🚀">NotionKit MVP</nk-tree-item>
8
+ // </nk-tree>
9
+ // </nk-sheet>
10
+ // → <div class="nk-sheet-backdrop open"><div class="nk-sheet" role="dialog">
11
+ // <div class="sh-grabber"></div><div class="sh-title">More</div>…</div></div>
12
+ //
13
+ // A bottom sheet (NotionKit 1.7.0) with nk-modal's contract: show(), close()
14
+ // and toggle(), Escape and the backdrop close it, focus moves in and back
15
+ // out, the page behind is scroll-locked and inert. It lies above the tab
16
+ // bar; rows inside are 40px high. `title` is the heading shown under the
17
+ // grabber and the dialog's name, never a tooltip – see NkElement.takeTitle().
18
+ // Choosing a row does not close the sheet – the app decides (nk-select
19
+ // bubbles out). Put the element directly under <body>: a transformed
20
+ // ancestor would trap the fixed backdrop.
21
+ class NkSheet extends NkElement {
22
+ static get observedAttributes() { return ['open', 'title']; }
23
+
24
+ render() {
25
+ this.takeTitle();
26
+ this._backdrop = this.createElement('div', ['nk-sheet-backdrop']);
27
+ this._backdrop.inert = true;
28
+ this._box = this.createElement('div', ['nk-sheet'], { role: 'dialog', 'aria-modal': 'true', tabindex: '-1' });
29
+ this._heading = this.createElement('div', ['sh-title'], { id: 'title' });
30
+ this._box.append(this.createElement('div', ['sh-grabber']), this._heading, document.createElement('slot'));
31
+ this._backdrop.appendChild(this._box);
32
+ this._wrapper.appendChild(this._backdrop);
33
+ this._syncTitle();
34
+ this._syncOpen();
35
+ }
36
+
37
+ _syncTitle() {
38
+ const text = this._titleText || '';
39
+ this._heading.textContent = text;
40
+ this._heading.hidden = !text;
41
+ if (text) this._box.setAttribute('aria-labelledby', 'title');
42
+ else this._box.removeAttribute('aria-labelledby');
43
+ }
44
+
45
+ _syncOpen() {
46
+ const open = this.getBoolAttr('open');
47
+ this._backdrop.classList.toggle('open', open);
48
+ this._backdrop.inert = !open;
49
+ if (open === this._wasOpen) return;
50
+ this._wasOpen = open;
51
+ if (open) {
52
+ this._returnFocus = deepActiveElement();
53
+ lockScroll();
54
+ this._undoInert = inertOutside(this);
55
+ requestAnimationFrame(() => (firstFocusable(this) || this._box).focus({ preventScroll: true }));
56
+ } else {
57
+ unlockScroll();
58
+ this._undoInert?.(); this._undoInert = null;
59
+ const back = this._returnFocus;
60
+ if (back && typeof back.focus === 'function' && back.isConnected) back.focus({ preventScroll: true });
61
+ this._returnFocus = null;
62
+ }
63
+ }
64
+
65
+ setupEvents() {
66
+ this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
67
+ this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
68
+ this._backdrop.addEventListener('click', this._onBackdrop);
69
+ document.addEventListener('keydown', this._onKey);
70
+ }
71
+
72
+ teardownEvents() {
73
+ this._backdrop?.removeEventListener('click', this._onBackdrop);
74
+ document.removeEventListener('keydown', this._onKey);
75
+ if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
76
+ }
77
+
78
+ onAttributeChanged(name, oldValue, value) {
79
+ if (name === 'title') { if (this.takeTitle(value)) this._syncTitle(); return; }
80
+ this._syncOpen();
81
+ this.emit('nk-toggle', { open: this.getBoolAttr('open') });
82
+ }
83
+
84
+ show() { this.setBoolAttr('open', true); }
85
+ close() { this.setBoolAttr('open', false); }
86
+ toggle() { this.setBoolAttr('open', !this.getBoolAttr('open')); }
87
+
88
+ get open() { return this.getBoolAttr('open'); }
89
+ set open(v) { this.setBoolAttr('open', v); }
90
+ get title() { return this._titleText ?? ''; }
91
+ set title(v) { this._titleText = v == null ? '' : String(v); if (this._initialized) this._syncTitle(); }
92
+ }
93
+
94
+ customElements.define('nk-sheet', NkSheet);
95
+ export { NkSheet };
@@ -6,6 +6,9 @@ import { NkFormElement } from '../../base.js';
6
6
  // → <div class="nk-segmented">…</div> with .active on the chosen button.
7
7
  // The buttons stay in the light DOM (their look comes from the ::slotted
8
8
  // twins); the element sets type/role, moves .active and submits the value.
9
+ // With `scroll` the chosen option is kept in view – after the first layout,
10
+ // on every change and when the row's width changes – by scrolling the row
11
+ // alone, never the page, by a relative distance, so right-to-left works too.
9
12
  const KEYS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
10
13
 
11
14
  class NkSegmented extends NkFormElement {
@@ -49,6 +52,19 @@ class NkSegmented extends NkFormElement {
49
52
  }
50
53
  this._value = value;
51
54
  this.setFormValue(value);
55
+ this._reveal(this._revealed ? 'smooth' : 'instant');
56
+ }
57
+
58
+ /** Scrolls the row the least distance that shows the chosen button whole. */
59
+ _reveal(behavior) {
60
+ if (!this.getBoolAttr('scroll') || !this._box?.clientWidth) return;
61
+ const btn = this.buttons.find(b => b.classList.contains('active'));
62
+ if (!btn) return;
63
+ const row = this._box.getBoundingClientRect(), r = btn.getBoundingClientRect(), pad = 8;
64
+ const delta = r.left < row.left ? r.left - row.left - pad : r.right > row.right ? r.right - row.right + pad : 0;
65
+ const smooth = behavior === 'smooth' && !matchMedia('(prefers-reduced-motion: reduce)').matches;
66
+ if (delta) this._box.scrollBy({ left: delta, behavior: smooth ? 'smooth' : 'instant' });
67
+ this._revealed = true;
52
68
  }
53
69
 
54
70
  setupEvents() {
@@ -72,6 +88,8 @@ class NkSegmented extends NkFormElement {
72
88
  this._slot.addEventListener('slotchange', this._onSlot);
73
89
  this.addEventListener('click', this._onClick);
74
90
  this.addEventListener('keydown', this._onKey);
91
+ this._resize = new ResizeObserver(() => this._reveal('instant'));
92
+ this._resize.observe(this._box);
75
93
  this._sync();
76
94
  }
77
95
 
@@ -79,6 +97,7 @@ class NkSegmented extends NkFormElement {
79
97
  this._slot?.removeEventListener('slotchange', this._onSlot);
80
98
  this.removeEventListener('click', this._onClick);
81
99
  this.removeEventListener('keydown', this._onKey);
100
+ this._resize?.disconnect();
82
101
  }
83
102
 
84
103
  _choose(value) {