@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,356 @@
1
+ import { NkFormElement } from './base.js';
2
+ import { p as parseDay, i as isoOf, C as CHEVRONS, r as resolveLocale, f as firstWeekday, m as monthGrid, w as weekLabel, a as weekdayNames, b as isoWeek, s as stepMonth, c as addDays } from './shared/dates-a32DcW1l.js';
3
+ import { p as placeUnder } from './shared/floating-B8CwbXnV.js';
4
+ import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
5
+ import '@jungherz-de/notionkit/notionkit-styles.js';
6
+
7
+ // <nk-calendar name="due" value="2026-06-02" weeks weekend="6,0"></nk-calendar>
8
+ // <nk-calendar range value="2026-06-08/2026-06-12"></nk-calendar>
9
+ // <nk-calendar floating sheet id="picker"></nk-calendar> picker.show(button)
10
+ // → [<div class="nk-pop floating sheet open">]<div class="nk-calendar weeks">
11
+ // <div class="cal-head"><div class="cal-title">June 2026</div><button class="cal-nav">Today</button>‹ ›</div>
12
+ // <div class="cal-grid"><span class="cal-wd">W</span>…<span class="cal-week">23</span><button class="cal-day selected">2</button>…</div>
13
+ // [<div class="cal-foot"><input class="nk-input" type="time"></div>]</div>
14
+ //
15
+ // Notion's date picker (NotionKit 1.9.0), after GlassKit Elements'
16
+ // glk-calendar, with what a planning tool needs on top: a range, ISO
17
+ // calendar weeks, days not worked, marks and a time.
18
+ // value: YYYY-MM-DD; with `range` the ISO interval "start/end"; with `time`
19
+ // YYYY-MM-DDTHH:MM. Also as the properties value, start, end.
20
+ // month (YYYY-MM shown; default the value's, else today's), min, max, today,
21
+ // locale (default the page's lang), week-start (0 = Sunday … 6 = Saturday;
22
+ // default from the locale, Monday where the browser cannot say), weeks,
23
+ // weekend (weekdays not worked, "6,0" for Saturday and Sunday), range, time,
24
+ // clearable, label and the texts today-label, prev-label, next-label,
25
+ // week-label, time-label, clear-label. `time` belongs to single days; a
26
+ // range has none.
27
+ // days (property, or JSON attribute): { 'YYYY-MM-DD': { off, label, marks } }
28
+ // – `off` greys a holiday, `label` names the day (tooltip and screen
29
+ // reader), `marks` puts up to three dots in the nine colours on it.
30
+ // Keys: arrows by a day or a week, Home/End to the ends of the week,
31
+ // PageUp/PageDown by a month (Shift: a year); Enter or Space picks. Arrows
32
+ // only move, so the picker stays quiet while someone looks around. Days
33
+ // outside min/max are aria-disabled – in the arrow path and announced, never
34
+ // picked. A range takes two picks, in either order. Form-associated: the
35
+ // value goes with the form, reset restores it.
36
+ // Events: nk-change { value, start, end, time } when a pick – for a range the
37
+ // second one –, a new time or Clear changes the value; nk-month { month } when the month
38
+ // changes; nk-toggle { open } with `floating`. `required` makes an empty
39
+ // value invalid.
40
+ // `floating` makes it a popover over the page – show(anchor) puts it under
41
+ // the anchor, right edges aligned (align="start": left edges) – and `sheet`
42
+ // a bottom sheet with 44px cells on a phone, as <nk-menu floating sheet>.
43
+ // A pick closes it – the second of a range; with `time` Escape or a tap
44
+ // outside does. Put a floating calendar directly under <body>.
45
+ const STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
46
+ const floatSheet = new CSSStyleSheet();
47
+ floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
48
+
49
+ class NkCalendar extends NkFormElement {
50
+ static get hostStyles() { return floatSheet; }
51
+ static get observedAttributes() {
52
+ return ['value', 'month', 'min', 'max', 'today', 'locale', 'week-start', 'weeks', 'weekend', 'range', 'time', 'clearable', 'days',
53
+ 'label', 'today-label', 'prev-label', 'next-label', 'week-label', 'time-label', 'clear-label', 'floating', 'sheet', 'open', 'align', 'required'];
54
+ }
55
+
56
+ render() {
57
+ // A value or month set as a property before the first connect wins over the attributes.
58
+ this._readValue(this._pendingValue ?? this.getAttribute('value'));
59
+ const month = this._pendingMonth || this._attrMonth();
60
+ if (month) this._month = month;
61
+ this._initialValue = this.getAttribute('value') ?? '';
62
+ this._build();
63
+ }
64
+
65
+ // ── value ──
66
+ _readValue(v) {
67
+ const s = String(v ?? '');
68
+ this._start = this._end = this._day = null;
69
+ this._time = '';
70
+ if (this.getBoolAttr('range')) {
71
+ const [a, b] = s.split('/');
72
+ this._start = parseDay(a) ? isoOf(parseDay(a)) : null;
73
+ this._end = parseDay(b) ? isoOf(parseDay(b)) : null;
74
+ } else if (parseDay(s)) {
75
+ this._day = isoOf(parseDay(s));
76
+ this._time = s.match(/T(\d{2}:\d{2})/)?.[1] ?? '';
77
+ }
78
+ // A new value brings its month; without one the month stays.
79
+ const shown = this._day || this._start;
80
+ this._month = shown ? shown.slice(0, 7) : (this._month || this._attrMonth() || this._todayIso().slice(0, 7));
81
+ this._focus = null;
82
+ }
83
+
84
+ _attrMonth() { const m = this.getAttribute('month'); return /^\d{4}-\d{2}$/.test(m || '') ? m : null; }
85
+ _todayIso() { return this.getAttribute('today') || isoOf(new Date()); }
86
+
87
+ get value() {
88
+ if (this.getBoolAttr('range')) return this._start ? `${this._start}${this._end ? `/${this._end}` : ''}` : '';
89
+ return this._day ? `${this._day}${this.getBoolAttr('time') && this._time ? `T${this._time}` : ''}` : '';
90
+ }
91
+ set value(v) {
92
+ this._readValue(v);
93
+ if (this._grid) this._draw(); else this._pendingValue = String(v ?? '');
94
+ }
95
+ get start() { return this.getBoolAttr('range') ? this._start : this._day; }
96
+ get end() { return this.getBoolAttr('range') ? this._end : this._day; }
97
+ get month() { return this._month; }
98
+ set month(v) {
99
+ if (!/^\d{4}-\d{2}$/.test(v)) return;
100
+ this._month = v; this._focus = null;
101
+ if (this._grid) this._draw(); else this._pendingMonth = v;
102
+ }
103
+ get days() { return this._days ?? {}; }
104
+ set days(v) { this._days = v && typeof v === 'object' ? v : {}; if (this._grid) this._draw(); }
105
+
106
+ // ── markup ──
107
+ _build() {
108
+ const floating = this.getBoolAttr('floating');
109
+ this._cal = this.createElement('div', ['nk-calendar'], { role: 'group' });
110
+ const head = this.createElement('div', ['cal-head']);
111
+ this._title = this.createElement('div', ['cal-title'], { 'aria-live': 'polite' });
112
+ this._todayBtn = this.createElement('button', ['cal-nav'], { type: 'button' });
113
+ this._prev = this.createElement('button', ['cal-nav'], { type: 'button' });
114
+ this._prev.innerHTML = CHEVRONS.prev;
115
+ this._next = this.createElement('button', ['cal-nav'], { type: 'button' });
116
+ this._next.innerHTML = CHEVRONS.next;
117
+ head.append(this._title, this._todayBtn, this._prev, this._next);
118
+ this._grid = this.createElement('div', ['cal-grid']);
119
+ this._foot = this.createElement('div', ['cal-foot']);
120
+ this._timeInput = this.createElement('input', ['nk-input'], { type: 'time' });
121
+ this._clear = this.createElement('button', ['cal-nav'], { type: 'button' });
122
+ this._cal.append(head, this._grid);
123
+ this._pop = floating ? this.createElement('div', ['nk-pop', 'floating']) : null;
124
+ if (this._pop) this._pop.appendChild(this._cal);
125
+ this._wrapper.replaceChildren(this._pop ?? this._cal);
126
+ this._draw();
127
+ }
128
+
129
+ get _hasTime() { return this.getBoolAttr('time') && !this.getBoolAttr('range'); }
130
+ get _locale() { return resolveLocale(this.getAttribute('locale')); }
131
+ get _weekStart() {
132
+ const n = Number(this.getAttribute('week-start'));
133
+ return this.hasAttribute('week-start') && Number.isInteger(n) && n >= 0 && n <= 6 ? n : firstWeekday(this._locale);
134
+ }
135
+
136
+ _syncPop() {
137
+ this._pop?.classList.toggle('sheet', this.getBoolAttr('sheet'));
138
+ this._pop?.classList.toggle('open', this.getBoolAttr('open'));
139
+ }
140
+
141
+ _draw() {
142
+ const locale = this._locale, ws = this._weekStart, weeks = this.getBoolAttr('weeks'), range = this.getBoolAttr('range');
143
+ const g = monthGrid(this._month, ws), today = this._todayIso();
144
+ const min = this.getAttribute('min') || '', max = this.getAttribute('max') || '';
145
+ const weekend = new Set((this.getAttribute('weekend') || '').split(',').map(s => s.trim()).filter(Boolean).map(Number));
146
+ let days = this._days;
147
+ if (!days && this.hasAttribute('days')) { try { days = JSON.parse(this.getAttribute('days')); } catch { days = {}; } }
148
+ days ??= {};
149
+ const long = new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
150
+ this._cal.classList.toggle('weeks', weeks);
151
+ this._syncPop();
152
+ const label = this.getAttribute('label');
153
+ if (label) this._cal.setAttribute('aria-label', label); else this._cal.removeAttribute('aria-label');
154
+ this._title.textContent = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(g.first);
155
+ this._todayBtn.textContent = this.getAttribute('today-label') || 'Today';
156
+ this._prev.setAttribute('aria-label', this.getAttribute('prev-label') || 'Previous month');
157
+ this._next.setAttribute('aria-label', this.getAttribute('next-label') || 'Next month');
158
+
159
+ // The one tab stop: the focused day, else the chosen one, else the 1st.
160
+ const cells = Array.from({ length: 42 }, (_, i) => isoOf(g.day(i)));
161
+ const chosen = range ? this._start : this._day;
162
+ if (!this._focus || !cells.includes(this._focus)) this._focus = cells.includes(chosen) ? chosen : `${this._month}-01`;
163
+
164
+ const nodes = [];
165
+ if (weeks) nodes.push(this._head(this.getAttribute('week-label') || weekLabel(locale)));
166
+ for (const w of weekdayNames(locale, ws, 2)) nodes.push(this._head(w));
167
+ for (let i = 0; i < 42; i++) {
168
+ const d = g.day(i), iso = cells[i], info = days[iso] || {};
169
+ if (weeks && i % 7 === 0) { const wk = this.createElement('span', ['cal-week'], { 'aria-hidden': 'true' }); wk.textContent = isoWeek(d); nodes.push(wk); }
170
+ const inRange = range && this._start && this._end && iso > this._start && iso < this._end;
171
+ const cls = ['cal-day', d.getMonth() !== g.m - 1 && 'out', (weekend.has(d.getDay()) || info.off) && 'off', iso === today && 'today',
172
+ !range && iso === this._day && 'selected', range && iso === this._start && 'start', range && iso === (this._end || this._start) && this._start && 'end', inRange && 'in-range'].filter(Boolean);
173
+ const day = this.createElement('button', cls, { type: 'button', 'data-date': iso, tabindex: iso === this._focus ? '0' : '-1',
174
+ 'aria-pressed': String(cls.includes('selected') || cls.includes('start') || cls.includes('end')),
175
+ 'aria-label': `${long.format(d)}${info.label ? `, ${info.label}` : ''}` });
176
+ if (info.label) day.title = info.label;
177
+ if ((min && iso < min) || (max && iso > max)) day.setAttribute('aria-disabled', 'true');
178
+ day.textContent = d.getDate();
179
+ const marks = (info.marks || []).slice(0, 3);
180
+ if (marks.length) {
181
+ const m = this.createElement('span', ['cal-marks']);
182
+ for (const tone of marks) m.appendChild(this.createElement('i', [tone]));
183
+ day.appendChild(m);
184
+ }
185
+ nodes.push(day);
186
+ }
187
+ // The days are drawn anew; focus inside the grid moves to the new tab stop.
188
+ const focused = this._grid.contains(this._shadow.activeElement);
189
+ this._grid.replaceChildren(...nodes);
190
+ if (focused) this.focusDay();
191
+
192
+ const time = this._hasTime, clearable = this.getBoolAttr('clearable');
193
+ this._timeInput.value = this._time;
194
+ this._timeInput.setAttribute('aria-label', this.getAttribute('time-label') || 'Time');
195
+ this._clear.textContent = this.getAttribute('clear-label') || 'Clear';
196
+ this._foot.replaceChildren(...[time && this._timeInput, clearable && this._clear].filter(Boolean));
197
+ if (time || clearable) this._cal.appendChild(this._foot); else this._foot.remove();
198
+ this._syncForm();
199
+ }
200
+
201
+ _syncForm() {
202
+ this.setFormValue(this.value);
203
+ if (this.getBoolAttr('required') && !this.value) this.setValidity({ valueMissing: true }, 'Please choose a date.', this._grid?.querySelector('[tabindex="0"]') ?? undefined);
204
+ else this.setValidity({});
205
+ }
206
+
207
+ _head(text) {
208
+ const s = this.createElement('span', ['cal-wd'], { 'aria-hidden': 'true' });
209
+ s.textContent = text;
210
+ return s;
211
+ }
212
+
213
+ // ── interaction ──
214
+ _goMonth(month, focus) {
215
+ if (month !== this._month) { this._month = month; this.emit('nk-month', { month }); }
216
+ this._focus = focus ?? null;
217
+ this._draw();
218
+ }
219
+
220
+ _pick(iso) {
221
+ const day = this._grid.querySelector(`[data-date="${iso}"]`);
222
+ if (day?.getAttribute('aria-disabled') === 'true') return;
223
+ const before = this.value;
224
+ let done = true;
225
+ if (this.getBoolAttr('range')) {
226
+ if (!this._start || this._end) { this._start = iso; this._end = null; done = false; }
227
+ else if (iso < this._start) { this._end = this._start; this._start = iso; }
228
+ else this._end = iso;
229
+ } else this._day = iso;
230
+ this._focus = iso;
231
+ if (iso.slice(0, 7) !== this._month) this._goMonth(iso.slice(0, 7), iso); else this._draw();
232
+ if (!done) return;
233
+ // Only a new value is a change; the same day again just closes a popover.
234
+ if (this.value !== before) this.emit('nk-change', { value: this.value, start: this.start, end: this.end, time: this._time || null });
235
+ if (this.getBoolAttr('floating') && !this._hasTime) this.close();
236
+ }
237
+
238
+ setupEvents() {
239
+ this._onGrid = (e) => { const day = e.target.closest('.cal-day'); if (day) this._pick(day.dataset.date); };
240
+ this._onToday = () => { const t = this._todayIso(); this._goMonth(t.slice(0, 7), t); };
241
+ this._onPrev = () => this._goMonth(stepMonth(this._month, -1));
242
+ this._onNext = () => this._goMonth(stepMonth(this._month, 1));
243
+ this._onKey = (e) => {
244
+ // Enter and Space are the day button's own click.
245
+ const day = e.target.closest?.('.cal-day');
246
+ if (!day) return;
247
+ const date = parseDay(day.dataset.date), ws = this._weekStart, wd = (date.getDay() - ws + 7) % 7;
248
+ let target = null;
249
+ if (STEPS[e.key]) target = addDays(date, STEPS[e.key]);
250
+ else if (e.key === 'Home') target = addDays(date, -wd);
251
+ else if (e.key === 'End') target = addDays(date, 6 - wd);
252
+ else if (e.key === 'PageUp' || e.key === 'PageDown') {
253
+ const months = (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1);
254
+ const last = new Date(date.getFullYear(), date.getMonth() + months + 1, 0).getDate();
255
+ target = new Date(date.getFullYear(), date.getMonth() + months, Math.min(date.getDate(), last));
256
+ }
257
+ if (!target) return;
258
+ e.preventDefault();
259
+ const iso = isoOf(target);
260
+ if (iso.slice(0, 7) !== this._month) this._goMonth(iso.slice(0, 7), iso);
261
+ else { this._focus = iso; this._draw(); }
262
+ };
263
+ this._onTime = () => {
264
+ this._time = this._timeInput.value;
265
+ this._syncForm();
266
+ if (this._day) this.emit('nk-change', { value: this.value, start: this.start, end: this.end, time: this._time || null });
267
+ };
268
+ this._onClear = () => {
269
+ if (!this.value) return;
270
+ this._day = this._start = this._end = null; this._time = '';
271
+ this._draw();
272
+ this.emit('nk-change', { value: '', start: null, end: null, time: null });
273
+ };
274
+ // Floating: a tap outside closes it and reaches nothing else; Escape closes (captured, before a modal around it).
275
+ this._onOutside = (e) => {
276
+ const path = e.composedPath();
277
+ if (path.includes(this) || (this._anchor && path.includes(this._anchor))) return;
278
+ e.preventDefault(); e.stopPropagation();
279
+ this.close();
280
+ };
281
+ this._onEscape = (e) => { if (e.key === 'Escape' && this.getBoolAttr('floating') && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
282
+ this._grid.addEventListener('click', this._onGrid);
283
+ this._grid.addEventListener('keydown', this._onKey);
284
+ this._todayBtn.addEventListener('click', this._onToday);
285
+ this._prev.addEventListener('click', this._onPrev);
286
+ this._next.addEventListener('click', this._onNext);
287
+ this._timeInput.addEventListener('change', this._onTime);
288
+ this._clear.addEventListener('click', this._onClear);
289
+ document.addEventListener('keydown', this._onEscape, true);
290
+ if (this.getBoolAttr('open')) document.addEventListener('click', this._onOutside, true);
291
+ }
292
+
293
+ teardownEvents() {
294
+ this._grid?.removeEventListener('click', this._onGrid);
295
+ this._grid?.removeEventListener('keydown', this._onKey);
296
+ this._todayBtn?.removeEventListener('click', this._onToday);
297
+ this._prev?.removeEventListener('click', this._onPrev);
298
+ this._next?.removeEventListener('click', this._onNext);
299
+ this._timeInput?.removeEventListener('change', this._onTime);
300
+ this._clear?.removeEventListener('click', this._onClear);
301
+ document.removeEventListener('keydown', this._onEscape, true);
302
+ document.removeEventListener('click', this._onOutside, true);
303
+ }
304
+
305
+ onAttributeChanged(name, _old, value) {
306
+ if (name === 'value') { this._readValue(value); this._draw(); return; }
307
+ if (name === 'month') { this.month = value; return; }
308
+ if (name === 'open' || name === 'sheet') { this._syncPop(); if (name === 'open') this._syncOpen(); return; }
309
+ if (name === 'align') return;
310
+ if (name === 'floating') { this.teardownEvents(); this._build(); if (this.isConnected) this.setupEvents(); return; }
311
+ if (name === 'range') this._readValue(this.getAttribute('value'));
312
+ if (name === 'days') this._days = null;
313
+ this._draw();
314
+ }
315
+
316
+ _syncOpen() {
317
+ const open = this.getBoolAttr('open');
318
+ if (this._anchor) (this._anchor.shadowRoot?.querySelector('button, a[href]') ?? this._anchor).setAttribute('aria-expanded', String(open));
319
+ if (open) {
320
+ this._returnFocus = deepActiveElement();
321
+ if (this._returnFocus?.matches(':focus-visible')) requestAnimationFrame(() => this.focusDay());
322
+ document.addEventListener('click', this._onOutside, true);
323
+ } else {
324
+ document.removeEventListener('click', this._onOutside, true);
325
+ const back = this._returnFocus;
326
+ this._returnFocus = null;
327
+ const active = deepActiveElement();
328
+ if (back?.isConnected && active && this._shadow.contains(active)) back.focus({ preventScroll: true });
329
+ }
330
+ this.emit('nk-toggle', { open });
331
+ }
332
+
333
+ // ── form ──
334
+ resetValue() { this._readValue(this._initialValue); this._draw(); }
335
+ restoreValue(state) { this._readValue(state); this._draw(); }
336
+ onFormDisabled(disabled) { this._cal?.toggleAttribute('inert', disabled); }
337
+
338
+ // ── API ──
339
+ /** Opens under `anchor` (with `floating`), on the month of the value. */
340
+ show(anchor) {
341
+ if (anchor) { this._anchor = anchor; placeUnder(this, anchor, this.getAttribute('align')); }
342
+ const shown = this.start;
343
+ if (shown && shown.slice(0, 7) !== this._month) { this._month = shown.slice(0, 7); this._focus = null; this._draw(); }
344
+ this.setBoolAttr('open', true);
345
+ }
346
+ close() { this.setBoolAttr('open', false); }
347
+ toggle(anchor) { this.getBoolAttr('open') ? this.close() : this.show(anchor); }
348
+ get open() { return this.getBoolAttr('open'); }
349
+ set open(v) { this.setBoolAttr('open', v); }
350
+ /** Moves focus to the day with the tab stop. */
351
+ focusDay() { this._grid?.querySelector('[tabindex="0"]')?.focus(); }
352
+ }
353
+
354
+ customElements.define('nk-calendar', NkCalendar);
355
+
356
+ export { NkCalendar };
@@ -0,0 +1,88 @@
1
+ import { NkElement } from './base.js';
2
+ import '@jungherz-de/notionkit/notionkit-styles.js';
3
+
4
+ // <nk-copy-field value="https://monahilft.notionkit.app"></nk-copy-field>
5
+ // <nk-copy-field value="ntn_4f2…" secret mono></nk-copy-field>
6
+ // → <div class="nk-copy-field mono"><span class="cf-value">…</span>
7
+ // <button class="cf-btn">Show</button><button class="cf-btn">Copy</button></div>
8
+ // A value to take along – a link, an address, a key (NotionKit 1.8.0). Copy
9
+ // writes it to the clipboard and says "Copied" in green for a moment; where
10
+ // the clipboard is not allowed, the value is shown and selected instead, for
11
+ // ⌘C. `secret` masks it behind a Show/Hide toggle; Copy still copies the
12
+ // real value. `value` set as a property is not reflected, so a key never
13
+ // lands in the markup. Fires nk-action { action: 'copy', value, ok }. The
14
+ // texts are attributes: copy-label, copied-label, show-label, hide-label.
15
+ class NkCopyField extends NkElement {
16
+ static get observedAttributes() { return ['value', 'mono', 'wrap', 'wide', 'secret', 'copy-label', 'copied-label', 'show-label', 'hide-label']; }
17
+
18
+ render() {
19
+ this._val ??= this.getAttribute('value') ?? '';
20
+ this._box = this.createElement('div', ['nk-copy-field']);
21
+ this._text = this.createElement('span', ['cf-value']);
22
+ this._show = this.createElement('button', ['cf-btn'], { type: 'button' });
23
+ this._copy = this.createElement('button', ['cf-btn'], { type: 'button' });
24
+ this._box.append(this._text, this._copy);
25
+ this._wrapper.appendChild(this._box);
26
+ this._sync();
27
+ }
28
+
29
+ _sync() {
30
+ if (!this._box) return;
31
+ for (const m of ['mono', 'wrap', 'wide']) this._box.classList.toggle(m, this.getBoolAttr(m));
32
+ const secret = this.getBoolAttr('secret');
33
+ this._text.textContent = secret && !this._revealed ? '•'.repeat(Math.min(this._val.length, 24)) : this._val;
34
+ // Not `hidden`: .cf-btn sets no display of its own that could lose to it,
35
+ // but a button that is not there cannot be tabbed to either.
36
+ if (secret && !this._show.isConnected) this._copy.before(this._show);
37
+ if (!secret) this._show.remove();
38
+ this._show.textContent = this._revealed ? (this.getAttribute('hide-label') || 'Hide') : (this.getAttribute('show-label') || 'Show');
39
+ this._show.setAttribute('aria-pressed', String(!!this._revealed));
40
+ this._copy.textContent = this._copied ? (this.getAttribute('copied-label') || 'Copied') : (this.getAttribute('copy-label') || 'Copy');
41
+ this._copy.classList.toggle('copied', !!this._copied);
42
+ }
43
+
44
+ setupEvents() {
45
+ this._onCopy = () => this.copy();
46
+ this._onShow = () => { this._revealed = !this._revealed; this._sync(); };
47
+ this._copy.addEventListener('click', this._onCopy);
48
+ this._show.addEventListener('click', this._onShow);
49
+ }
50
+
51
+ teardownEvents() {
52
+ this._copy?.removeEventListener('click', this._onCopy);
53
+ this._show?.removeEventListener('click', this._onShow);
54
+ clearTimeout(this._timer);
55
+ }
56
+
57
+ onAttributeChanged(name, _old, value) {
58
+ if (name === 'value') this._val = value ?? '';
59
+ this._sync();
60
+ }
61
+
62
+ /** Copies the value; resolves to whether the clipboard took it. */
63
+ async copy() {
64
+ const value = this._val;
65
+ let ok = true;
66
+ try { await navigator.clipboard.writeText(value); } catch { ok = false; }
67
+ if (ok) {
68
+ this._copied = true;
69
+ clearTimeout(this._timer);
70
+ this._timer = setTimeout(() => { this._copied = false; this._sync(); }, 1500);
71
+ } else {
72
+ this._revealed = true;
73
+ }
74
+ this._sync();
75
+ if (!ok) getSelection()?.selectAllChildren(this._text);
76
+ this.emit('nk-action', { action: 'copy', value, ok });
77
+ return ok;
78
+ }
79
+
80
+ get value() { return this._val ?? this.getAttribute('value') ?? ''; }
81
+ set value(v) { this._val = v == null ? '' : String(v); this._sync(); }
82
+ get secret() { return this.getBoolAttr('secret'); }
83
+ set secret(v) { this.setBoolAttr('secret', v); }
84
+ }
85
+
86
+ customElements.define('nk-copy-field', NkCopyField);
87
+
88
+ export { NkCopyField };
@@ -0,0 +1,130 @@
1
+ import { NkElement } from './base.js';
2
+ import '@jungherz-de/notionkit/notionkit-styles.js';
3
+
4
+ // <nk-image-picker initials="MK" label="Profile picture"></nk-image-picker>
5
+ // <nk-image-picker square initials="M" max="256" type="image/png"></nk-image-picker>
6
+ // → <div class="nk-profile-row"><div class="big-avatar">MK | <img></div>
7
+ // <div class="pr-actions"><button class="nk-btn secondary small">Upload image</button>
8
+ // <button class="nk-btn secondary small pr-remove">Remove</button><input type="file" hidden></div></div>
9
+ //
10
+ // A picture for a person or a workspace (NotionKit 1.8.0): the profile row
11
+ // with its actions, round – or `square`, a workspace icon. Ported from
12
+ // GlassKit Elements' glk-image-picker: the file goes through
13
+ // createImageBitmap with imageOrientation 'from-image' – the EXIF rotation
14
+ // of a phone photo without a library – onto a canvas no larger than `max`
15
+ // (default 512), out as `type` (default image/jpeg, on white; image/png
16
+ // keeps transparency) at `quality` (0.82). The file input is hidden and
17
+ // opened from a real button, and cleared after every pick, so the same file
18
+ // can be chosen again. Not form-associated: listen to nk-change and upload
19
+ // the data URL. `src` set as a property is not reflected, so megabytes of
20
+ // data URL never land in the DOM.
21
+ // Events: nk-change { dataUrl, width, height, size } after a pick, and on
22
+ // remove with an empty dataUrl; nk-error { message, name } when the
23
+ // file cannot be decoded (HEIC outside Safari, a broken file).
24
+ // Texts: choose-label, change-label, remove-label; label names the group.
25
+
26
+ function dataUrlBytes(url) {
27
+ const b64 = url.slice(url.indexOf(',') + 1);
28
+ const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
29
+ return Math.floor((b64.length * 3) / 4) - padding;
30
+ }
31
+
32
+ class NkImagePicker extends NkElement {
33
+ static get observedAttributes() { return ['src', 'initials', 'square', 'label', 'max', 'type', 'quality', 'accept', 'choose-label', 'change-label', 'remove-label']; }
34
+
35
+ render() {
36
+ this._src ??= this.getAttribute('src') || '';
37
+ this._row = this.createElement('div', ['nk-profile-row'], { role: 'group' });
38
+ this._avatar = this.createElement('div', ['big-avatar']);
39
+ const actions = this.createElement('div', ['pr-actions']);
40
+ this._choose = this.createElement('button', ['nk-btn', 'secondary', 'small'], { type: 'button' });
41
+ this._remove = this.createElement('button', ['nk-btn', 'secondary', 'small', 'pr-remove'], { type: 'button' });
42
+ this._input = this.createElement('input', [], { type: 'file', tabindex: '-1' });
43
+ this._input.hidden = true;
44
+ actions.append(this._choose, this._remove, this._input);
45
+ this._row.append(this._avatar, actions);
46
+ this._wrapper.appendChild(this._row);
47
+ this._sync();
48
+ }
49
+
50
+ get _max() { return Math.max(1, Math.floor(Number(this.getAttribute('max'))) || 512); }
51
+ get _type() { return this.getAttribute('type') || 'image/jpeg'; }
52
+ get _quality() { const q = Number(this.getAttribute('quality')); return this.hasAttribute('quality') && q >= 0 && q <= 1 ? q : 0.82; }
53
+
54
+ _sync() {
55
+ if (!this._row) return;
56
+ this._avatar.classList.toggle('square', this.getBoolAttr('square'));
57
+ if (!this._src) {
58
+ this._img = null;
59
+ this._avatar.textContent = this.getAttribute('initials') || '';
60
+ } else if (!this._img || this._img.getAttribute('src') !== this._src) {
61
+ this._img = this.createElement('img', [], { src: this._src, alt: '' });
62
+ this._avatar.replaceChildren(this._img);
63
+ }
64
+ const label = this.getAttribute('label');
65
+ if (label) this._row.setAttribute('aria-label', label); else this._row.removeAttribute('aria-label');
66
+ this._choose.textContent = this._src ? (this.getAttribute('change-label') || 'Change image') : (this.getAttribute('choose-label') || 'Upload image');
67
+ this._remove.textContent = this.getAttribute('remove-label') || 'Remove';
68
+ this._remove.hidden = !this._src;
69
+ this._input.accept = this.getAttribute('accept') || 'image/*';
70
+ }
71
+
72
+ async _pick(file) {
73
+ if (!file) return;
74
+ try {
75
+ const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
76
+ const scale = Math.min(1, this._max / Math.max(bitmap.width, bitmap.height));
77
+ const canvas = document.createElement('canvas');
78
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
79
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
80
+ const ctx = canvas.getContext('2d');
81
+ if (this._type === 'image/jpeg') {
82
+ // JPEG has no alpha; without this a transparent PNG comes out on black.
83
+ ctx.fillStyle = '#fff';
84
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
85
+ }
86
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
87
+ bitmap.close();
88
+ const dataUrl = canvas.toDataURL(this._type, this._quality);
89
+ this.src = dataUrl;
90
+ this.emit('nk-change', { dataUrl, width: canvas.width, height: canvas.height, size: dataUrlBytes(dataUrl) });
91
+ } catch (error) {
92
+ this.emit('nk-error', { message: error?.message || String(error), name: file.name });
93
+ }
94
+ }
95
+
96
+ setupEvents() {
97
+ this._onChoose = () => this._input.click();
98
+ this._onChange = (e) => { const file = e.target.files?.[0]; e.target.value = ''; this._pick(file); };
99
+ this._onRemove = () => {
100
+ this.src = '';
101
+ this._choose.focus(); // the remove button just went away under the focus
102
+ this.emit('nk-change', { dataUrl: '', width: 0, height: 0, size: 0 });
103
+ };
104
+ this._choose.addEventListener('click', this._onChoose);
105
+ this._input.addEventListener('change', this._onChange);
106
+ this._remove.addEventListener('click', this._onRemove);
107
+ }
108
+
109
+ teardownEvents() {
110
+ this._choose?.removeEventListener('click', this._onChoose);
111
+ this._input?.removeEventListener('change', this._onChange);
112
+ this._remove?.removeEventListener('click', this._onRemove);
113
+ }
114
+
115
+ onAttributeChanged(name, _old, value) {
116
+ if (name === 'src') this._src = value || '';
117
+ this._sync();
118
+ }
119
+
120
+ /** Opens the file dialog, as the button does. */
121
+ choose() { this._input?.click(); }
122
+ get src() { return this._src ?? this.getAttribute('src') ?? ''; }
123
+ set src(v) { this._src = v || ''; this._sync(); }
124
+ get square() { return this.getBoolAttr('square'); }
125
+ set square(v) { this.setBoolAttr('square', v); }
126
+ }
127
+
128
+ customElements.define('nk-image-picker', NkImagePicker);
129
+
130
+ export { NkImagePicker };
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from './base.js';
2
2
  import { d as deepActiveElement } from './shared/focus-BNAChOXO.js';
3
+ import { p as placeUnder } from './shared/floating-B8CwbXnV.js';
3
4
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
5
 
5
6
  // <nk-menu>
@@ -21,16 +22,6 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
21
22
  const floatSheet = new CSSStyleSheet();
22
23
  floatSheet.replaceSync(`:host([floating]) { display: block; position: fixed; z-index: 60; pointer-events: none; }`);
23
24
 
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
-
34
25
  class NkMenu extends NkElement {
35
26
  static get hostStyles() { return floatSheet; }
36
27
  static get observedAttributes() { return ['floating', 'sheet', 'open', 'align']; }
@@ -71,10 +62,7 @@ class NkMenu extends NkElement {
71
62
  show(anchor) {
72
63
  if (anchor) {
73
64
  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 = ''; }
65
+ placeUnder(this, anchor, this.getAttribute('align'));
78
66
  }
79
67
  this.setBoolAttr('open', true);
80
68
  }