@jungherz-de/notionkit-elements 1.7.0 → 1.9.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.
@@ -0,0 +1,86 @@
1
+ import { NkElement } from '../../base.js';
2
+
3
+ // <nk-copy-field value="https://monahilft.notionkit.app"></nk-copy-field>
4
+ // <nk-copy-field value="ntn_4f2…" secret mono></nk-copy-field>
5
+ // → <div class="nk-copy-field mono"><span class="cf-value">…</span>
6
+ // <button class="cf-btn">Show</button><button class="cf-btn">Copy</button></div>
7
+ // A value to take along – a link, an address, a key (NotionKit 1.8.0). Copy
8
+ // writes it to the clipboard and says "Copied" in green for a moment; where
9
+ // the clipboard is not allowed, the value is shown and selected instead, for
10
+ // ⌘C. `secret` masks it behind a Show/Hide toggle; Copy still copies the
11
+ // real value. `value` set as a property is not reflected, so a key never
12
+ // lands in the markup. Fires nk-action { action: 'copy', value, ok }. The
13
+ // texts are attributes: copy-label, copied-label, show-label, hide-label.
14
+ class NkCopyField extends NkElement {
15
+ static get observedAttributes() { return ['value', 'mono', 'wrap', 'wide', 'secret', 'copy-label', 'copied-label', 'show-label', 'hide-label']; }
16
+
17
+ render() {
18
+ this._val ??= this.getAttribute('value') ?? '';
19
+ this._box = this.createElement('div', ['nk-copy-field']);
20
+ this._text = this.createElement('span', ['cf-value']);
21
+ this._show = this.createElement('button', ['cf-btn'], { type: 'button' });
22
+ this._copy = this.createElement('button', ['cf-btn'], { type: 'button' });
23
+ this._box.append(this._text, this._copy);
24
+ this._wrapper.appendChild(this._box);
25
+ this._sync();
26
+ }
27
+
28
+ _sync() {
29
+ if (!this._box) return;
30
+ for (const m of ['mono', 'wrap', 'wide']) this._box.classList.toggle(m, this.getBoolAttr(m));
31
+ const secret = this.getBoolAttr('secret');
32
+ this._text.textContent = secret && !this._revealed ? '•'.repeat(Math.min(this._val.length, 24)) : this._val;
33
+ // Not `hidden`: .cf-btn sets no display of its own that could lose to it,
34
+ // but a button that is not there cannot be tabbed to either.
35
+ if (secret && !this._show.isConnected) this._copy.before(this._show);
36
+ if (!secret) this._show.remove();
37
+ this._show.textContent = this._revealed ? (this.getAttribute('hide-label') || 'Hide') : (this.getAttribute('show-label') || 'Show');
38
+ this._show.setAttribute('aria-pressed', String(!!this._revealed));
39
+ this._copy.textContent = this._copied ? (this.getAttribute('copied-label') || 'Copied') : (this.getAttribute('copy-label') || 'Copy');
40
+ this._copy.classList.toggle('copied', !!this._copied);
41
+ }
42
+
43
+ setupEvents() {
44
+ this._onCopy = () => this.copy();
45
+ this._onShow = () => { this._revealed = !this._revealed; this._sync(); };
46
+ this._copy.addEventListener('click', this._onCopy);
47
+ this._show.addEventListener('click', this._onShow);
48
+ }
49
+
50
+ teardownEvents() {
51
+ this._copy?.removeEventListener('click', this._onCopy);
52
+ this._show?.removeEventListener('click', this._onShow);
53
+ clearTimeout(this._timer);
54
+ }
55
+
56
+ onAttributeChanged(name, _old, value) {
57
+ if (name === 'value') this._val = value ?? '';
58
+ this._sync();
59
+ }
60
+
61
+ /** Copies the value; resolves to whether the clipboard took it. */
62
+ async copy() {
63
+ const value = this._val;
64
+ let ok = true;
65
+ try { await navigator.clipboard.writeText(value); } catch { ok = false; }
66
+ if (ok) {
67
+ this._copied = true;
68
+ clearTimeout(this._timer);
69
+ this._timer = setTimeout(() => { this._copied = false; this._sync(); }, 1500);
70
+ } else {
71
+ this._revealed = true;
72
+ }
73
+ this._sync();
74
+ if (!ok) getSelection()?.selectAllChildren(this._text);
75
+ this.emit('nk-action', { action: 'copy', value, ok });
76
+ return ok;
77
+ }
78
+
79
+ get value() { return this._val ?? this.getAttribute('value') ?? ''; }
80
+ set value(v) { this._val = v == null ? '' : String(v); this._sync(); }
81
+ get secret() { return this.getBoolAttr('secret'); }
82
+ set secret(v) { this.setBoolAttr('secret', v); }
83
+ }
84
+
85
+ customElements.define('nk-copy-field', NkCopyField);
86
+ export { NkCopyField };
@@ -0,0 +1,128 @@
1
+ import { NkElement } from '../../base.js';
2
+
3
+ // <nk-image-picker initials="MK" label="Profile picture"></nk-image-picker>
4
+ // <nk-image-picker square initials="M" max="256" type="image/png"></nk-image-picker>
5
+ // → <div class="nk-profile-row"><div class="big-avatar">MK | <img></div>
6
+ // <div class="pr-actions"><button class="nk-btn secondary small">Upload image</button>
7
+ // <button class="nk-btn secondary small pr-remove">Remove</button><input type="file" hidden></div></div>
8
+ //
9
+ // A picture for a person or a workspace (NotionKit 1.8.0): the profile row
10
+ // with its actions, round – or `square`, a workspace icon. Ported from
11
+ // GlassKit Elements' glk-image-picker: the file goes through
12
+ // createImageBitmap with imageOrientation 'from-image' – the EXIF rotation
13
+ // of a phone photo without a library – onto a canvas no larger than `max`
14
+ // (default 512), out as `type` (default image/jpeg, on white; image/png
15
+ // keeps transparency) at `quality` (0.82). The file input is hidden and
16
+ // opened from a real button, and cleared after every pick, so the same file
17
+ // can be chosen again. Not form-associated: listen to nk-change and upload
18
+ // the data URL. `src` set as a property is not reflected, so megabytes of
19
+ // data URL never land in the DOM.
20
+ // Events: nk-change { dataUrl, width, height, size } after a pick, and on
21
+ // remove with an empty dataUrl; nk-error { message, name } when the
22
+ // file cannot be decoded (HEIC outside Safari, a broken file).
23
+ // Texts: choose-label, change-label, remove-label; label names the group.
24
+
25
+ function dataUrlBytes(url) {
26
+ const b64 = url.slice(url.indexOf(',') + 1);
27
+ const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
28
+ return Math.floor((b64.length * 3) / 4) - padding;
29
+ }
30
+
31
+ class NkImagePicker extends NkElement {
32
+ static get observedAttributes() { return ['src', 'initials', 'square', 'label', 'max', 'type', 'quality', 'accept', 'choose-label', 'change-label', 'remove-label']; }
33
+
34
+ render() {
35
+ this._src ??= this.getAttribute('src') || '';
36
+ this._row = this.createElement('div', ['nk-profile-row'], { role: 'group' });
37
+ this._avatar = this.createElement('div', ['big-avatar']);
38
+ const actions = this.createElement('div', ['pr-actions']);
39
+ this._choose = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button' });
40
+ this._remove = this.createElement('button', ['nk-btn', 'secondary', 'small', 'pr-remove'], { type: 'button' });
41
+ this._input = this.createElement('input', [], { type: 'file', tabindex: '-1' });
42
+ this._input.hidden = true;
43
+ actions.append(this._choose, this._remove, this._input);
44
+ this._row.append(this._avatar, actions);
45
+ this._wrapper.appendChild(this._row);
46
+ this._sync();
47
+ }
48
+
49
+ get _max() { return Math.max(1, Math.floor(Number(this.getAttribute('max'))) || 512); }
50
+ get _type() { return this.getAttribute('type') || 'image/jpeg'; }
51
+ get _quality() { const q = Number(this.getAttribute('quality')); return this.hasAttribute('quality') && q >= 0 && q <= 1 ? q : 0.82; }
52
+
53
+ _sync() {
54
+ if (!this._row) return;
55
+ this._avatar.classList.toggle('square', this.getBoolAttr('square'));
56
+ if (!this._src) {
57
+ this._img = null;
58
+ this._avatar.textContent = this.getAttribute('initials') || '';
59
+ } else if (!this._img || this._img.getAttribute('src') !== this._src) {
60
+ this._img = this.createElement('img', [], { src: this._src, alt: '' });
61
+ this._avatar.replaceChildren(this._img);
62
+ }
63
+ const label = this.getAttribute('label');
64
+ if (label) this._row.setAttribute('aria-label', label); else this._row.removeAttribute('aria-label');
65
+ this._choose.textContent = this._src ? (this.getAttribute('change-label') || 'Change image') : (this.getAttribute('choose-label') || 'Upload image');
66
+ this._remove.textContent = this.getAttribute('remove-label') || 'Remove';
67
+ this._remove.hidden = !this._src;
68
+ this._input.accept = this.getAttribute('accept') || 'image/*';
69
+ }
70
+
71
+ async _pick(file) {
72
+ if (!file) return;
73
+ try {
74
+ const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
75
+ const scale = Math.min(1, this._max / Math.max(bitmap.width, bitmap.height));
76
+ const canvas = document.createElement('canvas');
77
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
78
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
79
+ const ctx = canvas.getContext('2d');
80
+ if (this._type === 'image/jpeg') {
81
+ // JPEG has no alpha; without this a transparent PNG comes out on black.
82
+ ctx.fillStyle = '#fff';
83
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
84
+ }
85
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
86
+ bitmap.close();
87
+ const dataUrl = canvas.toDataURL(this._type, this._quality);
88
+ this.src = dataUrl;
89
+ this.emit('nk-change', { dataUrl, width: canvas.width, height: canvas.height, size: dataUrlBytes(dataUrl) });
90
+ } catch (error) {
91
+ this.emit('nk-error', { message: error?.message || String(error), name: file.name });
92
+ }
93
+ }
94
+
95
+ setupEvents() {
96
+ this._onChoose = () => this._input.click();
97
+ this._onChange = (e) => { const file = e.target.files?.[0]; e.target.value = ''; this._pick(file); };
98
+ this._onRemove = () => {
99
+ this.src = '';
100
+ this._choose.focus(); // the remove button just went away under the focus
101
+ this.emit('nk-change', { dataUrl: '', width: 0, height: 0, size: 0 });
102
+ };
103
+ this._choose.addEventListener('click', this._onChoose);
104
+ this._input.addEventListener('change', this._onChange);
105
+ this._remove.addEventListener('click', this._onRemove);
106
+ }
107
+
108
+ teardownEvents() {
109
+ this._choose?.removeEventListener('click', this._onChoose);
110
+ this._input?.removeEventListener('change', this._onChange);
111
+ this._remove?.removeEventListener('click', this._onRemove);
112
+ }
113
+
114
+ onAttributeChanged(name, _old, value) {
115
+ if (name === 'src') this._src = value || '';
116
+ this._sync();
117
+ }
118
+
119
+ /** Opens the file dialog, as the button does. */
120
+ choose() { this._input?.click(); }
121
+ get src() { return this._src ?? this.getAttribute('src') ?? ''; }
122
+ set src(v) { this._src = v || ''; this._sync(); }
123
+ get square() { return this.getBoolAttr('square'); }
124
+ set square(v) { this.setBoolAttr('square', v); }
125
+ }
126
+
127
+ customElements.define('nk-image-picker', NkImagePicker);
128
+ export { NkImagePicker };
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
2
  import { deepActiveElement } from '../../util/focus.js';
3
+ import { placeUnder } from '../../util/floating.js';
3
4
 
4
5
  // <nk-menu>
5
6
  // <nk-menu-item type="label">Page</nk-menu-item>
@@ -20,16 +21,6 @@ import { deepActiveElement } from '../../util/focus.js';
20
21
  const floatSheet = new CSSStyleSheet();
21
22
  floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
22
23
 
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
-
33
24
  class NkMenu extends NkElement {
34
25
  static get hostStyles() { return floatSheet; }
35
26
  static get observedAttributes() { return ['floating', 'sheet', 'open', 'align']; }
@@ -70,10 +61,7 @@ class NkMenu extends NkElement {
70
61
  show(anchor) {
71
62
  if (anchor) {
72
63
  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 = ''; }
64
+ placeUnder(this, anchor, this.getAttribute('align'));
77
65
  }
78
66
  this.setBoolAttr('open', true);
79
67
  }
@@ -0,0 +1,120 @@
1
+ import { NkElement } from '../../base.js';
2
+ import { lockScroll, unlockScroll, inertOutside, deepActiveElement } from '../../util/focus.js';
3
+
4
+ // <nk-peek id="peek" label="Database Table-View">
5
+ // <nk-btn slot="actions" variant="topbar" aria-label="Open as page"><svg …></svg></nk-btn>
6
+ // <nk-page-title>🗃️ Database Table-View</nk-page-title>
7
+ // <nk-props>…</nk-props>
8
+ // <div class="nk-prose">…</div>
9
+ // <nk-comments>…</nk-comments>
10
+ // </nk-peek>
11
+ // → <div class="nk-peek-backdrop open"><aside class="nk-peek" role="dialog">
12
+ // <div class="pk-bar"><button class="nk-topbar-btn">»</button>…actions…</div>
13
+ // <div class="pk-body">…</div></aside></div>
14
+ //
15
+ // Notion's side peek (NotionKit 1.8.0): a row beside the table, which stays
16
+ // usable – no scrim, nothing inert. show() slides it in and moves focus to
17
+ // it; », Escape and a click elsewhere on the page close it, and Escape and »
18
+ // hand focus back. A click that calls show() again – on another row – only
19
+ // swaps the content, and clicks inside other overlays (a menu opened from
20
+ // the peek) leave it open. Below 860px it is a bottom sheet over a dimmed
21
+ // page; there it is modal: the page is inert and scroll-locked, the dimmed
22
+ // page takes the tap that closes it. Put it directly under <body>.
23
+ 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>';
24
+ const OVERLAYS = new Set(['nk-menu', 'nk-pop', 'nk-modal', 'nk-sheet', 'nk-cmdk', 'nk-toast']);
25
+ const OVERLAY_CLASSES = ['nk-pop', 'nk-modal-backdrop', 'nk-sheet-backdrop', 'nk-cmdk-backdrop'];
26
+ const phone = () => matchMedia('(max-width: 860px)').matches;
27
+
28
+ class NkPeek extends NkElement {
29
+ static get observedAttributes() { return ['open', 'label', 'close-label']; }
30
+
31
+ render() {
32
+ this._backdrop = this.createElement('div', ['nk-peek-backdrop']);
33
+ this._box = this.createElement('aside', ['nk-peek'], { role: 'dialog', tabindex: '-1' });
34
+ const bar = this.createElement('div', ['pk-bar']);
35
+ this._close = this.createElement('button', ['nk-topbar-btn'], { type: 'button' });
36
+ this._close.innerHTML = CLOSE_ICON;
37
+ bar.append(this._close, this.createElement('slot', [], { name: 'actions' }));
38
+ const body = this.createElement('div', ['pk-body']);
39
+ body.appendChild(document.createElement('slot'));
40
+ this._box.append(bar, body);
41
+ this._backdrop.appendChild(this._box);
42
+ this._wrapper.appendChild(this._backdrop);
43
+ this._syncLabels();
44
+ this._syncOpen();
45
+ }
46
+
47
+ _syncLabels() {
48
+ const label = this.getAttribute('label');
49
+ if (label) this._box.setAttribute('aria-label', label); else this._box.removeAttribute('aria-label');
50
+ this._close.setAttribute('aria-label', this.getAttribute('close-label') || 'Close');
51
+ }
52
+
53
+ _syncOpen() {
54
+ const open = this.getBoolAttr('open');
55
+ this._backdrop.classList.toggle('open', open);
56
+ if (open === this._wasOpen) return;
57
+ this._wasOpen = open;
58
+ if (open) {
59
+ this._returnFocus = deepActiveElement();
60
+ if (phone()) {
61
+ this._modal = true;
62
+ this._box.setAttribute('aria-modal', 'true');
63
+ lockScroll();
64
+ this._undoInert = inertOutside(this);
65
+ }
66
+ requestAnimationFrame(() => this._box.focus({ preventScroll: true }));
67
+ } else {
68
+ if (this._modal) { this._modal = false; this._box.removeAttribute('aria-modal'); unlockScroll(); this._undoInert?.(); this._undoInert = null; }
69
+ const active = deepActiveElement();
70
+ const inside = active && (this.contains(active) || this._shadow.contains(active) || active === document.body);
71
+ const back = this._returnFocus;
72
+ this._returnFocus = null;
73
+ if (inside && back?.isConnected && typeof back.focus === 'function') back.focus({ preventScroll: true });
74
+ }
75
+ }
76
+
77
+ setupEvents() {
78
+ this._onClose = () => this.close();
79
+ this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
80
+ this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
81
+ // Bubble phase, after the page's own handlers: a click that showed the
82
+ // peek again (another row) must not close it.
83
+ this._onOutside = (e) => {
84
+ if (!this.getBoolAttr('open') || this._shownAt >= e.timeStamp) return;
85
+ const path = e.composedPath();
86
+ if (path.includes(this)) return;
87
+ if (path.some(n => n.nodeType === 1 && (OVERLAYS.has(n.localName) || OVERLAY_CLASSES.some(c => n.classList.contains(c))))) return;
88
+ this.close();
89
+ };
90
+ this._close.addEventListener('click', this._onClose);
91
+ this._backdrop.addEventListener('click', this._onBackdrop);
92
+ document.addEventListener('keydown', this._onKey);
93
+ document.addEventListener('click', this._onOutside);
94
+ }
95
+
96
+ teardownEvents() {
97
+ this._close?.removeEventListener('click', this._onClose);
98
+ this._backdrop?.removeEventListener('click', this._onBackdrop);
99
+ document.removeEventListener('keydown', this._onKey);
100
+ document.removeEventListener('click', this._onOutside);
101
+ if (this._modal) { this._modal = false; unlockScroll(); this._undoInert?.(); this._undoInert = null; }
102
+ this._wasOpen = false;
103
+ }
104
+
105
+ onAttributeChanged(name) {
106
+ if (name !== 'open') { this._syncLabels(); return; }
107
+ this._syncOpen();
108
+ this.emit('nk-toggle', { open: this.getBoolAttr('open') });
109
+ }
110
+
111
+ show() { this._shownAt = performance.now(); this.setBoolAttr('open', true); }
112
+ close() { this.setBoolAttr('open', false); }
113
+ toggle() { this.getBoolAttr('open') ? this.close() : this.show(); }
114
+
115
+ get open() { return this.getBoolAttr('open'); }
116
+ set open(v) { v ? this.show() : this.close(); }
117
+ }
118
+
119
+ customElements.define('nk-peek', NkPeek);
120
+ export { NkPeek };
package/src/index.js CHANGED
@@ -84,6 +84,13 @@ export { NkPanel } from './components/page/nk-panel.js';
84
84
  export { NkSheet } from './components/overlays/nk-sheet.js';
85
85
  export { NkSteps } from './components/page/nk-steps.js';
86
86
 
87
+ // 1.8.0 – links and peeks
88
+ export { NkBookmark } from './components/content/nk-bookmark.js';
89
+ export { NkCopyField } from './components/forms/nk-copy-field.js';
90
+ export { NkImagePicker } from './components/forms/nk-image-picker.js';
91
+ export { NkCalendar } from './components/forms/nk-calendar.js';
92
+ export { NkPeek } from './components/overlays/nk-peek.js';
93
+
87
94
  // Wave 4 – overlays
88
95
  export { NkModal } from './components/overlays/nk-modal.js';
89
96
  export { NkSettingsPane } from './components/overlays/nk-settings-pane.js';
@@ -100,6 +107,7 @@ export { NkDatabase } from './components/data/nk-database.js';
100
107
  export { NkTableView } from './components/data/nk-table-view.js';
101
108
  export { NkBoardView } from './components/data/nk-board-view.js';
102
109
  export { NkListView } from './components/data/nk-list-view.js';
110
+ export { NkCalendarView } from './components/data/nk-calendar-view.js';
103
111
  export { NkFilterBar } from './components/data/nk-filter-bar.js';
104
112
  export { NkComments } from './components/data/nk-comments.js';
105
113
  export { NkComment } from './components/data/nk-comment.js';
@@ -0,0 +1,65 @@
1
+ // Calendar days for nk-calendar and nk-calendar-view: local days as
2
+ // 'YYYY-MM-DD', no library. Weeks are ISO weeks (Monday-based, the week with
3
+ // the year's first Thursday); the first weekday comes from the locale.
4
+ const pad = n => String(n).padStart(2, '0');
5
+
6
+ export const isoOf = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
7
+ export const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
8
+
9
+ /** 'YYYY-MM-DD[THH:MM]', 'D.M.YYYY', a Date or { start } → a local Date at midnight, or null. */
10
+ export function parseDay(v) {
11
+ if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : new Date(v.getFullYear(), v.getMonth(), v.getDate());
12
+ if (v && typeof v === 'object') return v.start ? parseDay(v.start) : null;
13
+ const s = String(v ?? '').trim();
14
+ let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
15
+ if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
16
+ m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
17
+ return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null;
18
+ }
19
+
20
+ export function isoWeek(d) {
21
+ const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
22
+ t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));
23
+ return Math.ceil(((t - Date.UTC(t.getUTCFullYear(), 0, 1)) / 864e5 + 1) / 7);
24
+ }
25
+
26
+ /** The page's language unless the element names one. */
27
+ export const resolveLocale = attr => attr || document.documentElement.lang || navigator.language || 'en';
28
+
29
+ /** 0 = Sunday … 6 = Saturday, from Intl.Locale's week info; Monday where the browser cannot say. */
30
+ export function firstWeekday(locale) {
31
+ try {
32
+ const l = new Intl.Locale(locale), info = l.getWeekInfo?.() ?? l.weekInfo;
33
+ if (info?.firstDay) return info.firstDay % 7;
34
+ } catch { /* an unknown tag */ }
35
+ return 1;
36
+ }
37
+
38
+ /** Short weekday names from `weekStart` on; `length` cuts them (2 → "Mo"). */
39
+ export function weekdayNames(locale, weekStart, length = 0) {
40
+ const f = new Intl.DateTimeFormat(locale, { weekday: 'short' });
41
+ // 7 June 2026 is a Sunday, so 7 + n is weekday n.
42
+ return Array.from({ length: 7 }, (_, i) => {
43
+ const name = f.format(new Date(2026, 5, 7 + ((weekStart + i) % 7))).replace('.', '');
44
+ return length ? name.slice(0, length) : name;
45
+ });
46
+ }
47
+
48
+ /** A month from its week start: `first`, `day(i)` for the i-th cell, the `rows` it needs. */
49
+ export function monthGrid(month, weekStart) {
50
+ const [y, m] = month.split('-').map(Number), first = new Date(y, m - 1, 1), offset = (first.getDay() - weekStart + 7) % 7;
51
+ return { m, first, day: i => new Date(y, m - 1, 1 - offset + i), rows: Math.ceil((offset + new Date(y, m, 0).getDate()) / 7) };
52
+ }
53
+
54
+ export function stepMonth(month, n) {
55
+ const [y, m] = month.split('-').map(Number);
56
+ return isoOf(new Date(y, m - 1 + n, 1)).slice(0, 7);
57
+ }
58
+
59
+ /** "KW" for German, "W" elsewhere – the head of the week column. */
60
+ export const weekLabel = locale => (/^de\b/i.test(locale) ? 'KW' : 'W');
61
+
62
+ export const CHEVRONS = {
63
+ prev: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>',
64
+ next: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>',
65
+ };
@@ -0,0 +1,20 @@
1
+ // Placement for elements that float over the page – <nk-menu floating>,
2
+ // <nk-calendar floating>: the host is a fixed box put under its anchor.
3
+
4
+ /** The border box of an element, or of the first descendant with one (display: contents hosts). */
5
+ export function boxOf(el) {
6
+ if (el.getClientRects().length) return el.getBoundingClientRect();
7
+ for (const child of [...(el.shadowRoot?.children ?? []), ...el.children]) {
8
+ const r = boxOf(child);
9
+ if (r.width || r.height) return r;
10
+ }
11
+ return el.getBoundingClientRect();
12
+ }
13
+
14
+ /** Puts `host` 6px under `anchor`, right edges aligned – left edges with align="start". */
15
+ export function placeUnder(host, anchor, align) {
16
+ const r = boxOf(anchor);
17
+ host.style.top = `${r.bottom + 6}px`;
18
+ if (align === 'start') { host.style.left = `${r.left}px`; host.style.right = ''; }
19
+ else { host.style.right = `${Math.max(8, innerWidth - r.right)}px`; host.style.left = ''; }
20
+ }