@muten/shadcn 0.1.15 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +88 -1
- package/globals.css +1 -0
- package/kit.css +348 -126
- package/motion.css +3 -0
- package/package.json +1 -1
- package/registry/accordion.muten +3 -3
- package/registry/agenda.js +20 -16
- package/registry/agenda.muten +14 -12
- package/registry/alert-dialog.muten +2 -2
- package/registry/alert.muten +1 -1
- package/registry/app-shell.muten +1 -1
- package/registry/attachment.muten +1 -1
- package/registry/avatar.muten +4 -4
- package/registry/badge.muten +14 -3
- package/registry/band.muten +2 -2
- package/registry/bar-chart.muten +2 -2
- package/registry/bio-play.muten +2 -2
- package/registry/booking-play.muten +5 -5
- package/registry/booking.muten +2 -2
- package/registry/breadcrumb.muten +4 -4
- package/registry/button.muten +9 -7
- package/registry/calendar.js +15 -10
- package/registry/calendar.muten +1 -1
- package/registry/card.muten +1 -1
- package/registry/carousel.muten +1 -1
- package/registry/chat-play.muten +8 -8
- package/registry/chatbot.muten +5 -4
- package/registry/checklist.muten +1 -1
- package/registry/choice-list.muten +3 -3
- package/registry/code.js +10 -4
- package/registry/collapsible.muten +2 -2
- package/registry/color-picker.js +37 -36
- package/registry/color-picker.muten +1 -1
- package/registry/combobox.js +1 -1
- package/registry/combobox.muten +2 -2
- package/registry/command-palette.muten +2 -2
- package/registry/command.js +1 -1
- package/registry/command.muten +7 -7
- package/registry/context-menu.muten +3 -3
- package/registry/count-up.js +33 -31
- package/registry/count-up.muten +6 -4
- package/registry/date-field.js +1 -1
- package/registry/date-field.muten +3 -3
- package/registry/day-plan.muten +5 -5
- package/registry/device-frame.muten +3 -3
- package/registry/dialog.muten +1 -1
- package/registry/dirty-bar.js +2 -2
- package/registry/dirty-bar.muten +3 -3
- package/registry/drawer.muten +1 -1
- package/registry/dropdown-menu.muten +4 -4
- package/registry/empty.muten +1 -1
- package/registry/feature-grid.muten +1 -1
- package/registry/feature-rows.muten +2 -2
- package/registry/field-behavior.js +13 -3
- package/registry/field-behavior.muten +3 -3
- package/registry/field.muten +5 -5
- package/registry/file-input.js +8 -4
- package/registry/file-input.muten +3 -3
- package/registry/fixed-card.muten +4 -4
- package/registry/folder-picker.js +6 -2
- package/registry/folder-picker.muten +2 -2
- package/registry/heatmap.muten +5 -5
- package/registry/image-upload.js +1 -1
- package/registry/image-upload.muten +2 -2
- package/registry/inbox-play.muten +5 -5
- package/registry/indicator.muten +1 -1
- package/registry/info-table.muten +1 -1
- package/registry/input-group.muten +11 -11
- package/registry/input-otp.muten +2 -2
- package/registry/input.muten +1 -1
- package/registry/inspector.muten +2 -2
- package/registry/item.muten +2 -2
- package/registry/kit/board.js +36 -34
- package/registry/kit/collections.js +24 -24
- package/registry/kit/core.js +231 -118
- package/registry/kit/dates.js +29 -27
- package/registry/kit/i18n.js +172 -0
- package/registry/kit/menus.js +14 -13
- package/registry/kit/overlays.js +7 -2
- package/registry/kit/structure.js +66 -61
- package/registry/kit/table.js +19 -17
- package/registry/kit/values.js +36 -28
- package/registry/kit-skeleton.muten +1 -1
- package/registry/kit-widget.js +9 -4
- package/registry/lang-scope.js +8 -0
- package/registry/lang-scope.muten +5 -0
- package/registry/legal-doc.muten +5 -5
- package/registry/link-btn.muten +1 -1
- package/registry/list-motion.js +46 -0
- package/registry/list-motion.muten +4 -0
- package/registry/live-stack.muten +2 -2
- package/registry/logo-cloud.muten +2 -2
- package/registry/marketing.muten +7 -7
- package/registry/member-list.muten +2 -2
- package/registry/menubar.muten +3 -3
- package/registry/message-scroller.muten +3 -3
- package/registry/message.muten +9 -9
- package/registry/messaging.muten +1 -1
- package/registry/modal-behavior.js +24 -0
- package/registry/modal.muten +4 -4
- package/registry/money-field.js +1 -1
- package/registry/money-field.muten +2 -2
- package/registry/nav-button.muten +4 -4
- package/registry/navigation-menu.muten +4 -4
- package/registry/navpill.muten +6 -6
- package/registry/new-appointment.js +1 -1
- package/registry/new-appointment.muten +4 -4
- package/registry/number-field.js +6 -4
- package/registry/number-field.muten +1 -1
- package/registry/order-board.js +1 -1
- package/registry/order-board.muten +2 -2
- package/registry/order-play.muten +4 -4
- package/registry/pagination-nav.js +1 -1
- package/registry/pagination-nav.muten +3 -3
- package/registry/pagination.muten +4 -4
- package/registry/password-field.muten +2 -2
- package/registry/permission-matrix.muten +4 -4
- package/registry/phone-bar.muten +6 -6
- package/registry/phone-field.js +1 -1
- package/registry/phone-field.muten +1 -1
- package/registry/phone-shell.js +1 -1
- package/registry/pill.muten +2 -2
- package/registry/plans.muten +1 -1
- package/registry/popover.muten +2 -2
- package/registry/pricing.muten +4 -4
- package/registry/product-play.muten +3 -3
- package/registry/progress.muten +3 -3
- package/registry/queue.muten +1 -1
- package/registry/radio-group.muten +1 -1
- package/registry/rank-item.muten +4 -4
- package/registry/resizable.muten +1 -1
- package/registry/row-menu.js +1 -1
- package/registry/row-menu.muten +2 -2
- package/registry/save-button.muten +5 -5
- package/registry/scale-slider.js +1 -1
- package/registry/scale-slider.muten +2 -2
- package/registry/screen-tour.muten +1 -1
- package/registry/share-bar.muten +2 -2
- package/registry/share-button.muten +4 -4
- package/registry/sheet.muten +1 -1
- package/registry/sidebar.muten +12 -6
- package/registry/site-footer.muten +6 -6
- package/registry/sortable-behavior.js +67 -20
- package/registry/sortable-list.js +3 -1
- package/registry/sortable-list.muten +2 -2
- package/registry/sortable.muten +5 -4
- package/registry/spinner.muten +2 -2
- package/registry/split-button.muten +2 -2
- package/registry/stacked-bars.js +3 -3
- package/registry/stacked-bars.muten +2 -2
- package/registry/stamp-card.muten +2 -2
- package/registry/stat-band.muten +3 -3
- package/registry/stat.muten +3 -3
- package/registry/status-bar.muten +2 -2
- package/registry/stepper.muten +4 -4
- package/registry/store-qr.js +1 -1
- package/registry/store-qr.muten +2 -2
- package/registry/swatch.muten +1 -1
- package/registry/switch.muten +8 -7
- package/registry/table-view.muten +3 -3
- package/registry/tabs-behavior.js +9 -1
- package/registry/tabs.muten +2 -2
- package/registry/tag-input.js +1 -1
- package/registry/tag-input.muten +2 -2
- package/registry/text.muten +2 -2
- package/registry/time-field.js +1 -1
- package/registry/time-field.muten +3 -3
- package/registry/timeline.muten +4 -4
- package/registry/toaster.muten +2 -2
- package/registry/toggle-group.muten +1 -1
- package/registry/toggle.muten +3 -3
- package/registry/toolbar.muten +4 -4
- package/registry/tooltip.muten +2 -2
- package/registry/tree-view.js +38 -25
- package/registry/tree-view.muten +1 -1
- package/registry/veil.muten +1 -1
- package/registry/virtual-list.js +1 -1
- package/registry/virtual-list.muten +2 -2
- package/registry/virtual-window.js +4 -4
- package/registry/virtual-window.muten +2 -2
- package/registry/wordline.muten +1 -1
- package/registry/zoom-control.muten +1 -1
- package/registry.json +22 -2
package/registry/kit/core.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// modal (openModal/closeModal + CONTENT), the wheels, and the system kit (kit.* : calendar, agenda, messaging,
|
|
4
4
|
// chatbot, stat, charts, settings, plans, team, extras, skeletons…). Edit the artifact first, then regenerate this.
|
|
5
5
|
// A Custom loads it with `import("@muten/shadcn/registry/kit/core.js")` (a Custom is inlined, so no top-level import).
|
|
6
|
+
import { t, langOf, words, localizeTree } from './i18n.js';
|
|
6
7
|
|
|
7
8
|
const $ = (s, r = document) => r.querySelector(s);
|
|
8
9
|
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
|
@@ -11,6 +12,37 @@ const root = document.documentElement;
|
|
|
11
12
|
|
|
12
13
|
// ── the layer floating panels, the tooltip, the island and the modal live in (themed by the page's skin) ──
|
|
13
14
|
const lib = document.createElement('div'); lib.className = 'cx-kit-layer'; document.body.appendChild(lib);
|
|
15
|
+
// THE FIRST PAINT STANDS STILL: once the page has painted, <html> says so (data-kit-live), and kit.css lets a piece
|
|
16
|
+
// that appears from then on make its entrance (@starting-style under [data-kit-live]); what was there at load never moves.
|
|
17
|
+
requestAnimationFrame(() => requestAnimationFrame(() => document.documentElement.setAttribute('data-kit-live', '')));
|
|
18
|
+
// The motion tokens for an animation started from script (WAAPI cannot read var()): ms('--t-move'), ease('--ease-out').
|
|
19
|
+
// Read live, so “reduce motion” (tokens at 1 ms) and a skin that overrides them both apply.
|
|
20
|
+
const motionToken = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
21
|
+
const ms = (name, fallback = 240) => parseFloat(motionToken(name)) || fallback;
|
|
22
|
+
const ease = (name) => motionToken(name) || 'ease-out';
|
|
23
|
+
// the kit's words: tx(node, 'English', …args) in the language of that node (a widget's root), T0 for the page's
|
|
24
|
+
const tx = (node, k, ...a) => t(langOf(node), k, ...a);
|
|
25
|
+
const T0 = (k, ...a) => t(langOf(document.body), k, ...a);
|
|
26
|
+
// a Custom that renders once: relang(el, inputs, paint) gives it inputs.lang (as its `lang`), paints it, and paints it
|
|
27
|
+
// again in the new language when a lang changes and it has none of its own. Returns what the first paint returned.
|
|
28
|
+
const relang = (el, inputs, paint) => {
|
|
29
|
+
if (inputs && inputs.lang) el.setAttribute('lang', inputs.lang);
|
|
30
|
+
document.addEventListener('kit:lang', () => { if (el.isConnected && !(inputs && inputs.lang)) { el.innerHTML = ''; paint(); } });
|
|
31
|
+
return paint();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── language: what a part or a Custom marks (data-tr…, kit/i18n.js) is said in the language of its nearest `lang`, and
|
|
35
|
+
// said again whenever a `lang` changes (Storio switching to English); a kit:lang event tells the Customs to repaint.
|
|
36
|
+
localizeTree(document.body);
|
|
37
|
+
new MutationObserver((records) => {
|
|
38
|
+
let langChanged = false;
|
|
39
|
+
for (const r of records) {
|
|
40
|
+
if (r.type === 'childList') r.addedNodes.forEach(localizeTree);
|
|
41
|
+
else if (r.attributeName === 'lang') { langChanged = true; localizeTree(r.target === document.documentElement ? document.body : r.target); }
|
|
42
|
+
else localizeTree(r.target);
|
|
43
|
+
}
|
|
44
|
+
if (langChanged) document.dispatchEvent(new CustomEvent('kit:lang'));
|
|
45
|
+
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['lang', 'data-tra'] });
|
|
14
46
|
const sprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sprite.setAttribute('aria-hidden', 'true'); sprite.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
|
|
15
47
|
sprite.innerHTML = `<defs><symbol id="b-whatsapp" viewBox="0 0 24 24"><path fill="currentColor" d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></symbol><symbol id="b-instagram" viewBox="0 0 24 24"><path fill="currentColor" d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/></symbol><symbol id="b-facebook" viewBox="0 0 24 24"><path fill="currentColor" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></symbol><symbol id="b-x" viewBox="0 0 24 24"><path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/></symbol><symbol id="b-telegram" viewBox="0 0 24 24"><path fill="currentColor" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></symbol><symbol id="b-gmail" viewBox="0 0 24 24"><path fill="currentColor" d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z"/></symbol><symbol id="b-tiktok" viewBox="0 0 24 24"><path fill="currentColor" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></symbol><symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></symbol><symbol id="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="chevup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></symbol><symbol id="updown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></symbol><symbol id="right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></symbol><symbol id="left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></symbol><symbol id="arrowup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7M12 19V5"/></symbol><symbol id="arrowleft" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7M19 12H5"/></symbol><symbol id="more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></symbol><symbol id="search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol><symbol id="okc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol><symbol id="folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol><symbol id="cal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></symbol><symbol id="smile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/></symbol><symbol id="calc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M8 6h8M16 14v4M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M8 18h.01M12 18h.01"/></symbol><symbol id="user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></symbol><symbol id="gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol><symbol id="loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol><symbol id="bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol><symbol id="italic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 4h-9M14 20H5M15 4 9 20"/></symbol><symbol id="under" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4M4 20h16"/></symbol><symbol id="bookmark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></symbol><symbol id="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10M7 17 17 7"/></symbol><symbol id="badgecheck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="grip" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></symbol><symbol id="scissors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12"/></symbol><symbol id="store" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9h18l-1.5-5h-15z"/><path d="M5 9v11h14V9M9 20v-6h6v6"/></symbol><symbol id="clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol><symbol id="pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></symbol><symbol id="trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></symbol><symbol id="caloff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18M9.5 14.5l5 5M14.5 14.5l-5 5"/></symbol><symbol id="copyi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></symbol><symbol id="mappin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></symbol><symbol id="quote" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol><symbol id="truck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2M15 18H9M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/></symbol><symbol id="call" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></symbol><symbol id="pause" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></symbol><symbol id="play" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.5v15l13-7.5z"/></symbol><symbol id="filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18M6 12h12M10 19h4"/></symbol><symbol id="upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol><symbol id="image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></symbol><symbol id="sort" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></symbol><symbol id="arrowdown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M19 12l-7 7-7-7"/></symbol><symbol id="share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></symbol><symbol id="home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1z"/></symbol><symbol id="bag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0"/></symbol><symbol id="menu-lines" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></symbol><symbol id="mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></symbol><symbol id="link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></symbol><symbol id="qrc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3h-3zM20 14v.01M14 20h.01M17 20h4v-3"/></symbol><symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol><symbol id="i-clip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></symbol><symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/></symbol><symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></symbol><symbol id="i-eyeoff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 7 10 7a17 17 0 0 1-2.2 3.2M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 5.4-1.6M2 2l20 20M14.1 14.1a3 3 0 0 1-4.2-4.2"/></symbol><symbol id="i-gift" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7M7.5 8a2.5 2.5 0 0 1 0-5C10 3 12 8 12 8s2-5 4.5-5a2.5 2.5 0 0 1 0 5"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h5"/></symbol><symbol id="i-ticks" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 7 17l-5-5M22 10l-7.5 7.5L13 16"/></symbol><symbol id="i-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></symbol><symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></symbol><symbol id="i-archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol><symbol id="i-bot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></symbol><symbol id="i-hand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v5M14 10V4a2 2 0 0 0-4 0v2M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></symbol><symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></symbol><symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></symbol><symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></symbol></defs>`; document.body.prepend(sprite);
|
|
16
48
|
// follow the page's skin: the layer sits outside the app, so it copies data-skin from the nearest skinned element
|
|
@@ -164,7 +196,18 @@ watchReveal();
|
|
|
164
196
|
setTimeout(done, ms + 400); // safety net if an animation never reports back
|
|
165
197
|
};
|
|
166
198
|
// floating panel: `gap` px from the anchor, aligned to its start/center/end, flips up when it does not fit below
|
|
199
|
+
// Every panel placed here rises to the browser's top layer (a manual popover, as overlays.js already does), so no
|
|
200
|
+
// ancestor clips or shifts it: a transform, a filter, contain or content-visibility on a card or a sheet turned its
|
|
201
|
+
// `position: fixed` into “fixed to that box” and cut it at the box's sides. It stays where muten put it in the DOM.
|
|
202
|
+
const lift = (el) => {
|
|
203
|
+
if (!('popover' in el) || el === tip) return;
|
|
204
|
+
if (!el.hasAttribute('popover')) { el.setAttribute('popover', 'manual'); el.classList.add('cx-floated'); }
|
|
205
|
+
// raised ONCE and left there: taking it out and back in on every opening made a menu reopened while it was still
|
|
206
|
+
// closing vanish and reappear for a frame (a flicker)
|
|
207
|
+
if (!el.matches(':popover-open')) { try { el.showPopover(); } catch { /* not connected yet */ } }
|
|
208
|
+
};
|
|
167
209
|
const place = (el, anchor, align = 'center', gap = 4, width = null) => {
|
|
210
|
+
lift(el);
|
|
168
211
|
const r = anchor.getBoundingClientRect();
|
|
169
212
|
if (width) el.style.width = width + 'px';
|
|
170
213
|
const w = el.offsetWidth, h = el.offsetHeight;
|
|
@@ -213,14 +256,21 @@ watchReveal();
|
|
|
213
256
|
// It holds while the pointer travels inside its trigger (icon to label is not leaving, so it never blinks), it goes on
|
|
214
257
|
// a press, and in a sidebar it only speaks while the rail is folded to icons: open, the label is already on screen.
|
|
215
258
|
const tip = document.createElement('div'); tip.className = 'cx-tip'; tip.setAttribute('role', 'tooltip'); tip.hidden = true; lib.appendChild(tip);
|
|
216
|
-
|
|
217
|
-
|
|
259
|
+
// Going from one trigger to the next (a rail of icons, a toolbar) does not blink: leaving waits 90 ms before it
|
|
260
|
+
// hides, so the gap between two rows is not a “leave”, and while a tooltip is up (or just went) the next one moves
|
|
261
|
+
// there at once, with no fade (Emil's “instant after the first”). Only the first one of a run fades in.
|
|
262
|
+
let tipFor = null, tipLeave = 0, tipGone = 0;
|
|
263
|
+
const tipHide = () => { clearTimeout(tipLeave); if (tipFor) tipGone = performance.now(); tipFor = null; tip.dataset.state = 'closed'; tip.hidden = true; tip.removeAttribute('data-instant'); };
|
|
218
264
|
const tipOn = (e) => {
|
|
219
265
|
const w = e.target.closest?.('[data-name]');
|
|
220
|
-
if (
|
|
266
|
+
if (w === tipFor && w) { clearTimeout(tipLeave); return; }
|
|
267
|
+
if (!w || !w.dataset.name) return; /* an empty name (no data yet) says nothing */
|
|
221
268
|
const rail = w.closest('[data-slot="sidebar"]');
|
|
222
269
|
if (rail && rail.dataset.collapsed !== 'true' && w.dataset.slot !== 'tooltip-trigger') return;
|
|
223
|
-
|
|
270
|
+
clearTimeout(tipLeave);
|
|
271
|
+
const warm = !!tipFor || performance.now() - tipGone < 400;
|
|
272
|
+
tipFor = w; tip.textContent = w.dataset.name; tip.hidden = false;
|
|
273
|
+
tip.toggleAttribute('data-instant', warm); if (!warm) tip.dataset.state = 'closed';
|
|
224
274
|
const r = w.getBoundingClientRect();
|
|
225
275
|
if (w.dataset.side === 'right' || rail) {
|
|
226
276
|
tip.dataset.side = 'right'; tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px';
|
|
@@ -231,12 +281,13 @@ watchReveal();
|
|
|
231
281
|
tip.style.top = (above ? r.top - tip.offsetHeight - 8 : r.bottom + 8) + 'px';
|
|
232
282
|
tip.style.setProperty('--ax', (r.left + r.width / 2 - parseFloat(tip.style.left)) + 'px');
|
|
233
283
|
}
|
|
234
|
-
requestAnimationFrame(() => { if (tipFor === w) tip.dataset.state = 'open'; });
|
|
284
|
+
if (warm) tip.dataset.state = 'open'; else requestAnimationFrame(() => { if (tipFor === w) tip.dataset.state = 'open'; });
|
|
235
285
|
};
|
|
236
286
|
const tipOff = (e) => {
|
|
237
287
|
const w = e.target.closest?.('[data-name]');
|
|
238
288
|
if (!w || w !== tipFor || (e.relatedTarget && w.contains(e.relatedTarget))) return;
|
|
239
|
-
tipHide();
|
|
289
|
+
if (e.type === 'focusout') { tipHide(); return; }
|
|
290
|
+
clearTimeout(tipLeave); tipLeave = setTimeout(() => { if (tipFor === w) tipHide(); }, 90);
|
|
240
291
|
};
|
|
241
292
|
['pointerover', 'focusin'].forEach((t) => document.addEventListener(t, tipOn)); ['pointerout', 'focusout'].forEach((t) => document.addEventListener(t, tipOff));
|
|
242
293
|
document.addEventListener('pointerdown', () => { if (tipFor) tipHide(); }, true);
|
|
@@ -264,7 +315,7 @@ const islText = $('.cx-island-text', isl), islIcon = $('.cx-island-icon', isl);
|
|
|
264
315
|
const easeHeight = (el, change) => { // FLIP on height: measure, change, measure, animate between
|
|
265
316
|
const from = el.offsetHeight; change(); const to = el.offsetHeight;
|
|
266
317
|
if (from === to || !from) return; // from 0 = the element was empty or not laid out yet: its first content just appears
|
|
267
|
-
el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 200, easing: '
|
|
318
|
+
el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 200, easing: ease('--ease-out') });
|
|
268
319
|
};
|
|
269
320
|
|
|
270
321
|
// ── atoms ──
|
|
@@ -296,7 +347,7 @@ const islText = $('.cx-island-text', isl), islIcon = $('.cx-island-icon', isl);
|
|
|
296
347
|
// Text: every piece of copy is one of these variants, never an ad-hoc font size.
|
|
297
348
|
ui.text = (content, variant = 'body', tag = 'span', attrs = '') => `<${tag} data-slot="text" data-variant="${variant}" class="cx-text" ${attrs}>${content}</${tag}>`;
|
|
298
349
|
// Stepper: one Progress atom per step, plus the step's name as Text.
|
|
299
|
-
ui.stepper = (steps, at) => `<div data-slot="stepper" class="cx-stepper" style="--n:${steps.length}" role="list" aria-label="
|
|
350
|
+
ui.stepper = (steps, at) => `<div data-slot="stepper" class="cx-stepper" style="--n:${steps.length}" role="list" aria-label="${T0('Step {0} of {1}: {2}', at + 1, steps.length, steps[at])}">${steps.map((name, i) => `<div role="listitem" class="cx-step" ${i < at ? 'data-done' : i === at ? 'data-now aria-current="step"' : ''}>${ui.progress(i <= at ? 100 : 0, name)}${ui.text(name, i === at ? 'label' : 'caption')}</div>`).join('')}</div>`;
|
|
300
351
|
ui.field = ({ id, label, control, description = '' }) => `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="${id}">${label}</label>${control}${description ? `<p data-slot="field-description" class="cn-field-description">${description}</p>` : ''}</div>`;
|
|
301
352
|
ui.input = ({ id, value = '', placeholder = '', type = 'text', attrs = '' }) => `<input data-slot="input" class="cn-input" id="${id}" type="${type}" value="${value}" placeholder="${placeholder}" ${attrs}>`;
|
|
302
353
|
ui.choice = ({ media = '', title, description = '', aside = '', checked = false, attrs = '' }) => `<div role="radio" aria-checked="${checked}" tabindex="${checked ? 0 : -1}" data-slot="item" class="cn-item cn-item-variant-outline cn-item-size-sm group/item cx-choice" ${attrs}>${media}<div class="cn-item-content"><div class="cn-item-title">${title}</div>${description ? `<p class="cn-item-description">${description}</p>` : ''}</div>${aside ? `<div class="cn-item-actions">${aside}</div>` : ''}</div>`;
|
|
@@ -378,6 +429,30 @@ const focusables = () => $$('button:not([disabled]), input, textarea, select, [t
|
|
|
378
429
|
};
|
|
379
430
|
addEventListener('pointerup', endDrag); addEventListener('pointercancel', endDrag);
|
|
380
431
|
|
|
432
|
+
// a finger can also pull the sheet down from ANYWHERE in it while its content sits at the top, like the phone's own
|
|
433
|
+
// sheets: the body pans vertically (touch-action: pan-y), so this listens to touch events and takes the gesture
|
|
434
|
+
// (preventDefault) only once it is a pull down with nothing above to scroll back to
|
|
435
|
+
let pull = null;
|
|
436
|
+
const scrollerOf = (el) => { for (let x = el; x && x !== mdl; x = x.parentElement) { if (x.scrollHeight > x.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(x).overflowY)) return x; } return null; };
|
|
437
|
+
mdl.addEventListener('touchstart', (e) => {
|
|
438
|
+
if (mdl.dataset.mode !== 'drawer' || e.touches.length !== 1 || drag) return;
|
|
439
|
+
if (e.target.closest('input, textarea, select, [contenteditable="true"], [role="slider"], .cx-wheel, .cx-slider')) return;
|
|
440
|
+
const t = e.touches[0]; pull = { y: t.clientY, x: t.clientX, t: performance.now(), dy: 0, live: false, sc: scrollerOf(e.target) };
|
|
441
|
+
}, { passive: true });
|
|
442
|
+
mdl.addEventListener('touchmove', (e) => {
|
|
443
|
+
if (!pull) return;
|
|
444
|
+
const t = e.touches[0], dy = t.clientY - pull.y, dx = t.clientX - pull.x;
|
|
445
|
+
if (!pull.live) {
|
|
446
|
+
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
|
|
447
|
+
if (dy < 0 || Math.abs(dx) > Math.abs(dy) || (pull.sc && pull.sc.scrollTop > 0)) { pull = null; return; }
|
|
448
|
+
pull.live = true; mdl.classList.add('dragging'); ov.classList.add('dragging');
|
|
449
|
+
}
|
|
450
|
+
e.preventDefault(); pull.dy = dy; mdl.style.transform = `translateY(${dy}px)`;
|
|
451
|
+
ov.style.opacity = String(Math.max(0, 1 - dy / mdl.offsetHeight));
|
|
452
|
+
}, { passive: false });
|
|
453
|
+
const endPull = () => { if (!pull) return; const d = pull; pull = null; if (!d.live) return; mdl.classList.remove('dragging'); ov.classList.remove('dragging'); const v = d.dy / (performance.now() - d.t); if (d.dy > mdl.offsetHeight * 0.3 || v > 0.5) closeModal(); else { mdl.style.transform = ''; ov.style.opacity = ''; } };
|
|
454
|
+
mdl.addEventListener('touchend', endPull); mdl.addEventListener('touchcancel', endPull);
|
|
455
|
+
|
|
381
456
|
|
|
382
457
|
// ── wheels (date and time) ──
|
|
383
458
|
const MONTHS = MON.map((m) => m[0].toUpperCase() + m.slice(1));
|
|
@@ -431,10 +506,10 @@ let wheelsNow = [];
|
|
|
431
506
|
// kit.pickTime({ value, label, from, to, step }) → Promise<'HH:MM' | null>, in the adaptive modal with wheels
|
|
432
507
|
let tpCb = null, tpState = null, tpBack = null;
|
|
433
508
|
const tpSettle = () => requestAnimationFrame(() => requestAnimationFrame(() => { wheelsNow.forEach((w) => w.place()); $('.cx-wheel', body)?.focus({ preventScroll: true }); }));
|
|
434
|
-
kit.pickTime = ({ value = '09:00', label = '
|
|
509
|
+
kit.pickTime = ({ value = '09:00', label = T0('Time'), from = 6, to = 22, step = 15 } = {}) => new Promise((resolve) => {
|
|
435
510
|
tpCb?.(null); tpState = { value, label, from, to, step }; tpCb = resolve;
|
|
436
511
|
if (!open) { tpBack = null; openModal('timeWheel', 'auto'); return tpSettle(); }
|
|
437
|
-
// a modal is already open: the wheels become a step of it, and
|
|
512
|
+
// a modal is already open: the wheels become a step of it, and “Done” brings the form back untouched
|
|
438
513
|
const kept = { body: [...body.childNodes], foot: [...foot.childNodes], title: $('#mdl-title').textContent, desc: $('#mdl-desc').textContent, focus: document.activeElement };
|
|
439
514
|
tpBack = () => { body.replaceChildren(...kept.body); foot.replaceChildren(...kept.foot); $('#mdl-title').textContent = kept.title; $('#mdl-desc').textContent = kept.desc; kept.focus?.focus({ preventScroll: true }); };
|
|
440
515
|
easeHeight(mdl, () => CONTENT.timeWheel()); tpSettle();
|
|
@@ -443,12 +518,12 @@ let wheelsNow = [];
|
|
|
443
518
|
const { value, label, from: f, to: t, step } = tpState, [h0, m0] = value.split(':').map(Number);
|
|
444
519
|
const HS = Array.from({ length: t - f + 1 }, (_, i) => f + i), MS = Array.from({ length: 60 / step }, (_, i) => i * step);
|
|
445
520
|
let h = HS.includes(h0) ? h0 : HS[0], m = MS.includes(m0) ? m0 : 0;
|
|
446
|
-
$('#mdl-title').textContent = label; $('#mdl-desc').textContent = '
|
|
521
|
+
$('#mdl-title').textContent = label; $('#mdl-desc').textContent = T0('Drag each wheel, tap a value or use the arrows.');
|
|
447
522
|
body.innerHTML = '<div class="cx-wheels"><div class="cx-wheel" id="tw-h"></div><div class="cx-wheel" id="tw-m"></div></div><p class="cx-wheel-sum" id="tw-sum"></p>';
|
|
448
523
|
const sum = () => { $('#tw-sum').textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; };
|
|
449
|
-
wheelsNow = [makeWheel($('#tw-h'), HS.map((x) => String(x).padStart(2, '0')), HS.indexOf(h), (i) => { h = HS[i]; sum(); }, '
|
|
524
|
+
wheelsNow = [makeWheel($('#tw-h'), HS.map((x) => String(x).padStart(2, '0')), HS.indexOf(h), (i) => { h = HS[i]; sum(); }, T0('Hour')), makeWheel($('#tw-m'), MS.map((x) => String(x).padStart(2, '0')), MS.indexOf(m), (i) => { m = MS[i]; sum(); }, T0('Minutes'))];
|
|
450
525
|
sum();
|
|
451
|
-
foot.innerHTML = ui.button({ label: tpBack ? '
|
|
526
|
+
foot.innerHTML = ui.button({ label: tpBack ? T0('Back') : T0('Cancel'), size: 'default', attrs: 'data-tw-no', cls: 'cx-exit' }) + ui.button({ label: T0('Done'), variant: 'default', size: 'default', attrs: 'data-tw-ok', cls: 'cx-go' });
|
|
452
527
|
};
|
|
453
528
|
document.addEventListener('click', (e) => {
|
|
454
529
|
const ok = e.target.closest('[data-tw-ok]'), no = e.target.closest('[data-tw-no]'); if ((!ok && !no) || !tpCb) return;
|
|
@@ -478,11 +553,14 @@ let wheelsNow = [];
|
|
|
478
553
|
|
|
479
554
|
|
|
480
555
|
// ── Calendar: mode 'single' | 'range'; months 1 | 2; onChange(value) ──
|
|
481
|
-
kit.calendar = (root,
|
|
556
|
+
kit.calendar = (root, opts = {}) => {
|
|
557
|
+
// its words default to the language of its root (the page's), English when there is none
|
|
558
|
+
const cw = kit.calWords(langOf(root));
|
|
559
|
+
let { mode = 'single', months = 1, value = null, onChange = () => {}, min = null, mon = cw.mon, weekdays = cw.weekdays, prevLabel = cw.prevLabel, nextLabel = cw.nextLabel, prevYear = cw.prevYear, nextYear = cw.nextYear, pickLabel = cw.pickLabel } = opts;
|
|
482
560
|
let picking = false; // the month's title opens a year of months: far dates in two taps
|
|
483
561
|
let view = new Date((mode === 'range' ? value?.[0] : value) || today); view.setDate(1);
|
|
484
562
|
let sel = value, hover = null, focusDay = startOfDay((mode === 'range' ? value?.[0] : value) || today);
|
|
485
|
-
const WD = weekdays
|
|
563
|
+
const WD = weekdays;
|
|
486
564
|
const inRange = (d) => mode === 'range' && sel?.[0] && (sel[1] || hover) && d > startOfDay(sel[0]) && d < startOfDay(sel[1] || hover) ;
|
|
487
565
|
const isEdge = (d) => mode === 'range' ? (sameDay(d, sel?.[0]) || sameDay(d, sel?.[1])) : sameDay(d, sel);
|
|
488
566
|
// the arrows sit in the title's own row, so they can never land on the month's name
|
|
@@ -493,7 +571,7 @@ let wheelsNow = [];
|
|
|
493
571
|
return `<div class="cx-cal-month" role="grid" aria-label="${mon[first.getMonth()]} ${first.getFullYear()}"><div class="cx-cal-head">${m === 0 ? navBtn(-1, 'left', prevLabel, 'nav') : '<span></span>'}<button type="button" class="cx-cal-title cx-cal-pick" data-cal-pick aria-label="${pickLabel}: ${mon[first.getMonth()]} ${first.getFullYear()}">${ui.text(`${mon[first.getMonth()][0].toUpperCase()}${mon[first.getMonth()].slice(1)} ${first.getFullYear()}`, 'heading')}${ui.icon('chev')}</button>${m === months - 1 ? navBtn(1, 'right', nextLabel, 'nav') : '<span></span>'}</div><div class="cx-cal-grid" role="row">${WD.map((w) => `<span class="cx-cal-wd" role="columnheader">${ui.text(w, 'caption')}</span>`).join('')}</div><div class="cx-cal-grid">${cells.map((d) => {
|
|
494
572
|
const out = d.getMonth() !== first.getMonth(), off = min && d < startOfDay(min);
|
|
495
573
|
const st = isEdge(d) ? 'edge' : inRange(d) ? 'mid' : '';
|
|
496
|
-
return `<button class="cn-button cn-button-variant-ghost cn-button-size-icon cx-cal-day" role="gridcell" data-day="${+d}" ${out ? 'data-out' : ''} ${sameDay(d, today) ? 'data-today' : ''} ${st ? `data-range="${st}"` : ''} aria-pressed="${st === 'edge'}" aria-label="${
|
|
574
|
+
return `<button class="cn-button cn-button-variant-ghost cn-button-size-icon cx-cal-day" role="gridcell" data-day="${+d}" ${out ? 'data-out' : ''} ${sameDay(d, today) ? 'data-today' : ''} ${st ? `data-range="${st}"` : ''} aria-pressed="${st === 'edge'}" aria-label="${langOf(root) === 'es' ? `${kit.words('es').dayL[d.getDay()]} ${d.getDate()} de ${mon[d.getMonth()]}` : `${kit.words('en').dayL[d.getDay()]}, ${mon[d.getMonth()]} ${d.getDate()}`}" tabindex="${sameDay(d, focusDay) && !out ? 0 : -1}" ${off ? 'disabled' : ''}>${d.getDate()}</button>`;
|
|
497
575
|
}).join('')}</div></div>`;
|
|
498
576
|
};
|
|
499
577
|
const picker = () => `<div class="cx-cal cx-cal-picker"><div class="cx-cal-head">${navBtn(-1, 'left', prevYear, 'year')}<div class="cx-cal-title">${ui.text(String(view.getFullYear()), 'heading')}</div>${navBtn(1, 'right', nextYear, 'year')}</div><div class="cx-cal-mgrid">${mon.map((m, k) => `<button type="button" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-cal-m" data-cal-month="${k}" ${k === view.getMonth() ? 'aria-current="true"' : ''} ${today.getFullYear() === view.getFullYear() && today.getMonth() === k ? 'data-today' : ''}>${m[0].toUpperCase()}${m.slice(1, 3)}</button>`).join('')}</div></div>`;
|
|
@@ -509,9 +587,11 @@ let wheelsNow = [];
|
|
|
509
587
|
focusDay = d; paint(true); onChange(sel);
|
|
510
588
|
};
|
|
511
589
|
root.addEventListener('click', (e) => {
|
|
512
|
-
|
|
590
|
+
// a month changed by its arrow slides in from that side (kit.css “Calendar”); the keyboard's PageUp/PageDown stays instant
|
|
591
|
+
const slide = (dir) => { const box = root.querySelector('.cx-cal-months, .cx-cal-picker'); if (box) box.dataset.slide = dir > 0 ? 'next' : 'prev'; };
|
|
592
|
+
const n = e.target.closest('[data-cal-nav]'); if (n) { view.setMonth(view.getMonth() + +n.dataset.calNav); paint(); slide(+n.dataset.calNav); return; }
|
|
513
593
|
if (e.target.closest('[data-cal-pick]')) { picking = true; paint(); return; }
|
|
514
|
-
const yr = e.target.closest('[data-cal-year]'); if (yr) { view.setFullYear(view.getFullYear() + +yr.dataset.calYear); paint(); return; }
|
|
594
|
+
const yr = e.target.closest('[data-cal-year]'); if (yr) { view.setFullYear(view.getFullYear() + +yr.dataset.calYear); paint(); slide(+yr.dataset.calYear); return; }
|
|
515
595
|
const mo = e.target.closest('[data-cal-month]'); if (mo) { view = new Date(view.getFullYear(), +mo.dataset.calMonth, 1); picking = false; paint(); return; }
|
|
516
596
|
const b = e.target.closest('[data-day]'); if (b && !b.disabled) pick(new Date(+b.dataset.day));
|
|
517
597
|
});
|
|
@@ -534,7 +614,7 @@ let wheelsNow = [];
|
|
|
534
614
|
// Day columns per person, the week, or a list (the phone keeps the day grid and the list). Two ways to use it:
|
|
535
615
|
// · a SHOWCASE (no app behind it): its own sheets open, book, block, move and cancel, all in memory;
|
|
536
616
|
// · an APP (sheet: 'app'): the app owns the data. The agenda paints what it is given and tells the app what
|
|
537
|
-
// happened: onOpen(event) when a
|
|
617
|
+
// happened: onOpen(event) when a appointment is tapped, onCreate({resource, day, start}) on a free hour, onBlock(block)
|
|
538
618
|
// when a block is asked, onMove(change) when one is dragged, onRange({from, to, view}) when the days shown
|
|
539
619
|
// change (so the app loads them). The app answers with update(next): new data, same day, view and person.
|
|
540
620
|
// The free hours can come from the server (free: [{resource, day, start, end}]): then they are the ONLY free hours,
|
|
@@ -589,7 +669,10 @@ let wheelsNow = [];
|
|
|
589
669
|
mon: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
|
590
670
|
},
|
|
591
671
|
};
|
|
592
|
-
//
|
|
672
|
+
// the date words of a language (months, days, the calendar's buttons) for any piece that shows a date
|
|
673
|
+
kit.words = (lang) => AG_WORDS[lang === 'es' ? 'es' : 'en'];
|
|
674
|
+
kit.calWords = (lang) => { const w = kit.words(lang); return { mon: w.mon, weekdays: w.dayS.slice(1).concat(w.dayS[0]).map((x) => x.slice(0, 2).toLowerCase()), prevLabel: w.prevMonth, nextLabel: w.nextMonth, prevYear: w.prevYear, nextYear: w.nextYear, pickLabel: w.pickMonth }; };
|
|
675
|
+
// a appointment's status as the agenda draws it: its tone (the colour family) and whether it still occupies its time
|
|
593
676
|
const AG_STATUS = { awaiting_payment: 'info', confirmed: 'ok', started: 'live', done: 'done', noshow: 'bad' };
|
|
594
677
|
|
|
595
678
|
let agSeq = 0, agOwner = 0; // several agendas on one page: each its own sheets, and only the one that opened the sheet answers it
|
|
@@ -604,7 +687,12 @@ let wheelsNow = [];
|
|
|
604
687
|
actions = null, createSheet = false, services = [], view: startView = 'day', sheet: sheetMode = 'built-in', drag: dragOn = true,
|
|
605
688
|
resize: resizeMode = 'on', moveAcross = 'on',
|
|
606
689
|
start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each', phoneStrip = '' } = opts;
|
|
607
|
-
// THE
|
|
690
|
+
// WHAT THE APP'S ASSISTANT DID: an event with by "agent" carries the app's mark (an image url) before its name, and on
|
|
691
|
+
// the phone's list a line that says so (agentLabel). The library knows no assistant: the app names it and draws it.
|
|
692
|
+
let agentMark = opts.agentMark || '', agentLabel = opts.agentLabel || '';
|
|
693
|
+
const byAgent = (e) => e.by === 'agent' && !!agentMark;
|
|
694
|
+
const agentImg = () => `<img class="cx-ag-by" src="${String(agentMark).replace(/"/g, '"')}" alt="" aria-hidden="true">`;
|
|
695
|
+
// THE SHOP'S CLOCK: with a timeZone, “today”, “now” and “the past” are the shop's, not the browser's (an owner
|
|
608
696
|
// travelling, or a phone set to another zone, still sees the shop's day)
|
|
609
697
|
const clockNow = () => { if (!timeZone) return new Date(); try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date()).map((x) => [x.type, x.value])); return new Date(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute); } catch { return new Date(); } };
|
|
610
698
|
const today = (() => { const d = clockNow(); d.setHours(0, 0, 0, 0); return d; })();
|
|
@@ -630,13 +718,13 @@ let wheelsNow = [];
|
|
|
630
718
|
const y = (m) => ((m - from * 60) / 60) * hourPx;
|
|
631
719
|
const weekDays = () => { const mon = addDays(day, -((day.getDay() + 6) % 7)); return Array.from({ length: 7 }, (_, i) => addDays(mon, i)); };
|
|
632
720
|
// SEVERAL AGENDAS: a resource is an agenda (kind "business" for the shop's own, "person" for each worker, or any other
|
|
633
|
-
// kind: a room, a chair). The business goes first. A
|
|
634
|
-
// `with` (a worker AND the room). `capacity` says how many
|
|
721
|
+
// kind: a room, a chair). The business goes first. A appointment belongs to its `resource` and also takes every agenda in
|
|
722
|
+
// `with` (a worker AND the room). `capacity` says how many appointments an agenda holds at once: 1 (the default) = they never
|
|
635
723
|
// overlap, 3 = a class of three, 0 = no limit. `overlap` "never" | "allow" overrides it for the whole agenda.
|
|
636
724
|
const kindOf = (r) => r?.kind || 'person';
|
|
637
725
|
const ordered = () => [...resources].sort((a, b) => (kindOf(a) === 'business' ? 0 : 1) - (kindOf(b) === 'business' ? 0 : 1));
|
|
638
726
|
const shown = () => (who === 'all' ? ordered() : who.startsWith('kind:') ? ordered().filter((r) => kindOf(r) === who.slice(5)) : resources.filter((r) => r.id === who));
|
|
639
|
-
// what an agenda is, under its name: its role (
|
|
727
|
+
// what an agenda is, under its name: its role (“Barber”), else its kind (the business, a room's group), plus how many fit
|
|
640
728
|
const roleOf = (r) => [r.role || (kindOf(r) === 'business' ? w.business : kindOf(r) !== 'person' ? r.group || '' : ''), capOf(r.id) > 1 ? w.capacityOf(capOf(r.id)) : ''].filter(Boolean).join(' · ');
|
|
641
729
|
// a resource with hideEmpty (the business's own agenda, which only holds what nobody was given) shows its column
|
|
642
730
|
// only on days it has something; the rest of the agendas always show
|
|
@@ -646,10 +734,10 @@ let wheelsNow = [];
|
|
|
646
734
|
const occ = (e) => [e.resource, ...(Array.isArray(e.with) ? e.with : [])];
|
|
647
735
|
const capOf = (rid) => { if (overlap === 'never') return 1; if (overlap === 'allow') return 0; const c = resources.find((r) => r.id === rid)?.capacity; return c === undefined || c === null || c === '' ? 1 : Math.max(0, Number(c) || 0); };
|
|
648
736
|
const evOn = (d, rid) => { const ids = rid ? [rid] : who === 'all' ? null : shown().map((r) => r.id); return events.filter((e) => sameDay(e.day, d) && e.status !== 'cancelled' && (!ids || occ(e).some((x) => ids.includes(x)))); };
|
|
649
|
-
// the most
|
|
737
|
+
// the most appointments one agenda holds at once inside [st, st + dur) (except one, the appointment being moved)
|
|
650
738
|
const peak = (rid, d, st, dur, except) => { const iv = events.filter((x) => x.id !== except && x.status !== 'cancelled' && sameDay(x.day, d) && occ(x).includes(rid)).map((x) => [toMin(x.start), toMin(x.start) + x.dur]).filter(([a, b]) => a < st + dur && b > st); return Math.max(0, ...[st, ...iv.map(([a]) => a).filter((a) => a > st)].map((pt) => iv.filter(([a, b]) => a <= pt && b > pt).length)); };
|
|
651
739
|
const roomFor = (rid, d, st, dur, except) => { const cap = capOf(rid); return cap === 0 || peak(rid, d, st, dur, except) < cap; };
|
|
652
|
-
//
|
|
740
|
+
// appointments that break an agenda's capacity (data from the server that crossed): they say so, in words
|
|
653
741
|
let clash = new Set();
|
|
654
742
|
const findClashes = () => { const out = new Set(); resources.forEach((r) => { const cap = capOf(r.id); if (!cap) return; events.filter((x) => x.status !== 'cancelled' && occ(x).includes(r.id)).forEach((e) => { if (peak(r.id, e.day, toMin(e.start), e.dur, null) > cap) out.add(e.id); }); }); return out; };
|
|
655
743
|
const blOn = (d, rid) => blocks.filter((b) => sameDay(b.day, d) && (!rid || b.resource === rid || b.resource === 'all'));
|
|
@@ -662,13 +750,13 @@ let wheelsNow = [];
|
|
|
662
750
|
const isClosed = (d) => closed.some((c) => sameDay(c, d)) || (!freeList && shown().every((r) => !availability(r.id, d).length));
|
|
663
751
|
// overlapping events share the column: each gets a lane and the lane count of its cluster
|
|
664
752
|
const lanes = (list) => { const s = [...list].sort((a, b) => toMin(a.start) - toMin(b.start)); const out = new Map(); let cluster = [], end = -1; const flush = () => { const lanesEnd = []; cluster.forEach((e) => { const st = toMin(e.start); let l = lanesEnd.findIndex((x) => x <= st); if (l < 0) { l = lanesEnd.length; lanesEnd.push(0); } lanesEnd[l] = st + e.dur; out.set(e.id, [l, 0]); }); cluster.forEach((e) => { out.get(e.id)[1] = lanesEnd.length; }); }; s.forEach((e) => { const st = toMin(e.start); if (st >= end && cluster.length) { flush(); cluster = []; } cluster.push(e); end = Math.max(end, st + e.dur); }); if (cluster.length) flush(); return out; };
|
|
665
|
-
// an app's own words on
|
|
753
|
+
// an app's own words on an appointment (“At home”, “Arrived 10:02”, “Confirmed”): said next to its time, never as colour
|
|
666
754
|
const tagsOf = (e) => (Array.isArray(e.tags) ? e.tags.filter(Boolean).join(' · ') : '');
|
|
667
755
|
const SAY = ['started', 'awaiting_payment', 'noshow']; // the statuses a cita SAYS in words (no stripes, no glow)
|
|
668
756
|
const evHtml = (e, ln, showWho, rid = null, gut = false) => { const [l, n] = ln.get(e.id) || [0, 1]; const short = e.dur < 45; const said = clash.has(e.id) ? w.clashOne : SAY.includes(e.status) && w.status[e.status] ? w.status[e.status] : ''; const others = rid ? occ(e).filter((x) => x !== rid).map(resName).filter(Boolean).join(', ') : ''; const tg = tagsOf(e);
|
|
669
|
-
const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}">${short ? ui.text(`${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(e.title
|
|
757
|
+
const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}${byAgent(e) && agentLabel ? `, ${agentLabel}` : ''}">${short ? ui.text(`${byAgent(e) ? agentImg() : ''}${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(`${byAgent(e) ? agentImg() : ''}${e.title}`, 'label')}${ui.text(`${said ? said + ' · ' : ''}${tg ? tg + ' · ' : ''}${clk(e.start)} · ${e.subtitle || ''}${showWho ? ' · ' + resName(e.resource) : ''}${others ? ' · ' + others : ''}`, 'caption')}`}${e.price && !short ? `<span class="cx-ag-evprice">${fmtMoney(e.price)}</span>` : ''}${dragOn && resizeMode !== 'off' ? '<span class="cx-ag-grip" aria-hidden="true"></span>' : ''}</button>`; };
|
|
670
758
|
// the hours a person does not work: hatched. With server free hours, everything that is neither free nor booked.
|
|
671
|
-
// a day the business does not open: the column hatched,
|
|
759
|
+
// a day the business does not open: the column hatched, “Closed” in the middle (grid) or as the list's only line
|
|
672
760
|
const shutPill = () => `<span class="cx-ag-shutpill">${ui.icon('caloff')}${ui.text(w.closed, 'label')}</span>`;
|
|
673
761
|
const shutHtml = () => `<div class="cx-ag-off cx-ag-shut" style="top:0;height:100%">${shutPill()}</div>`;
|
|
674
762
|
const offHtml = (d, rid) => {
|
|
@@ -678,9 +766,9 @@ let wheelsNow = [];
|
|
|
678
766
|
const out = []; let cur = from * 60; av.forEach(([a, b]) => { if (a > cur) out.push([cur, a]); cur = Math.max(cur, b); }); if (cur < to * 60) out.push([cur, to * 60]);
|
|
679
767
|
return out.map(([a, b]) => `<div class="cx-ag-off" style="top:${y(a)}px;height:${y(b) - y(a)}px"></div>`).join('');
|
|
680
768
|
};
|
|
681
|
-
// the server's free windows, drawn as dashed
|
|
769
|
+
// the server's free windows, drawn as dashed “free” tiles you tap to book (they replace the bare slot buttons)
|
|
682
770
|
const freeHtml = (d, rid, label) => freeOf(rid, d).map(([a, b]) => `<button class="cx-ag-free" data-free="${a}" data-rid="${rid}" data-day="${+d}" style="top:${y(a)}px;height:${y(b) - y(a) - 2}px" aria-label="${w.freeFromTo(clk(toHM(a)), clk(toHM(b)))}, ${label}">${b - a >= 45 ? ui.text(`${w.free} · ${clk(toHM(a))}`, 'caption') : ''}</button>`).join('');
|
|
683
|
-
// PLACING: while the app's
|
|
771
|
+
// PLACING: while the app's “new appointment” sheet is open, where the appointment will land, drawn dashed (placing {resource, day,
|
|
684
772
|
// start, dur, title, tint}); the app clears it when the sheet closes
|
|
685
773
|
// what already went by: today up to now, and whole past days, under a quiet veil (nothing to book there)
|
|
686
774
|
const goneHtml = (d) => { const k = new Date(d); k.setHours(0, 0, 0, 0); if (k > today) return ''; const h = k < today ? (to - from) * hourPx : Math.max(0, Math.min(y(nowMin()), (to - from) * hourPx)); return h ? `<div class="cx-ag-gone" style="height:${h}px" aria-hidden="true"></div>` : ''; };
|
|
@@ -688,12 +776,12 @@ let wheelsNow = [];
|
|
|
688
776
|
const blockHtml = (b) => `<div class="cx-ag-block" style="top:${y(toMin(b.start))}px;height:${(b.dur / 60) * hourPx - 2}px">${ui.text(`${b.reason || w.blocked} · ${clk(b.start)}`, 'caption')}</div>`;
|
|
689
777
|
// while loading, everything that would ask the server for something is shut (days, views, arrows, actions, hours)
|
|
690
778
|
// the toolbar locks only on the very first load: once the agenda has shown data, a skeleton while the next week
|
|
691
|
-
// arrives leaves the arrows, the date and the views free (tapping
|
|
779
|
+
// arrives leaves the arrows, the date and the views free (tapping “next” three times in a row must work)
|
|
692
780
|
let shownOnce = false;
|
|
693
781
|
const shut = () => (loading && !shownOnce ? ' disabled aria-disabled="true"' : '');
|
|
694
782
|
const title = () => (eff() === 'range' ? `${fmtD(rangeSel[0])} → ${fmtD(rangeSel[1])}` : view === 'week' && !narrow ? w.weekOf(fmtD(weekDays()[0])) : w.dayTitle(w.dayL[day.getDay()], day.getDate(), w.mon[day.getMonth()]));
|
|
695
783
|
const toolbar = () => `<div class="cx-ag-bar">${ui.button({ label: w.today, size: 'sm', attrs: `data-ag="today"${shut()}` })}<div class="cx-hstack" style="gap:2px;flex-wrap:nowrap">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', attrs: `data-ag="prev" data-ag-move aria-label="${view === 'week' && !narrow ? w.prevWeek : w.prevDay}"${shut()}` })}${ui.button({ icon: 'right', variant: 'ghost', size: 'icon-sm', attrs: `data-ag="next" data-ag-move aria-label="${view === 'week' && !narrow ? w.nextWeek : w.nextDay}"${shut()}` })}</div><button type="button" class="cx-ag-title" data-ag-date aria-haspopup="dialog" aria-expanded="false" aria-label="${w.goTo}: ${title()}"${shut()}>${ui.text(title(), 'heading')}${ui.icon('chev')}</button><span class="cx-grow"></span><div class="cx-tabs" role="tablist" aria-label="${w.view}">${[['day', w.vDay], ['week', w.vWeek], ['list', w.vList]].filter(([k]) => !narrow || k !== 'week').map(([k, l]) => `<button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" aria-selected="${eff() === k}" data-ag-view="${k}"${shut()}>${l}</button>`).join('')}</div>${bar()}</div>`;
|
|
696
|
-
// the toolbar's own buttons are data: [{id, label, icon, primary}];
|
|
784
|
+
// the toolbar's own buttons are data: [{id, label, icon, primary}]; “block” and “new” keep their behaviour, any other id goes to onAction
|
|
697
785
|
function bar() { const list = Array.isArray(actions) && actions.length ? actions : [{ id: 'block', label: w.block, icon: 'i-lock' }, { id: 'new', label: w.newItem, icon: 'plus', primary: true }]; return list.map((x) => ui.button({ label: `${x.icon ? ui.icon(x.icon) : ''}${x.label}`, variant: x.primary ? 'default' : 'outline', size: 'sm', attrs: `data-ag="${x.id}" data-ag-act${shut()}` })).join(''); }
|
|
698
786
|
const personBtn = (r) => ui.button({ label: `${r.photo ? `<span class="cn-avatar cx-ag-face" style="background-image:url('${r.photo}')"></span>` : ui.avatar(r.name, { size: 'sm' })}${r.name}`, size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === r.id}" data-who="${r.id}"` });
|
|
699
787
|
const groupName = (k) => (k === 'business' ? w.business : k === 'person' ? w.team : resources.find((r) => kindOf(r) === k)?.group || k);
|
|
@@ -708,9 +796,9 @@ let wheelsNow = [];
|
|
|
708
796
|
if (kinds.length < 2) return `<div class="cx-hstack cx-ag-who" role="radiogroup" aria-label="${w.whose}"><span class="cx-ag-wholabel" aria-hidden="true">${w.whose}</span>${all}${resources.map(personBtn).join('')}</div>`;
|
|
709
797
|
return `<div class="cx-hstack cx-ag-who" role="radiogroup" aria-label="${w.whose}"><span class="cx-ag-wholabel" aria-hidden="true">${w.whose}</span>${all}${kinds.map((k) => { const rs = ordered().filter((r) => kindOf(r) === k); return `<span class="cx-ag-sep" aria-hidden="true"></span>${rs.length > 1 ? ui.button({ label: groupName(k), size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === 'kind:' + k}" data-who="kind:${k}"`, cls: 'cx-ag-group' }) : ''}${rs.map(personBtn).join('')}`; }).join('')}</div>`;
|
|
710
798
|
};
|
|
711
|
-
// the week strip: each day with how many
|
|
712
|
-
const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading && !shownOnce ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b>${phoneStrip === 'dots' ? `<span class="cx-ag-dots" aria-hidden="true">${loading ? '' :
|
|
713
|
-
//
|
|
799
|
+
// the week strip: each day with how many appointments it has (a plain number), closed days dimmed
|
|
800
|
+
const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading && !shownOnce ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b>${phoneStrip === 'dots' ? `<span class="cx-ag-dots" aria-hidden="true">${loading ? '' : (n ? '<i></i>' : '')}</span>` : `<span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span>`}</button>`; }).join('')}</div>`;
|
|
801
|
+
// “Ahora”: on today, the appointment in progress (started, or whose time is now), first thing, one tap to open it
|
|
714
802
|
const nowCard = () => {
|
|
715
803
|
if (!sameDay(day, today)) return '';
|
|
716
804
|
const nowM = nowMin();
|
|
@@ -725,15 +813,19 @@ let wheelsNow = [];
|
|
|
725
813
|
const skHead = (c) => { const r = resources.find((x) => x.id === c.rid); return v0() === 'week' ? `<span class="cx-ag-skhead" aria-hidden="true"><s class="skel" style="width:56px"></s></span>` : `<span class="cx-ag-skhead" aria-hidden="true"><s class="skel" style="width:24px;height:24px;border-radius:50%"></s><span class="cx-ag-colname"><b><s class="skel cx-ag-skline" style="width:64px"></s></b>${r && roleOf(r) ? `<small><s class="skel cx-ag-skline" style="width:48px"></s></small>` : ''}</span></span>`; };
|
|
726
814
|
const v0 = eff;
|
|
727
815
|
// the line at the current time, in today's column (closed or not: the time still passes), its dot in the first one
|
|
728
|
-
|
|
729
|
-
|
|
816
|
+
// NOW, across the whole week when today is in it (as every calendar draws it), one unbroken line from the hour's pill:
|
|
817
|
+
// the days already gone show it faint, the days ahead whole; it rides over everything in the grid (appointments, blocks).
|
|
818
|
+
// The dot marks where today's column starts, so day and week begin the same (pill, tail, line): in day view, or
|
|
819
|
+
// when today is the week's first column, the pill already stands there and no dot is drawn. Nothing while loading.
|
|
820
|
+
const nowLine = (c, ci, cols) => { const nowM = nowMin(); const at = cols.findIndex((k) => sameDay(k.day, today)); if (at < 0 || nowM <= from * 60 || nowM >= to * 60) return ''; const mine = sameDay(c.day, today); const gone = !mine && c.day < today; return `<div class="cx-ag-now${mine ? '' : gone ? ' cx-ag-now-past' : ' cx-ag-now-ahead'}" style="top:${y(nowM)}px" aria-hidden="true">${mine && at === ci && ci > 0 ? '<i></i>' : ''}</div>`; };
|
|
821
|
+
const grid = (cols) => { const hours = Array.from({ length: to - from }, (_, i) => from + i); const nowM = nowMin(); return `<div class="cx-ag-scroll cx-scroll"><div class="cx-ag" style="--cols:${cols.length};--h:${hourPx}px;grid-template-rows:auto auto"><div class="cx-ag-head"><span></span>${cols.map((c) => `<div class="cx-ag-colhead" ${c.today ? 'data-today' : ''} ${c.closed ? 'data-closed' : ''}>${loading ? skHead(c) : c.head}</div>`).join('')}</div><div class="cx-ag-body" style="height:${(to - from) * hourPx}px"><div class="cx-ag-times">${!loading && cols.some((k) => sameDay(k.day, today)) && nowM > from * 60 && nowM < to * 60 ? `<b class="cx-ag-nowtime" style="top:${y(nowM)}px"${clk(toHM(nowM)).length > 7 ? ' data-long' : ''}>${clk(toHM(nowM))}</b>` : ''}${hours.map((h) => `<span style="top:${(h - from) * hourPx}px">${ui.text(hour12 ? `${((h + 11) % 12) + 1} ${h < 12 ? 'AM' : 'PM'}` : `${String(h).padStart(2, '0')}:00`, 'caption')}</span>`).join('')}</div>${cols.map((c, ci) => { const rid = c.rid === 'all' ? null : c.rid; const evs = evOn(c.day, rid); const cap = rid ? capOf(rid) : 1; const ln = lanes(evs); if (cap > 1) ln.forEach((v) => { v[1] = Math.max(v[1], Math.min(cap, 4)); }); const rids = rid ? [rid] : shown().map((r) => r.id); if (loading) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" aria-hidden="true">${rid && !freeList ? offHtml(c.day, rid) : ''}${skelCol(ci)}</div>`;
|
|
730
822
|
const offRid = rid || (shown().length === 1 && !freeList ? shown()[0].id : null);
|
|
731
823
|
if (c.closed && !evs.length) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" data-closed role="group" aria-label="${c.label}, ${w.closed}">${shutHtml()}${nowLine(c, ci, cols)}</div>`;
|
|
732
824
|
return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" role="group" aria-label="${c.label}">${offRid ? offHtml(c.day, offRid) : ''}${!rids.some(bookable) ? '' : freeList ? rids.filter(bookable).map((r) => freeHtml(c.day, r, c.label)).join('') : Array.from({ length: ((to - from) * 60) / slot }, (_, i) => gone(c.day, from * 60 + i * slot) ? '' : `<button class="cx-ag-slot" data-slot-time="${from * 60 + i * slot}" style="top:${y(from * 60 + i * slot)}px;height:${(slot / 60) * hourPx}px" aria-label="${w.newItem}, ${c.label}, ${toHM(from * 60 + i * slot)}" tabindex="-1"></button>`).join('')}${blOn(c.day, rid).map(blockHtml).join('')}${evs.map((e) => evHtml(e, ln, c.rid === 'all' && resources.length > 1, rid, cap === 0)).join('')}${ghostHtml(c.day, rid)}${goneHtml(c.day)}${nowLine(c, ci, cols)}</div>`; }).join('')}</div></div></div>`; };
|
|
733
825
|
// list = the phone's agenda: a solid row is sold, a dashed one is still free
|
|
734
|
-
// one
|
|
735
|
-
const evRow = (e) => `<button class="cx-ag-row" data-ev="${e.id}"${clash.has(e.id) ? ' data-conflict' : ''} data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}"><span class="cx-ag-row-t">${ui.text(clk(e.start), 'strong')}${ui.text(`${e.dur} min`, 'caption')}</span>${e.photo ? `<span class="cn-avatar cx-ag-face cx-ag-rowface" style="background-image:url('${e.photo}')" aria-hidden="true"></span>` : ''}<span class="cx-vstack" style="gap:0;min-width:0">${ui.text(e.title, 'heading')}${ui.text(`${e.subtitle || ''}${resources.length > 1 ? ' · ' + resName(e.resource) : ''}${tagsOf(e) ? ' · ' + tagsOf(e) : ''}`, 'muted')}</span>${e.status && w.status[e.status] ? ui.badge(w.status[e.status], { tone: { info: 'info', live: 'wait', done: 'ok', bad: 'bad', ok: 'ok' }[toneOf(e)] || 'ok' }) : e.steps ? ui.badge(e.steps[e.at], { tone: e.tone === 'wait' ? 'wait' : e.tone === 'info' ? 'info' : 'ok' }) : ''}${e.price ? ui.text(fmtMoney(e.price), 'strong') : ''}</button>`;
|
|
736
|
-
// RANGE: the days picked in the calendar, one after another, each with its
|
|
826
|
+
// one appointment as a row of the list (the day's list and the range's)
|
|
827
|
+
const evRow = (e) => `<button class="cx-ag-row" data-ev="${e.id}"${clash.has(e.id) ? ' data-conflict' : ''} data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}"><span class="cx-ag-row-t">${ui.text(clk(e.start), 'strong')}${ui.text(`${e.dur} min`, 'caption')}</span>${e.photo ? `<span class="cn-avatar cx-ag-face cx-ag-rowface" style="background-image:url('${e.photo}')" aria-hidden="true"></span>` : ''}<span class="cx-vstack" style="gap:0;min-width:0">${ui.text(e.title, 'heading')}${ui.text(`${e.subtitle || ''}${resources.length > 1 ? ' · ' + resName(e.resource) : ''}${tagsOf(e) ? ' · ' + tagsOf(e) : ''}`, 'muted')}${byAgent(e) && agentLabel ? `<span class="cx-ag-byline">${agentImg()}${agentLabel}</span>` : ''}</span>${e.status && w.status[e.status] ? ui.badge(w.status[e.status], { tone: { info: 'info', live: 'wait', done: 'ok', bad: 'bad', ok: 'ok' }[toneOf(e)] || 'ok' }) : e.steps ? ui.badge(e.steps[e.at], { tone: e.tone === 'wait' ? 'wait' : e.tone === 'info' ? 'info' : 'ok' }) : ''}${e.price ? ui.text(fmtMoney(e.price), 'strong') : ''}</button>`;
|
|
828
|
+
// RANGE: the days picked in the calendar, one after another, each with its appointments (a day with none says so)
|
|
737
829
|
const rangeList = () => { const [a, b] = rangeSel; const n = Math.min(62, Math.round((b - a) / 864e5) + 1); return `<div class="cx-ag-list cx-ag-range" role="list">${Array.from({ length: n }, (_, k) => { const d = addDays(a, k); const evs = evOn(d).sort((x, z) => toMin(x.start) - toMin(z.start)); return `<button type="button" class="cx-ag-rangeday" data-ag-day="${+d}" ${sameDay(d, today) ? 'data-today' : ''}>${ui.text(`${w.dayL[d.getDay()]} ${d.getDate()} ${w.mon[d.getMonth()]}`, 'strong')}${ui.text(isClosed(d) && !evs.length ? w.closed : evs.length ? w.countOf(evs.length) : w.noItems, 'caption')}</button>${evs.map(evRow).join('')}`; }).join('')}</div>`; };
|
|
738
830
|
const list = () => {
|
|
739
831
|
const rids = shown().map((r) => r.id), rows = [];
|
|
@@ -754,11 +846,11 @@ let wheelsNow = [];
|
|
|
754
846
|
return `<div class="cx-ag-list" role="list">${html || (isClosed(day) ? `<div class="cx-ag-shutlist">${shutPill()}</div>` : '') || `<div class="cn-empty" style="padding:32px 12px"><div class="cn-empty-header"><div class="cn-empty-title">${!rids.some((rid) => availability(rid, day).length) ? w.noHours : +day < +today && !sameDay(day, today) ? w.dayOver : sameDay(day, today) ? w.todayOver : w.noHours}</div></div></div>`}</div>`;
|
|
755
847
|
};
|
|
756
848
|
// LOADING keeps the very layout it is about to show: the same toolbar, strip, column heads, hours and height,
|
|
757
|
-
// with grey
|
|
849
|
+
// with grey appointments where the real ones will land. Nothing jumps when the data arrives, nothing shrinks while it loads.
|
|
758
850
|
const SKEL = [[0.5, 1], [2, 1.5], [4.5, 1], [6, 2], [8, 1]];
|
|
759
851
|
const skelCol = (ci) => SKEL.filter(([h], i) => (i + ci) % 3 !== 2 && h < to - from).map(([h, len]) => `<span class="cx-ag-skev skel" style="top:${h * hourPx + 2}px;height:${Math.min(len, to - from - h) * hourPx - 4}px"></span>`).join('');
|
|
760
852
|
const skelList = () => `<div class="cx-ag-list" aria-hidden="true">${Array.from({ length: Math.max(6, Math.round((to - from) * 60 / slot / 2)) }, (_, i) => `<div class="cx-ag-row cx-ag-skrow"><span class="cx-ag-row-t"><s class="skel" style="width:38px"></s><s class="skel" style="width:28px"></s></span><span class="cx-vstack" style="gap:6px;flex:1"><s class="skel" style="width:${[46, 38, 54, 42][i % 4]}%"></s><s class="skel" style="width:${[30, 26, 34, 22][i % 4]}%"></s></span></div>`).join('')}</div>`;
|
|
761
|
-
// the top band keeps what it had (the
|
|
853
|
+
// the top band keeps what it had (the “Ahora” card, the queue) as grey shapes, so its height holds too
|
|
762
854
|
let hadNow = false, hadQueue = false;
|
|
763
855
|
const skelTop = () => `${hadNow && sameDay(day, today) ? '<div class="cx-ag-nowcard cx-ag-sknow" aria-hidden="true"><span class="cx-vstack" style="gap:0;flex:1">' + [[90, 'caption'], [160, 'heading'], [120, 'muted']].map(([wd, v]) => ui.text(`<s class="skel cx-ag-skline" style="width:${wd}px"></s>`, v)).join('') + '</span></div>' : ''}${hadQueue ? '<div class="cx-ag-queue" aria-hidden="true"><s class="skel" style="width:24px;height:24px;border-radius:50%"></s><s class="skel" style="width:24px;height:24px;border-radius:50%;margin-left:4px"></s><s class="skel" style="width:70px;margin-left:8px"></s></div>' : ''}`;
|
|
764
856
|
// the days shown, so an app can load exactly those (it is told once per change)
|
|
@@ -777,7 +869,7 @@ let wheelsNow = [];
|
|
|
777
869
|
else return onAction(k);
|
|
778
870
|
paint();
|
|
779
871
|
}
|
|
780
|
-
// a week column says its day and, under it, how many
|
|
872
|
+
// a week column says its day and, under it, how many appointments it holds and who waits for a free hour (closed: “Closed”)
|
|
781
873
|
const weekHead = (d, rid) => { const n = evOn(d, rid).length, q = queue.filter((x) => sameDay(x.day, d)).length, shut = isClosed(d); const sub = [shut && !n ? w.closed : n ? w.countOf(n) : '', q ? w.waiting(q) : ''].filter(Boolean).join(' · '); return `<span class="cx-ag-colday">${w.dayS[d.getDay()]} ${d.getDate()}</span>${sub ? `<small class="cx-ag-colsub"${q ? ' data-queue' : ''}>${sub}</small>` : ''}`; };
|
|
782
874
|
const tellRange = () => { const v = eff(); const a = v === 'range' ? rangeSel[0] : v === 'week' ? weekDays()[0] : day, b = v === 'range' ? rangeSel[1] : v === 'week' ? weekDays()[6] : day; const key = `${v}|${+a}|${+b}`; if (key === lastRange) return; lastRange = key; onRange({ from: a, to: b, view: v }); };
|
|
783
875
|
const paint = () => {
|
|
@@ -799,7 +891,7 @@ let wheelsNow = [];
|
|
|
799
891
|
};
|
|
800
892
|
// drag to move · drag the bottom edge to resize (15 min). A ghost shows where it lands and turns red where the
|
|
801
893
|
// time is not free; the real event never moves until a valid drop. Mouse starts after 6 px, touch after a long press.
|
|
802
|
-
// With server free hours,
|
|
894
|
+
// With server free hours, “free” means inside one of them or inside the appointment's own time.
|
|
803
895
|
// the past and the closed days are never free, whatever the hours say
|
|
804
896
|
const gone = (d, st) => { const k = new Date(d); k.setHours(0, 0, 0, 0); return k < today || (sameDay(k, today) && st < nowMin()); };
|
|
805
897
|
const fits = (rid, d, st, dur, except) => {
|
|
@@ -820,7 +912,7 @@ let wheelsNow = [];
|
|
|
820
912
|
if (d.resize) { const top = d.ev.getBoundingClientRect().top; d.dur = Math.max(15, Math.min(to * 60 - toMin(d.it.start), Math.round(((cy - top) / hourPx) * 60 / 15) * 15)); d.target = { day: d.it.day, resource: d.it.resource, start: d.it.start }; }
|
|
821
913
|
else {
|
|
822
914
|
let col = document.elementsFromPoint(cx, cy).find((x) => x.classList?.contains('cx-ag-col')); if (!col) return;
|
|
823
|
-
// moveAcross "off": over another person's column the
|
|
915
|
+
// moveAcross "off": over another person's column the appointment stays in its own (only its time changes)
|
|
824
916
|
if (moveAcross === 'off' && col.dataset.rid !== 'all' && col.dataset.rid !== d.it.resource) col = root.querySelector(`.cx-ag-col[data-rid="${d.it.resource}"][data-day="${col.dataset.day}"]`) || col;
|
|
825
917
|
const cr = col.getBoundingClientRect(), m = Math.round((((cy - d.dy - cr.top) / hourPx) * 60 + from * 60) / 15) * 15;
|
|
826
918
|
d.target = { day: new Date(+col.dataset.day), resource: col.dataset.rid === 'all' || moveAcross === 'off' ? d.it.resource : col.dataset.rid, start: toHM(Math.max(from * 60, Math.min(to * 60 - d.it.dur, m))) };
|
|
@@ -849,7 +941,7 @@ let wheelsNow = [];
|
|
|
849
941
|
if (commit && !d.ok && changed) island('error', w.notFreeLong, 2000);
|
|
850
942
|
// back to where it was: the ghost flies home, then goes
|
|
851
943
|
const home = d.ev.getBoundingClientRect(), g = d.ghost.getBoundingClientRect();
|
|
852
|
-
d.ghost.animate([{ transform: 'none', opacity: 1 }, { transform: `translate(${home.left - g.left}px, ${home.top - g.top}px)`, opacity: 0.4 }], { duration: 220, easing: '
|
|
944
|
+
d.ghost.animate([{ transform: 'none', opacity: 1 }, { transform: `translate(${home.left - g.left}px, ${home.top - g.top}px)`, opacity: 0.4 }], { duration: 220, easing: ease('--ease-out'), fill: 'forwards' }).finished.then(() => d.ghost.remove());
|
|
853
945
|
};
|
|
854
946
|
root.addEventListener('pointerdown', (e) => {
|
|
855
947
|
if (!dragOn || loading) return;
|
|
@@ -912,7 +1004,7 @@ let wheelsNow = [];
|
|
|
912
1004
|
const tick = setInterval(() => {
|
|
913
1005
|
if (!root.isConnected) { clearInterval(tick); return; }
|
|
914
1006
|
if (drag || loading) return; const m = nowMin(); if (m <= from * 60 || m >= to * 60) return;
|
|
915
|
-
$$('.cx-ag-now', root).forEach((x) => { x.style.top = y(m) + 'px'; }); const t = $('.cx-ag-nowtime', root); if (t) { t.style.top = y(m) + 'px'; t.textContent = clk(toHM(m)); }
|
|
1007
|
+
$$('.cx-ag-now', root).forEach((x) => { x.style.top = y(m) + 'px'; }); const t = $('.cx-ag-nowtime', root); if (t) { t.style.top = y(m) + 'px'; t.textContent = clk(toHM(m)); t.toggleAttribute('data-long', t.textContent.length > 7); }
|
|
916
1008
|
$$('.cx-ag-col', root).forEach((c) => { if (!sameDay(new Date(+c.dataset.day), today)) return; const g = $('.cx-ag-gone', c); if (g) g.style.height = y(m) + 'px'; });
|
|
917
1009
|
}, 30000);
|
|
918
1010
|
// only the agenda's own box decides: crossing the phone width repaints, and with fit a new HEIGHT of the box measures
|
|
@@ -927,7 +1019,7 @@ let wheelsNow = [];
|
|
|
927
1019
|
fitKey = ''; fitSeen.clear(); paint(); })).observe(root);
|
|
928
1020
|
|
|
929
1021
|
// ── the event sheet (showcase): detail · lifecycle · payment · move (second step) · cancel (second step).
|
|
930
|
-
// In app mode a tapped
|
|
1022
|
+
// In app mode a tapped appointment goes to onOpen and the app opens its own sheet. ──
|
|
931
1023
|
let open1 = null, step = 'detail', moveTo = null;
|
|
932
1024
|
const openEvent = (ev) => { if (!ev) return; if (appMode && onOpen) return onOpen(ev); open1 = ev; step = 'detail'; moveTo = null; sheetOf('agEvent'); };
|
|
933
1025
|
const sheet = () => {
|
|
@@ -1006,8 +1098,8 @@ let wheelsNow = [];
|
|
|
1006
1098
|
// ── block time: the form is the agenda's; the block itself is the app's when it listens (onBlock), else local ──
|
|
1007
1099
|
let blockOpen = false;
|
|
1008
1100
|
// THE TITLE OPENS A MONTH, floating under it (on a phone, the sheet from below): one day, or a range of days
|
|
1009
|
-
// (
|
|
1010
|
-
//
|
|
1101
|
+
// (“Several days”: the appointments of each day one after another), and the month's title opens a year of months.
|
|
1102
|
+
// “See which”: each press scrolls to the next overlapping appointment and marks it for a moment
|
|
1011
1103
|
let clashAt = 0;
|
|
1012
1104
|
const seeClash = () => { const els = $$('.cx-ag-ev[data-conflict], .cx-ag-row[data-conflict]', root); if (!els.length) return; const el = els[clashAt++ % els.length]; const sc = $('.cx-ag-scroll', root);
|
|
1013
1105
|
if (sc && sc.contains(el)) { const a = sc.getBoundingClientRect(), b = el.getBoundingClientRect(); sc.scrollTo({ top: sc.scrollTop + b.top - a.top - 60, left: sc.scrollLeft + b.left - a.left - 60, behavior: 'smooth' }); } else el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
@@ -1098,6 +1190,7 @@ let wheelsNow = [];
|
|
|
1098
1190
|
if ('availability' in next) availability = next.availability; if ('free' in next) freeList = next.free; if ('queue' in next) queue = next.queue;
|
|
1099
1191
|
if ('closed' in next) closed = next.closed; if ('loading' in next) loading = next.loading;
|
|
1100
1192
|
if (Number(next.from) >= 0 && Number(next.to) > Number(next.from)) { from = Number(next.from); to = Number(next.to); }
|
|
1193
|
+
if ('agentMark' in next) agentMark = next.agentMark; if ('agentLabel' in next) agentLabel = next.agentLabel;
|
|
1101
1194
|
if ('overlap' in next) overlap = next.overlap; if ('placing' in next) placing = next.placing; if ('currency' in next) currency = next.currency; if ('locale' in next) locale = next.locale; if ('cents' in next) cents = next.cents;
|
|
1102
1195
|
if ('lang' in next || 'words' in next) { lang = next.lang ?? lang; words = next.words ?? words; w = { ...(AG_WORDS[lang] || AG_WORDS.es), ...words }; }
|
|
1103
1196
|
if (who !== 'all' && !(who.startsWith('kind:') ? resources.some((r) => kindOf(r) === who.slice(5)) : resources.some((r) => r.id === who))) who = 'all';
|
|
@@ -1110,10 +1203,10 @@ let wheelsNow = [];
|
|
|
1110
1203
|
const TICK = { sending: 'clock', sent: 'i-tick', delivered: 'i-ticks', read: 'i-ticks' };
|
|
1111
1204
|
kit.bubble = (m) => {
|
|
1112
1205
|
if (m.day) return `<div class="cx-chat-day" role="separator">${ui.text(m.day, 'caption')}</div>`;
|
|
1113
|
-
const body = m.image ? `<span class="cx-chat-img" style="background:${m.image}" role="img" aria-label="
|
|
1114
|
-
: m.audio ? `<span class="cx-chat-audio">${ui.button({ icon: 'play', variant: 'ghost', size: 'icon-sm', cls: 'cx-audio-btn', attrs: `data-audio="${m.audio}" aria-label="
|
|
1206
|
+
const body = m.image ? `<span class="cx-chat-img" style="background:${m.image}" role="img" aria-label="${T0('Photo')}"></span>${m.text ? `<p>${m.text}</p>` : ''}`
|
|
1207
|
+
: m.audio ? `<span class="cx-chat-audio">${ui.button({ icon: 'play', variant: 'ghost', size: 'icon-sm', cls: 'cx-audio-btn', attrs: `data-audio="${m.audio}" aria-label="${T0('Play {0} s of audio', m.audio)}"` })}${ui.progress(0, T0('Audio'))}${ui.text(`0:${String(m.audio).padStart(2, '0')}`, 'caption')}</span>`
|
|
1115
1208
|
: m.card ? `<span class="cx-chat-card">${m.card}</span>` : `<p>${m.text}</p>`;
|
|
1116
|
-
return `<div class="cx-chat-msg" data-who="${m.out ? 'out' : 'in'}" ${m.id ? `data-mid="${m.id}"` : ''}><div class="cx-chat-bubble">${body}<span class="cx-chat-meta">${m.tag ? `<span class="cx-chat-tag">${m.tagIcon ? ui.icon(m.tagIcon) : ''}${m.tag}</span>` : ''}${ui.text(m.time || '', 'caption')}${m.out && m.status ? `<svg class="cx-tick" data-status="${m.status}" aria-label="${{ sending: '
|
|
1209
|
+
return `<div class="cx-chat-msg" data-who="${m.out ? 'out' : 'in'}" ${m.id ? `data-mid="${m.id}"` : ''}><div class="cx-chat-bubble">${body}<span class="cx-chat-meta">${m.tag ? `<span class="cx-chat-tag">${m.tagIcon ? ui.icon(m.tagIcon) : ''}${m.tag}</span>` : ''}${ui.text(m.time || '', 'caption')}${m.out && m.status ? `<svg class="cx-tick" data-status="${m.status}" aria-label="${T0({ sending: 'Sending', sent: 'Sent', delivered: 'Delivered', read: 'Read' }[m.status])}"><use href="#${TICK[m.status]}"/></svg>` : ''}</span></div>${m.actions ? `<div class="cx-chat-actions">${m.actions}</div>` : ''}</div>`;
|
|
1117
1210
|
};
|
|
1118
1211
|
// audio bubbles play (a demo clock drives the Progress atom)
|
|
1119
1212
|
document.addEventListener('click', (e) => {
|
|
@@ -1123,7 +1216,7 @@ let wheelsNow = [];
|
|
|
1123
1216
|
let t = b._pos || 0; b.innerHTML = ui.icon('pause');
|
|
1124
1217
|
b._t = setInterval(() => { t += 0.25; b._pos = t; bar.style.transform = `translateX(-${100 - (t / secs) * 100}%)`; label.textContent = `0:${String(Math.floor(t)).padStart(2, '0')}`; if (t >= secs) { clearInterval(b._t); b._t = null; b._pos = 0; b.innerHTML = ui.icon('play'); } }, 250);
|
|
1125
1218
|
});
|
|
1126
|
-
const typing = () => `<div class="cx-chat-msg" data-who="in" data-typing><div class="cx-chat-bubble cx-typing" aria-label="
|
|
1219
|
+
const typing = () => `<div class="cx-chat-msg" data-who="in" data-typing><div class="cx-chat-bubble cx-typing" aria-label="${T0('Typing')}"><i></i><i></i><i></i></div></div>`;
|
|
1127
1220
|
|
|
1128
1221
|
// ── More atoms: Card, Alert and the InputGroup button, so nothing below is drawn by hand ──
|
|
1129
1222
|
ui.card = ({ title = '', description = '', action = '', content = '', footer = '', size = 'sm', cls = '', attrs = '' }) =>
|
|
@@ -1134,7 +1227,7 @@ let wheelsNow = [];
|
|
|
1134
1227
|
`<button type="${type}" data-slot="button" data-size="${size}" class="cn-button cn-button-variant-${variant} cn-input-group-button cn-input-group-button-size-${size} group/button" ${attrs}>${icon ? ui.icon(icon) : ''}${label}</button>`;
|
|
1135
1228
|
|
|
1136
1229
|
// ── Action menu: the library's dropdown for verbs (archive, delete…), items [{id, label, icon, danger}] ──
|
|
1137
|
-
kit.actionMenu = (trigger, { items, label = '
|
|
1230
|
+
kit.actionMenu = (trigger, { items, label = T0('Options'), onPick }) => {
|
|
1138
1231
|
const menu = document.createElement('div'); menu.className = 'cn-dropdown-menu-content pg-pop cx-selmenu'; menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', label); menu.hidden = true; lib.appendChild(menu);
|
|
1139
1232
|
trigger.setAttribute('aria-haspopup', 'menu'); trigger.setAttribute('aria-expanded', 'false');
|
|
1140
1233
|
const paint = () => { menu.innerHTML = (typeof items === 'function' ? items() : items).map((it) => `<div class="cn-dropdown-menu-item cx-selitem" role="menuitem" tabindex="-1" data-mi-id="${it.id}" ${it.danger ? 'data-variant="destructive"' : ''}><span class="cx-hstack" style="gap:8px">${ui.icon(it.icon)}${it.label}</span></div>`).join(''); };
|
|
@@ -1148,8 +1241,8 @@ let wheelsNow = [];
|
|
|
1148
1241
|
|
|
1149
1242
|
// ── Composer: shadcn's InputGroup with a Textarea and a block-end addon for its buttons ──
|
|
1150
1243
|
const EMOJI = ['😀', '😂', '😊', '😍', '🙏', '👍', '👌', '🙌', '🎉', '❤️', '🔥', '✨', '📅', '⏰', '📍', '💳', '✅', '👋', '🤝', '😉'];
|
|
1151
|
-
kit.composer = ({ placeholder = '
|
|
1152
|
-
`<form class="cx-composer" data-composer><div data-slot="input-group" role="group" class="cn-input-group group/input-group cx-composer-box"><textarea data-slot="input-group-control" class="cn-textarea cn-input-group-textarea cx-composer-in" rows="1" placeholder="${placeholder}" aria-label="${placeholder}"></textarea><div data-slot="input-group-addon" data-align="block-end" role="group" class="cn-input-group-addon cn-input-group-addon-align-block-end">${emoji ? ui.groupButton({ icon: 'smile', attrs: 'data-emoji aria-haspopup="dialog" aria-label="Emoji" title="Emoji"' }) : ''}${attach ? ui.groupButton({ icon: 'i-clip', attrs: 'data-attach aria-label="Adjuntar" title="Adjuntar"' }) : ''}${templates.length ? ui.groupButton({ icon: 'i-doc', attrs: 'data-templates aria-label="Plantillas" title="Plantillas"' }) : ''}<span class="cx-grow"></span>${ui.groupButton({ icon: 'i-send', variant: 'default', type: 'submit', attrs:
|
|
1244
|
+
kit.composer = ({ placeholder = T0('Write a message'), attach = true, emoji = true, templates = [], hint = '' } = {}) =>
|
|
1245
|
+
`<form class="cx-composer" data-composer><div data-slot="input-group" role="group" class="cn-input-group group/input-group cx-composer-box"><textarea data-slot="input-group-control" class="cn-textarea cn-input-group-textarea cx-composer-in" rows="1" placeholder="${placeholder}" aria-label="${placeholder}"></textarea><div data-slot="input-group-addon" data-align="block-end" role="group" class="cn-input-group-addon cn-input-group-addon-align-block-end">${emoji ? ui.groupButton({ icon: 'smile', attrs: 'data-emoji aria-haspopup="dialog" aria-label="Emoji" title="Emoji"' }) : ''}${attach ? ui.groupButton({ icon: 'i-clip', attrs: 'data-attach aria-label="Adjuntar" title="Adjuntar"' }) : ''}${templates.length ? ui.groupButton({ icon: 'i-doc', attrs: 'data-templates aria-label="Plantillas" title="Plantillas"' }) : ''}<span class="cx-grow"></span>${ui.groupButton({ icon: 'i-send', variant: 'default', type: 'submit', attrs: `data-send aria-label="${T0('Send')}" title="${T0('Send')}" disabled` })}</div></div>${hint ? `<p class="cx-composer-hint">${ui.text(hint, 'caption')}</p>` : ''}</form>`;
|
|
1153
1246
|
const autosize = (ta) => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; };
|
|
1154
1247
|
const wireComposer = (root, onSend, templates = []) => {
|
|
1155
1248
|
const form = $('[data-composer]', root), ta = $('textarea', form), send = $('[data-send]', form);
|
|
@@ -1159,10 +1252,10 @@ let wheelsNow = [];
|
|
|
1159
1252
|
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } });
|
|
1160
1253
|
form.addEventListener('submit', (e) => { e.preventDefault(); if ('stop' in send.dataset) return; const t = ta.value.trim(); if (!t) return; ta.value = ''; sync(); onSend(t); });
|
|
1161
1254
|
const tb = $('[data-templates]', form);
|
|
1162
|
-
if (tb) kit.actionMenu(tb, { label: '
|
|
1255
|
+
if (tb) kit.actionMenu(tb, { label: T0('Templates'), items: templates.map((t, i) => ({ id: String(i), label: t[0], icon: 'i-doc' })), onPick: (i) => { ta.value = templates[+i][1]; sync(); ta.focus(); } });
|
|
1163
1256
|
const eb = $('[data-emoji]', form);
|
|
1164
1257
|
if (eb) {
|
|
1165
|
-
const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-emoji'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', 'Emoji'); pop.hidden = true; lib.appendChild(pop);
|
|
1258
|
+
const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-emoji'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', T0('Emoji')); pop.hidden = true; lib.appendChild(pop);
|
|
1166
1259
|
pop.innerHTML = EMOJI.map((x) => ui.button({ label: x, variant: 'ghost', size: 'icon', attrs: `data-emo="${x}" aria-label="${x}"` })).join('');
|
|
1167
1260
|
eb.addEventListener('click', () => { if (pop.dataset.state === 'open') return hide(pop, 120); pop.hidden = false; place(pop, eb, 'start', 8); show(pop); });
|
|
1168
1261
|
pop.addEventListener('click', (e) => { const b = e.target.closest('[data-emo]'); if (b) insert(b.dataset.emo); });
|
|
@@ -1176,14 +1269,15 @@ let wheelsNow = [];
|
|
|
1176
1269
|
// and the person panel are options; a conversation is { id, name, channel, handle, state, control, unread, last,
|
|
1177
1270
|
// lastBy, time, window, archived, messages }. A message can be text/image/audio/card (kit.bubble), a day, a
|
|
1178
1271
|
// system line { system, icon } or an event card { event: { icon, title, subtitle, rows, total, state, actions } }.
|
|
1179
|
-
kit.messaging = (root, { title = '
|
|
1272
|
+
kit.messaging = (root, { title = T0('Messages'), channels, conversations, filters, states = {}, control = {}, quick = [], profile = () => null, templates = [], reply = () => null, hint = T0('Enter sends · Shift+Enter adds a line break') }) => {
|
|
1273
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1180
1274
|
let cur = conversations[0].id, filter = filters[0].id, q = '', archived = false, armed = null;
|
|
1181
1275
|
const conv = () => conversations.find((c) => c.id === cur);
|
|
1182
1276
|
const inView = (c) => !!c.archived === archived && (!q || fold(`${c.name} ${c.last}`).includes(fold(q)));
|
|
1183
1277
|
const visible = () => conversations.filter((c) => inView(c) && filters.find((f) => f.id === filter).match(c));
|
|
1184
1278
|
const chMark = (c) => `<span class="cx-ch" style="--brand:${channels[c.channel].color}" title="${channels[c.channel].name}">${ui.icon(channels[c.channel].icon)}</span>`;
|
|
1185
1279
|
const stateBadge = (c) => (states[c.state] ? ui.badge(states[c.state].label, { tone: states[c.state].tone }) : '');
|
|
1186
|
-
const animIn = (el) => el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: '
|
|
1280
|
+
const animIn = (el) => el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: ease('--ease-out') });
|
|
1187
1281
|
const clock = () => { const n = new Date(); return `${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}`; };
|
|
1188
1282
|
|
|
1189
1283
|
// ── list ──
|
|
@@ -1191,7 +1285,7 @@ let wheelsNow = [];
|
|
|
1191
1285
|
const paintChips = () => { $('.cx-mx-chips', root).innerHTML = filters.map((f) => ui.button({ label: `${f.label}<span class="cx-mx-n">${conversations.filter((c) => inView(c) && f.match(c)).length}</span>`, size: 'xs', attrs: `data-mx-f="${f.id}" aria-pressed="${f.id === filter}"` })).join(''); };
|
|
1192
1286
|
const paintList = () => {
|
|
1193
1287
|
const items = visible();
|
|
1194
|
-
$('.cx-mx-items', root).innerHTML = items.map(row).join('') || `<div class="cn-empty cx-mx-empty"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(archived ? 'i-archive' : 'i-inbox')}</div><div class="cn-empty-title">${archived ? '
|
|
1288
|
+
$('.cx-mx-items', root).innerHTML = items.map(row).join('') || `<div class="cn-empty cx-mx-empty"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(archived ? 'i-archive' : 'i-inbox')}</div><div class="cn-empty-title">${archived ? T('Nothing archived') : T('Nothing here')}</div><div class="cn-empty-description">${q ? T('Try another name.') : archived ? T('What you archive stays here.') : T('When they write to you, it shows up here.')}</div></div></div>`;
|
|
1195
1289
|
$('.cx-mx-count', root).textContent = `${conversations.filter((c) => !!c.archived === archived).length} ${archived ? 'archivadas' : 'conversaciones'}`;
|
|
1196
1290
|
$('[data-mx-arch]', root).innerHTML = archived ? `${ui.icon('i-inbox')}Bandeja` : `${ui.icon('i-archive')}Archivadas`;
|
|
1197
1291
|
paintChips();
|
|
@@ -1209,22 +1303,22 @@ let wheelsNow = [];
|
|
|
1209
1303
|
const paintQuick = () => {
|
|
1210
1304
|
const slot = $('.cx-mx-quick', root), c = conv();
|
|
1211
1305
|
easeHeight(slot, () => {
|
|
1212
|
-
slot.innerHTML = armed ? ui.alert({ icon: armed.icon, title:
|
|
1213
|
-
: `<div class="cx-mx-qrow cx-noscrollbar" role="group" aria-label="
|
|
1306
|
+
slot.innerHTML = armed ? ui.alert({ icon: armed.icon, title: T('{0} goes to {1} on {2}.', armed.noun, c.name.split(' ')[0], channels[c.channel].name), action: `<span class="cx-hstack" style="gap:6px">${ui.button({ label: T('Cancel'), variant: 'ghost', size: 'xs', attrs: 'data-mx-q-no' })}${ui.button({ label: T('Send'), variant: 'default', size: 'xs', attrs: 'data-mx-q-yes' })}</span>`, attrs: 'data-mx-confirm' })
|
|
1307
|
+
: `<div class="cx-mx-qrow cx-noscrollbar" role="group" aria-label="${T('Quick actions')}">${quick.map((x) => ui.button({ label: `${ui.icon(x.icon)}${x.label}`, size: 'xs', attrs: `data-mx-q="${x.id}"` })).join('')}</div>`;
|
|
1214
1308
|
});
|
|
1215
1309
|
};
|
|
1216
1310
|
const paintThread = () => {
|
|
1217
1311
|
const c = conv(), th = $('.cx-mx-thread', root);
|
|
1218
|
-
if (!c) { th.innerHTML = `<div class="cn-empty cx-mx-empty" style="flex:1"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-inbox')}</div><div class="cn-empty-title"
|
|
1312
|
+
if (!c) { th.innerHTML = `<div class="cn-empty cx-mx-empty" style="flex:1"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-inbox')}</div><div class="cn-empty-title">${T('Pick a conversation')}</div></div></div>`; return; }
|
|
1219
1313
|
armed = null;
|
|
1220
1314
|
const resolved = c.state === 'resolved';
|
|
1221
|
-
th.innerHTML = `<header class="cx-mx-th">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-back', attrs:
|
|
1315
|
+
th.innerHTML = `<header class="cx-mx-th">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-back', attrs: `data-mx-back aria-label="${T('Back to the inbox')}"` })}<span class="cx-mx-av">${ui.avatar(c.name)}${chMark(c)}</span><div class="cx-vstack cx-mx-thwho"><span class="cx-hstack" style="gap:8px;flex-wrap:nowrap;min-width:0"><span class="cx-ellipsis cx-mx-thname">${ui.text(c.name, 'heading')}</span>${stateBadge(c)}</span>${ui.text(`${channels[c.channel].name}${c.handle ? ` · ${c.handle}` : ''}`, 'caption')}</div><span class="cx-grow"></span>${ui.button({ label: `${ui.icon('check')}<span class="cx-mx-hide-sm">${resolved ? 'Resuelta' : 'Marcar resuelta'}</span>`, size: 'sm', attrs: `data-mx-resolve aria-pressed="${resolved}" aria-label="${resolved ? 'Resuelta, reabrir' : 'Marcar resuelta'}"` })}${ui.button({ icon: 'i-panel', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-ctxbtn', attrs: `data-mx-profile aria-label="${T('See client')}" title="${T('See client')}"` })}${ui.button({ icon: 'more', variant: 'ghost', size: 'icon-sm', attrs: `data-mx-more aria-label="${T('Options')}" title="${T('Options')}"` })}</header>`
|
|
1222
1316
|
+ `<div class="cx-mx-control"></div>`
|
|
1223
1317
|
+ `<div class="cx-chat cx-scroll" role="log" aria-live="polite">${c.window ? `<div class="cx-chat-sys" role="note">${ui.icon('clock')}${ui.text(c.window, 'caption')}</div>` : ''}${c.messages.map(msgHtml).join('')}</div>`
|
|
1224
|
-
+ `<div class="cx-mx-compose"><div class="cx-mx-quick"></div>${kit.composer({ placeholder: '
|
|
1318
|
+
+ `<div class="cx-mx-compose"><div class="cx-mx-quick"></div>${kit.composer({ placeholder: T('Write a message…'), templates, hint })}</div>`;
|
|
1225
1319
|
paintControl(); paintQuick();
|
|
1226
1320
|
wireComposer($('.cx-mx-compose', root), send, templates);
|
|
1227
|
-
kit.actionMenu($('[data-mx-more]', root), { items: () => [{ id: 'archive', label: c.archived ? '
|
|
1321
|
+
kit.actionMenu($('[data-mx-more]', root), { items: () => [{ id: 'archive', label: c.archived ? T('Unarchive') : T('Archive'), icon: c.archived ? 'i-inbox' : 'i-archive' }, { id: 'delete', label: T('Delete'), icon: 'trash', danger: true }], onPick: act });
|
|
1228
1322
|
const ch = $('.cx-chat', root); ch.scrollTop = ch.scrollHeight;
|
|
1229
1323
|
};
|
|
1230
1324
|
|
|
@@ -1233,26 +1327,26 @@ let wheelsNow = [];
|
|
|
1233
1327
|
const p = profile(c) || {};
|
|
1234
1328
|
return `<div class="cx-mx-who"><span class="cx-mx-av">${ui.avatar(c.name, { size: 'lg' })}${chMark(c)}</span>${ui.text(c.name, 'heading')}${ui.text(c.handle || channels[c.channel].name, 'caption')}</div>`
|
|
1235
1329
|
+ (p.stats ? `<div class="cx-mx-stats">${p.stats.map(([v, l]) => `<div>${ui.text(v, 'strong')}${ui.text(l, 'caption')}</div>`).join('')}</div>` : '')
|
|
1236
|
-
+ (p.now?.length ? `<section class="cx-mx-sec">${ui.text('
|
|
1237
|
-
+ (p.rows?.length ? `<section class="cx-mx-sec">${ui.text('
|
|
1238
|
-
+ `<div class="cx-mx-acts">${ui.button({ label: `${ui.icon(c.archived ? 'i-inbox' : 'i-archive')}${c.archived ? '
|
|
1330
|
+
+ (p.now?.length ? `<section class="cx-mx-sec">${ui.text(T('Now'), 'label')}<div class="cn-item-group">${p.now.map((n) => `<button type="button" class="cn-item cn-item-variant-outline cn-item-size-sm cx-mx-now" data-mx-go="${n.go || ''}">${ui.itemMedia(ui.icon(n.icon), 'icon')}<div class="cn-item-content"><div class="cn-item-title">${n.title}</div><p class="cn-item-description">${n.description}</p></div><div class="cn-item-actions">${ui.icon('right')}</div></button>`).join('')}</div></section>` : '')
|
|
1331
|
+
+ (p.rows?.length ? `<section class="cx-mx-sec">${ui.text(T('Client'), 'label')}<dl class="cx-mx-dl">${p.rows.map(([l, v]) => `<div><dt>${ui.text(l, 'muted')}</dt><dd>${ui.text(v)}</dd></div>`).join('')}</dl></section>` : '')
|
|
1332
|
+
+ `<div class="cx-mx-acts">${ui.button({ label: `${ui.icon(c.archived ? 'i-inbox' : 'i-archive')}${c.archived ? T('Unarchive') : T('Archive')}`, variant: 'ghost', size: 'sm', attrs: 'data-mx-act="archive"' })}${ui.button({ label: `${ui.icon('trash')}Eliminar`, variant: 'ghost', size: 'sm', cls: 'cx-mx-danger', attrs: 'data-mx-act="delete"' })}</div>`;
|
|
1239
1333
|
};
|
|
1240
1334
|
const paintCtx = () => { const c = conv(); $('.cx-mx-ctx', root).innerHTML = c ? profileHtml(c) : ''; };
|
|
1241
|
-
CONTENT.mxProfile = () => { $('#mdl-title').textContent = '
|
|
1242
|
-
CONTENT.mxDelete = () => { $('#mdl-title').textContent = '
|
|
1243
|
-
CONTENT.mxConnect = () => { $('#mdl-title').textContent = '
|
|
1335
|
+
CONTENT.mxProfile = () => { $('#mdl-title').textContent = T('Client'); $('#mdl-desc').textContent = conv().name; body.innerHTML = `<div class="cx-mx-ctx cx-mx-ctx-sheet">${profileHtml(conv())}</div>`; foot.innerHTML = ui.button({ label: T('Close'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }); };
|
|
1336
|
+
CONTENT.mxDelete = () => { $('#mdl-title').textContent = T('Delete the conversation?'); $('#mdl-desc').textContent = T('The chat with {0} is deleted from your inbox. Their appointments and orders stay the same.', conv().name); body.innerHTML = ''; foot.innerHTML = ui.button({ label: T('Cancel'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: T('Delete'), variant: 'destructive', size: 'default', attrs: 'data-mx-del-go', cls: 'cx-go' }); };
|
|
1337
|
+
CONTENT.mxConnect = () => { $('#mdl-title').textContent = T('Connect a channel'); $('#mdl-desc').textContent = T('The messages of every channel arrive in this same inbox.'); body.innerHTML = `<div class="cn-item-group">${Object.entries(channels).map(([k, ch]) => ui.item({ media: `<div class="cn-item-media"><span class="cx-ch cx-ch-lg" style="--brand:${ch.color}">${ui.icon(ch.icon)}</span></div>`, title: ch.name, description: ch.description || '', actions: ch.connected ? ui.badge(T('Connected'), { tone: 'ok' }) : ui.button({ label: T('Connect'), size: 'xs', attrs: `data-mx-conn="${k}"` }) })).join(ui.itemSeparator())}</div>`; foot.innerHTML = ui.button({ label: T('Done'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }); };
|
|
1244
1338
|
|
|
1245
1339
|
// ── behaviour ──
|
|
1246
1340
|
const openConv = (id) => { cur = id; const c = conv(); if (c) c.unread = 0; root.dataset.view = 'thread'; paintList(); paintThread(); paintCtx(); };
|
|
1247
1341
|
const act = (what) => {
|
|
1248
1342
|
const c = conv(); if (!c) return;
|
|
1249
|
-
if (what === 'archive') { c.archived = !c.archived; island('done', c.archived ? '
|
|
1343
|
+
if (what === 'archive') { c.archived = !c.archived; island('done', c.archived ? T('Conversation archived') : T('Back in the inbox'), 2000); const next = visible()[0]; cur = next?.id; root.dataset.view = 'list'; paintList(); paintThread(); paintCtx(); }
|
|
1250
1344
|
if (what === 'delete') openModal('mxDelete', 'auto');
|
|
1251
1345
|
};
|
|
1252
1346
|
const pushMsg = (m) => { const ch = $('.cx-chat', root); ch.insertAdjacentHTML('beforeend', msgHtml(m)); animIn(ch.lastElementChild); ch.scrollTop = ch.scrollHeight; };
|
|
1253
1347
|
const send = (text, extra = {}) => {
|
|
1254
1348
|
const c = conv(), id = 'm' + Date.now(), time = clock();
|
|
1255
|
-
const m = { id, out: true, text, time, status: 'sending', tag: '
|
|
1349
|
+
const m = { id, out: true, text, time, status: 'sending', tag: T('You'), ...extra }; c.messages.push(m); c.last = text || T('Photo'); c.lastBy = T('You'); c.time = time;
|
|
1256
1350
|
if (c.control === 'bot' || c.control === 'need') { c.control = 'me'; if (c.state === 'attention') c.state = ''; paintControl(); syncHead(); }
|
|
1257
1351
|
pushMsg(m);
|
|
1258
1352
|
const st = (s) => { m.status = s; const t = $(`[data-mid="${id}"] .cx-tick`, root); if (t) { t.dataset.status = s; t.innerHTML = `<use href="#${TICK[s]}"/>`; } };
|
|
@@ -1263,7 +1357,7 @@ let wheelsNow = [];
|
|
|
1263
1357
|
};
|
|
1264
1358
|
|
|
1265
1359
|
root.classList.add('cx-mx');
|
|
1266
|
-
root.innerHTML = `<aside class="cx-mx-list"><header class="cx-mx-lh">${ui.text(title, 'heading')}<span class="cx-grow"></span>${ui.button({ label: `${ui.icon('plus')}
|
|
1360
|
+
root.innerHTML = `<aside class="cx-mx-list"><header class="cx-mx-lh">${ui.text(title, 'heading')}<span class="cx-grow"></span>${ui.button({ label: `${ui.icon('plus')}${T('Connect')}`, size: 'sm', attrs: 'data-mx-connect' })}</header><div class="cx-mx-tools"><div data-slot="input-group" role="group" class="cn-input-group group/input-group"><div data-slot="input-group-addon" data-align="inline-start" class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('search')}</div><input data-slot="input-group-control" class="cn-input cn-input-group-input" placeholder="${T('Search conversation')}" aria-label="${T('Search conversation')}" data-mx-q></div><div class="cx-mx-chips cx-noscrollbar" role="group" aria-label="${T('Filter')}"></div></div><div class="cx-mx-items cx-scroll" role="list"></div><footer class="cx-mx-lf"><span class="cx-mx-count cx-text" data-variant="caption"></span>${ui.button({ label: '', variant: 'ghost', size: 'xs', attrs: 'data-mx-arch' })}</footer></aside><section class="cx-mx-thread" aria-label="${T('Conversation')}"></section><aside class="cx-mx-ctx" aria-label="${T('Client')}"></aside>`;
|
|
1267
1361
|
root.dataset.view = 'list';
|
|
1268
1362
|
|
|
1269
1363
|
root.addEventListener('input', (e) => { if (e.target.matches('[data-mx-q]')) { q = e.target.value; paintList(); } });
|
|
@@ -1276,15 +1370,15 @@ let wheelsNow = [];
|
|
|
1276
1370
|
if (t('[data-mx-connect]')) return openModal('mxConnect', 'auto');
|
|
1277
1371
|
if (t('[data-mx-profile]')) return openModal('mxProfile', 'auto');
|
|
1278
1372
|
if (t('[data-mx-act]')) return act(t('[data-mx-act]').dataset.mxAct);
|
|
1279
|
-
if (t('[data-mx-resolve]')) { const c = conv(); c.state = c.state === 'resolved' ? '' : 'resolved'; if (c.state === 'resolved') island('done', '
|
|
1373
|
+
if (t('[data-mx-resolve]')) { const c = conv(); c.state = c.state === 'resolved' ? '' : 'resolved'; if (c.state === 'resolved') island('done', T('Conversation solved'), 1800); paintList(); const b = t('[data-mx-resolve]'), on = c.state === 'resolved'; b.setAttribute('aria-pressed', on); b.setAttribute('aria-label', on ? 'Resuelta, reabrir' : 'Marcar resuelta'); $('.cx-mx-hide-sm', b).textContent = on ? 'Resuelta' : 'Marcar resuelta'; syncHead(); return; }
|
|
1280
1374
|
if (t('[data-mx-ctl]')) { const c = conv(); c.control = t('[data-mx-ctl]').dataset.mxCtl; if (c.state === 'attention' && c.control === 'me') c.state = ''; paintControl(); syncHead(); paintList(); return; }
|
|
1281
1375
|
if (t('[data-mx-q]')) { armed = quick.find((x) => x.id === t('[data-mx-q]').dataset.mxQ); paintQuick(); $('[data-mx-q-yes]', root)?.focus(); return; }
|
|
1282
1376
|
if (t('[data-mx-q-no]')) { armed = null; paintQuick(); return; }
|
|
1283
1377
|
if (t('[data-mx-q-yes]')) { const x = armed; armed = null; paintQuick(); send(null, { card: x.card ? x.card(conv()) : `${ui.text(x.label, 'label')}` }); conv().last = x.label; paintList(); return; }
|
|
1284
1378
|
});
|
|
1285
1379
|
document.addEventListener('click', (e) => {
|
|
1286
|
-
if (e.target.closest('[data-mx-del-go]')) { const i = conversations.findIndex((c) => c.id === cur); conversations.splice(i, 1); closeModal(); island('done', '
|
|
1287
|
-
const cn = e.target.closest('[data-mx-conn]'); if (cn) { channels[cn.dataset.mxConn].connected = true; cn.outerHTML = ui.badge('
|
|
1380
|
+
if (e.target.closest('[data-mx-del-go]')) { const i = conversations.findIndex((c) => c.id === cur); conversations.splice(i, 1); closeModal(); island('done', T('Conversation deleted'), 2000); cur = visible()[0]?.id; root.dataset.view = 'list'; paintList(); paintThread(); paintCtx(); }
|
|
1381
|
+
const cn = e.target.closest('[data-mx-conn]'); if (cn) { channels[cn.dataset.mxConn].connected = true; cn.outerHTML = ui.badge(T('Connected'), { tone: 'ok' }); island('done', `${channels[cn.dataset.mxConn].name} conectado`, 2000); }
|
|
1288
1382
|
});
|
|
1289
1383
|
// layout by the component's own width, not the window's: wide = 3 panes, mid = person in a sheet, narrow = one pane
|
|
1290
1384
|
kit.watchWidth(root, [[720, 'narrow'], [1060, 'mid']], 'wide');
|
|
@@ -1335,20 +1429,23 @@ let wheelsNow = [];
|
|
|
1335
1429
|
};
|
|
1336
1430
|
|
|
1337
1431
|
// ── Chatbot: { name, avatar, greeting, suggestions, respond(text) → {text, actions} } ──
|
|
1338
|
-
|
|
1339
|
-
|
|
1432
|
+
// avatar: an image, logo or GIF url in place of the drawn face (the face stays when there is none)
|
|
1433
|
+
kit.chatbot = (root, { name, greeting, suggestions, respond, avatar = '' }) => {
|
|
1434
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1435
|
+
const face = avatar ? `<img class="cx-bot-logo" src="${String(avatar).replace(/["<>]/g, '')}" alt="" aria-hidden="true">` : '<span class="cx-bot-face" aria-hidden="true"><i></i><i></i></span>';
|
|
1436
|
+
root.innerHTML = `<header class="cx-ib-th">${face}<div class="cx-vstack" style="gap:0">${ui.text(name, 'heading')}${ui.text(T('Assistant · answers at once'), 'caption')}</div></header><div class="cx-chat cx-scroll" role="log" aria-live="polite"></div><div class="cx-hstack cx-bot-sugs" role="group" aria-label="${T('Suggestions')}">${suggestions.map((s) => ui.button({ label: s, size: 'sm', attrs: `data-sug="${s}"` })).join('')}</div>${kit.composer({ placeholder: `Pregúntale a ${name}`, attach: false, emoji: false })}`;
|
|
1340
1437
|
const ch = $('.cx-chat', root);
|
|
1341
|
-
const add = (html) => { ch.insertAdjacentHTML('beforeend', html); const el = ch.lastElementChild; el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: '
|
|
1438
|
+
const add = (html) => { ch.insertAdjacentHTML('beforeend', html); const el = ch.lastElementChild; el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: ease('--ease-out') }); ch.scrollTop = ch.scrollHeight; return el; };
|
|
1342
1439
|
let stop = null;
|
|
1343
1440
|
const ask = (text) => {
|
|
1344
1441
|
if (stop) return; add(kit.bubble({ out: true, text }));
|
|
1345
1442
|
$('.cx-bot-sugs', root).hidden = true;
|
|
1346
|
-
const thinking = add(`<div class="cx-chat-msg" data-who="in"><div class="cx-chat-bubble cx-typing" aria-label="
|
|
1347
|
-
const send = $('[data-send]', root); send.innerHTML = ui.icon('pause'); send.disabled = false; send.setAttribute('aria-label', '
|
|
1443
|
+
const thinking = add(`<div class="cx-chat-msg" data-who="in"><div class="cx-chat-bubble cx-typing" aria-label="${T('Thinking')}"><i></i><i></i><i></i></div></div>`);
|
|
1444
|
+
const send = $('[data-send]', root); send.innerHTML = ui.icon('pause'); send.disabled = false; send.setAttribute('aria-label', T('Stop')); send.dataset.stop = '';
|
|
1348
1445
|
setTimeout(() => {
|
|
1349
1446
|
const ans = respond(text); thinking.remove();
|
|
1350
1447
|
const el = add(kit.bubble({ text: '' })); const p = $('p', el); let i = 0, cancelled = false;
|
|
1351
|
-
const done = () => { stop = null; send.innerHTML = ui.icon('i-send'); send.setAttribute('aria-label', '
|
|
1448
|
+
const done = () => { stop = null; send.innerHTML = ui.icon('i-send'); send.setAttribute('aria-label', T('Send')); delete send.dataset.stop; send.disabled = !$('textarea', root).value.trim(); el.insertAdjacentHTML('beforeend', `<div class="cx-chat-actions">${cancelled ? ui.text(T('Stopped'), 'caption') : ''}${!cancelled && ans.actions ? ans.actions : ''}${ui.button({ icon: 'i-copy', variant: 'ghost', size: 'icon-xs', attrs: `data-bot-copy aria-label="${T('Copy answer')}" title="${T('Copy')}"` })}${ui.button({ label: '👍', variant: 'ghost', size: 'icon-xs', attrs: `data-rate="up" aria-label="${T('It helped')}" aria-pressed="false"` })}${ui.button({ label: '👎', variant: 'ghost', size: 'icon-xs', attrs: `data-rate="down" aria-label="${T('It did not help')}" aria-pressed="false"` })}</div>`); ch.scrollTop = ch.scrollHeight; };
|
|
1352
1449
|
const tick = setInterval(() => { i += 2; p.textContent = ans.text.slice(0, i); ch.scrollTop = ch.scrollHeight; if (i >= ans.text.length) { clearInterval(tick); done(); } }, 18);
|
|
1353
1450
|
stop = () => { clearInterval(tick); cancelled = true; done(); };
|
|
1354
1451
|
}, 700);
|
|
@@ -1358,7 +1455,7 @@ let wheelsNow = [];
|
|
|
1358
1455
|
const s = e.target.closest('[data-sug]'); if (s) return ask(s.dataset.sug);
|
|
1359
1456
|
const st = e.target.closest('[data-stop]'); if (st && stop) { e.preventDefault(); stop(); return; }
|
|
1360
1457
|
const r = e.target.closest('[data-rate]'); if (r) { $$('[data-rate]', r.parentElement).forEach((x) => x.setAttribute('aria-pressed', x === r)); return; }
|
|
1361
|
-
const c = e.target.closest('[data-bot-copy]'); if (c) { try { navigator.clipboard.writeText($('p', c.closest('.cx-chat-msg')).textContent); } catch {} c.innerHTML = ui.icon('check')
|
|
1458
|
+
const c = e.target.closest('[data-bot-copy]'); if (c) { try { navigator.clipboard.writeText($('p', c.closest('.cx-chat-msg')).textContent); } catch {} c.innerHTML = `<span class="cx-swap-in">${ui.icon('check')}</span>`; }
|
|
1362
1459
|
}, true);
|
|
1363
1460
|
add(kit.bubble({ text: greeting }));
|
|
1364
1461
|
};
|
|
@@ -1370,13 +1467,14 @@ let wheelsNow = [];
|
|
|
1370
1467
|
|
|
1371
1468
|
// ── Bar chart { title, data [[label, value]], fmt } ──
|
|
1372
1469
|
kit.barChart = (root, { title, data, fmt = (v) => v, periods }) => {
|
|
1470
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1373
1471
|
const paint = (rows) => {
|
|
1374
1472
|
const max = Math.max(...rows.map(([, v]) => v)) * 1.1, W = 100, H = 160, bw = W / rows.length;
|
|
1375
1473
|
const ticks = [0, .5, 1].map((t) => max * t);
|
|
1376
|
-
root.innerHTML = `<div data-slot="card" data-size="sm" class="cn-card cx-chart"><div class="cx-hstack" style="justify-content:space-between;padding:14px 14px 0">${ui.text(title, 'heading')}${periods ? `<div class="cx-tabs" data-tabs role="tablist" aria-label="
|
|
1474
|
+
root.innerHTML = `<div data-slot="card" data-size="sm" class="cn-card cx-chart"><div class="cx-hstack" style="justify-content:space-between;padding:14px 14px 0">${ui.text(title, 'heading')}${periods ? `<div class="cx-tabs" data-tabs role="tablist" aria-label="${T('Period')}">${periods.map((p, i) => `<button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${i === 0 ? 'aria-selected="true"' : ''}>${p[0]}</button>`).join('')}</div>` : ''}</div>
|
|
1377
1475
|
<div class="cx-chart-body"><div class="cx-chart-y">${ticks.slice().reverse().map((t) => ui.text(fmt(Math.round(t)), 'caption')).join('')}</div><div class="cx-chart-plot"><div class="cx-chart-grid">${ticks.map(() => '<i></i>').join('')}</div><div class="cx-chart-bars" style="--n:${rows.length}">${rows.map(([l, v]) => `<button class="cx-chart-bar" style="--v:${(v / max) * 100}%" data-tip-v="${l}: ${fmt(v)}" aria-label="${l}: ${fmt(v)}"><i></i></button>`).join('')}</div><div class="cx-chart-x" style="--n:${rows.length}">${rows.map(([l], i) => `<span>${i % Math.ceil(rows.length / 7) === 0 ? ui.text(l, 'caption') : ''}</span>`).join('')}</div></div></div></div>`;
|
|
1378
1476
|
if (periods) { const tl = $('.cx-tabs', root); initTabs(tl); tl.addEventListener('pick', (e) => { const d = periods[e.detail][1]; paint(d); const t2 = $$('.cx-tab', root); t2.forEach((x, i) => x.setAttribute('aria-selected', i === e.detail)); }); }
|
|
1379
|
-
$$('.cx-chart-bar', root).forEach((b, i) => b.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration:
|
|
1477
|
+
$$('.cx-chart-bar', root).forEach((b, i) => b.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: ms('--t-move') * 1.75, delay: ms('--t-move') > 1 ? i * 18 : 0, easing: ease('--ease-out'), fill: 'backwards' }));
|
|
1380
1478
|
};
|
|
1381
1479
|
root.addEventListener('pointerover', (e) => { const b = e.target.closest('.cx-chart-bar'); if (b) { tip.textContent = b.dataset.tipV; tip.hidden = false; tip.dataset.side = 'top'; tip.style.removeProperty('--ax'); const r = b.getBoundingClientRect(); tip.style.left = (r.left + r.width / 2 - tip.offsetWidth / 2) + 'px'; tip.style.top = (r.top + (1 - parseFloat(getComputedStyle(b).getPropertyValue('--v')) / 100) * r.height - tip.offsetHeight - 6) + 'px'; requestAnimationFrame(() => tip.dataset.state = 'open'); } });
|
|
1382
1480
|
root.addEventListener('pointerout', (e) => { if (e.target.closest('.cx-chart-bar')) { tip.dataset.state = 'closed'; tip.hidden = true; } });
|
|
@@ -1386,23 +1484,24 @@ let wheelsNow = [];
|
|
|
1386
1484
|
|
|
1387
1485
|
// ── Checklist { title, steps [{title, desc, done, action}] } ──
|
|
1388
1486
|
kit.checklist = (root, { title, steps }) => {
|
|
1487
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1389
1488
|
const paint = () => { const n = steps.filter((s) => s.done).length; root.innerHTML = `<div data-slot="card" data-size="sm" class="cn-card"><div class="cx-vstack" style="gap:8px;padding:14px 14px 4px">${ui.text(title, 'heading')}<div class="cx-hstack" style="flex-wrap:nowrap;gap:10px">${ui.progress((n / steps.length) * 100, `$%n de ${steps.length} pasos`)}${ui.text(`$%n/${steps.length}`, 'caption')}</div></div><div class="cn-card-content" style="padding:0 12px 8px"><div class="cn-item-group">${steps.map((s, i) => ui.item({ media: `<div class="cn-item-media"><span class="cx-check-dot" ${s.done ? 'data-done' : ''}>${s.done ? ui.icon('i-tick') : i + 1}</span></div>`, title: s.title, description: s.desc, actions: s.done ? '' : ui.button({ label: s.action, size: 'xs', attrs: `data-step="${i}"` }) })).join(ui.itemSeparator())}</div></div></div>`; };
|
|
1390
1489
|
root.addEventListener('click', (e) => { const b = e.target.closest('[data-step]'); if (!b) return; steps[+b.dataset.step].done = true; easeHeight(root, paint); });
|
|
1391
1490
|
paint();
|
|
1392
1491
|
};
|
|
1393
1492
|
|
|
1394
|
-
// ── Dirty bar: watch a form; show
|
|
1493
|
+
// ── Dirty bar: watch a form; show “unsaved changes” while it differs from its saved state ──
|
|
1395
1494
|
kit.dirtyBar = (form, { onSave }) => {
|
|
1396
1495
|
const snap = () => JSON.stringify($$('input, select, textarea, [role=switch], .cx-time-btn, .cx-sel-btn', form).map((x) => x.getAttribute('role') === 'switch' ? x.getAttribute('aria-checked') : x.matches('button') ? x.textContent : x.value));
|
|
1397
1496
|
let saved = snap();
|
|
1398
|
-
const bar = document.createElement('div'); bar.className = 'cx-dirty'; bar.setAttribute('role', 'region'); bar.setAttribute('aria-label', '
|
|
1399
|
-
bar.innerHTML = `<div class="cx-dirty-in">${ui.text('
|
|
1497
|
+
const bar = document.createElement('div'); bar.className = 'cx-dirty'; bar.setAttribute('role', 'region'); bar.setAttribute('aria-label', tx(form, 'Unsaved changes'));
|
|
1498
|
+
bar.innerHTML = `<div class="cx-dirty-in">${ui.text(tx(form, 'You have unsaved changes'), 'label')}<span class="cx-dirty-acts">${ui.button({ label: tx(form, 'Discard'), variant: 'ghost', size: 'sm', attrs: 'data-discard' })}${ui.button({ label: tx(form, 'Save'), variant: 'default', size: 'sm', attrs: 'data-save-all', cls: 'cx-save-w' })}</span></div>`;
|
|
1400
1499
|
form.appendChild(bar);
|
|
1401
1500
|
const check = () => bar.toggleAttribute('data-open', snap() !== saved);
|
|
1402
1501
|
form.addEventListener('input', check); form.addEventListener('click', () => setTimeout(check));
|
|
1403
1502
|
bar.addEventListener('click', (e) => {
|
|
1404
1503
|
if (e.target.closest('[data-discard]')) { form.dispatchEvent(new CustomEvent('discard', { detail: JSON.parse(saved) })); setTimeout(check, 30); }
|
|
1405
|
-
const s = e.target.closest('[data-save-all]'); if (s) { s.innerHTML = `<svg class="animate-spin"><use href="#loader"/></svg>Guardando…`; s.disabled = true; setTimeout(() => { onSave?.(); saved = snap(); s.innerHTML = `${ui.icon('check')}Guardado`; setTimeout(() => { check(); setTimeout(() => { s.innerHTML = '
|
|
1504
|
+
const s = e.target.closest('[data-save-all]'); if (s) { s.innerHTML = `<svg class="animate-spin"><use href="#loader"/></svg>Guardando…`; s.disabled = true; setTimeout(() => { onSave?.(); saved = snap(); s.innerHTML = `${ui.icon('check')}Guardado`; setTimeout(() => { check(); setTimeout(() => { s.innerHTML = tx(form, 'Save'); s.disabled = false; }, 320); }, 900); }, 800); }
|
|
1406
1505
|
});
|
|
1407
1506
|
return { reset: () => { saved = snap(); check(); } };
|
|
1408
1507
|
};
|
|
@@ -1410,8 +1509,9 @@ let wheelsNow = [];
|
|
|
1410
1509
|
|
|
1411
1510
|
// ── Weekly hours { days: [{name, open, ranges:[[from,to]]}] } ──
|
|
1412
1511
|
kit.weeklyHours = (root, { days }) => {
|
|
1512
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1413
1513
|
days = JSON.parse(JSON.stringify(days));
|
|
1414
|
-
const paint = () => { root.innerHTML = days.map((d, i) => `<div class="cx-wh-day" data-wd="${i}"><div class="cx-wh-name">${sw(d.open, `${d.name} abierto`)}${ui.text(d.name, 'heading')}</div><div class="cx-wh-ranges">${d.open ? d.ranges.map((r, j) => `<div class="cx-wh-range">${kit.timeButton(r[0], `data-r="${j}" data-k="0" aria-label="${d.name}, desde ${r[0]}"`)}${ui.text('a', 'caption')}${kit.timeButton(r[1], `data-r="${j}" data-k="1" aria-label="${d.name}, hasta ${r[1]}"`)}${d.ranges.length > 1 ? ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: `data-rm="${j}" aria-label="
|
|
1514
|
+
const paint = () => { root.innerHTML = days.map((d, i) => `<div class="cx-wh-day" data-wd="${i}"><div class="cx-wh-name">${sw(d.open, `${d.name} abierto`)}${ui.text(d.name, 'heading')}</div><div class="cx-wh-ranges">${d.open ? d.ranges.map((r, j) => `<div class="cx-wh-range">${kit.timeButton(r[0], `data-r="${j}" data-k="0" aria-label="${d.name}, desde ${r[0]}"`)}${ui.text('a', 'caption')}${kit.timeButton(r[1], `data-r="${j}" data-k="1" aria-label="${d.name}, hasta ${r[1]}"`)}${d.ranges.length > 1 ? ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: `data-rm="${j}" aria-label="${T('Remove range')}" title="${T('Remove range')}"` }) : ''}</div>`).join('') + `<div class="cx-hstack">${ui.button({ label: `${ui.icon('plus')}${T('Add range')}`, variant: 'ghost', size: 'xs', attrs: 'data-add' })}${i === 0 ? ui.button({ label: `${ui.icon('i-copy')}${T('Copy to all')}`, variant: 'ghost', size: 'xs', attrs: 'data-copyall' }) : ''}</div>` : ui.text('Cerrado', 'muted')}</div></div>`).join(''); };
|
|
1415
1515
|
root.addEventListener('click', (e) => {
|
|
1416
1516
|
const day = e.target.closest('[data-wd]'); if (!day) return; const d = days[+day.dataset.wd];
|
|
1417
1517
|
if (e.target.closest('[role=switch]')) { d.open = !d.open; if (d.open && !d.ranges.length) d.ranges = [['09:00', '18:00']]; easeHeight(root, paint); }
|
|
@@ -1426,17 +1526,19 @@ let wheelsNow = [];
|
|
|
1426
1526
|
|
|
1427
1527
|
// ── Plan cards { cycle: ['month','year'], plans [{id,name,price:{month,year},desc,features,current}] } ──
|
|
1428
1528
|
kit.plans = (root, { plans, yearlyNote = '' }) => {
|
|
1529
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1429
1530
|
let cycle = 'month', chosen = plans.find((p) => p.current).id;
|
|
1430
|
-
const paint = () => { root.innerHTML = `<div class="cx-vstack" style="gap:16px;align-items:center"><div class="cx-tabs" data-tabs role="tablist" aria-label="
|
|
1531
|
+
const paint = () => { root.innerHTML = `<div class="cx-vstack" style="gap:16px;align-items:center"><div class="cx-tabs" data-tabs role="tablist" aria-label="${T('Billing')}"><button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${cycle === 'month' ? 'aria-selected="true"' : ''} data-cycle="month">${T('Monthly')}</button><button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${cycle === 'year' ? 'aria-selected="true"' : ''} data-cycle="year">${T('Yearly')} ${ui.badge(yearlyNote, { variant: 'secondary' })}</button></div><div class="cx-plans" role="radiogroup" aria-label="Planes">${plans.map((p) => `<div data-slot="card" class="cn-card cx-plan" role="radio" aria-checked="${p.id === chosen}" tabindex="${p.id === chosen ? 0 : -1}" data-plan="${p.id}"><div class="cx-vstack" style="gap:6px;padding:16px 16px 0"><div class="cx-hstack" style="justify-content:space-between">${ui.text(p.name, 'heading')}${p.current ? ui.badge(T('Your plan'), { tone: 'ok' }) : ''}</div><div class="cx-plan-price">${ui.text(money0(p.price[cycle]), 'title', 'b')}${ui.text(cycle === 'month' ? T('/ month') : T('/ year'), 'muted')}</div>${ui.text(p.desc, 'muted', 'p')}</div><ul class="cx-plan-feats">${p.features.map((f) => `<li>${ui.icon('check')}${ui.text(f)}</li>`).join('')}</ul><div style="padding:0 16px 16px;margin-top:auto">${ui.button({ label: p.current ? T('Your current plan') : (plans.indexOf(p) > plans.findIndex((x) => x.current) ? T('Move up to {0}', p.name) : T('Move down to {0}', p.name)), variant: p.current ? 'outline' : 'default', size: 'default', cls: 'cx-fill', attrs: p.current ? 'disabled' : '' })}</div></div>`).join('')}</div></div>`; const tl = $('.cx-tabs', root); initTabs(tl); };
|
|
1431
1532
|
root.addEventListener('click', (e) => { const c = e.target.closest('[data-cycle]'); if (c && c.dataset.cycle !== cycle) { cycle = c.dataset.cycle; setTimeout(paint, 220); return; } const p = e.target.closest('[data-plan]'); if (p && !e.target.closest('button')) { chosen = p.dataset.plan; $$('[data-plan]', root).forEach((x) => { const on = x === p; x.setAttribute('aria-checked', on); x.tabIndex = on ? 0 : -1; }); } });
|
|
1432
1533
|
paint();
|
|
1433
1534
|
};
|
|
1434
1535
|
|
|
1435
1536
|
// ── Card field (number with brand, expiry, CVC) ──
|
|
1436
1537
|
kit.cardField = (root, { onSubmit }) => {
|
|
1538
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1437
1539
|
const BRANDS = [[/^4/, 'Visa'], [/^(5[1-5]|2[2-7])/, 'Mastercard'], [/^3[47]/, 'Amex']];
|
|
1438
1540
|
const luhn = (n) => { let s = 0; [...n].reverse().forEach((c, i) => { let d = +c; if (i % 2) { d *= 2; if (d > 9) d -= 9; } s += d; }); return n.length >= 13 && s % 10 === 0; };
|
|
1439
|
-
root.innerHTML = `${ui.field({ id: 'cc-n', label: '
|
|
1541
|
+
root.innerHTML = `${ui.field({ id: 'cc-n', label: T('Card number'), control: `<div data-slot="input-group" class="cn-input-group">${'<div class="cn-input-group-addon cn-input-group-addon-align-inline-start">' + ui.icon('card') + '</div>'}<input class="cn-input cn-input-group-input cx-num" id="cc-n" inputmode="numeric" autocomplete="cc-number" placeholder="1234 1234 1234 1234"><div class="cn-input-group-addon cn-input-group-addon-align-inline-end"><span class="cx-cc-brand" id="cc-b"></span></div></div>` })}<div class="cx-frange">${ui.field({ id: 'cc-e', label: T('Expires'), control: ui.input({ id: 'cc-e', placeholder: 'MM/AA', attrs: 'inputmode="numeric" autocomplete="cc-exp" class="cn-input cx-num"' }) })}${ui.field({ id: 'cc-c', label: T('Code'), control: ui.input({ id: 'cc-c', placeholder: '123', attrs: 'inputmode="numeric" autocomplete="cc-csc" maxlength="4"' }) })}</div><p class="cn-field-description cx-msg" id="cc-msg">${ui.icon('i-lock')} ${T('Secure payment with Stripe. We do not keep the number.')}</p>${ui.button({ label: T('Save card'), variant: 'default', size: 'default', cls: 'cx-fill', attrs: 'data-cc-save' })}`;
|
|
1440
1542
|
const n = $('#cc-n', root), ex = $('#cc-e', root), cvc = $('#cc-c', root), msg = $('#cc-msg', root), help = msg.innerHTML;
|
|
1441
1543
|
const brand = () => BRANDS.find(([re]) => re.test(n.value.replace(/\D/g, '')))?.[1] || '';
|
|
1442
1544
|
n.addEventListener('input', () => { reformat(n, (v) => { const d = v.replace(/\D/g, '').slice(0, brand() === 'Amex' ? 15 : 16); return brand() === 'Amex' ? d.replace(/^(\d{0,4})(\d{0,6})(\d{0,5}).*/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' ')) : d.replace(/(\d{4})(?=\d)/g, '$1 '); }, /\d/); $('#cc-b', root).textContent = brand(); });
|
|
@@ -1444,22 +1546,23 @@ let wheelsNow = [];
|
|
|
1444
1546
|
cvc.addEventListener('input', () => { cvc.value = cvc.value.replace(/\D/g, ''); });
|
|
1445
1547
|
$('[data-cc-save]', root).addEventListener('click', () => {
|
|
1446
1548
|
const num = n.value.replace(/\D/g, ''), [mm, yy] = ex.value.split('/').map(Number), expOk = mm >= 1 && mm <= 12 && yy >= today.getFullYear() % 100;
|
|
1447
|
-
const err = !luhn(num) ? '
|
|
1448
|
-
msg.closest('.cx-form')?.toggleAttribute('data-invalid', !!err); msg.innerHTML = err ? `${ui.icon('info')} ${err}` : `${ui.icon('check')}
|
|
1549
|
+
const err = !luhn(num) ? T('Check the card number.') : !expOk ? T('The expiry date is not valid.') : cvc.value.length < 3 ? T('The 3 or 4 digit code is missing.') : '';
|
|
1550
|
+
msg.closest('.cx-form')?.toggleAttribute('data-invalid', !!err); msg.innerHTML = err ? `${ui.icon('info')} ${err}` : `${ui.icon('check')} ${T('{0} card ending in {1} saved.', brand(), num.slice(-4))}`; msg.dataset.tone = err ? 'bad' : 'ok';
|
|
1449
1551
|
if (!err) onSubmit?.(num.slice(-4));
|
|
1450
1552
|
});
|
|
1451
1553
|
};
|
|
1452
1554
|
|
|
1453
1555
|
// ── Member list (any group: team, workspace, family plan…) + invite; Permission matrix (any rows × any roles) ──
|
|
1454
1556
|
// memberList({ members:[{name,email,role,pending}], roles:[{name, description, icon}], ownerRole, invite:{ title, description, cta } })
|
|
1455
|
-
kit.memberList = (root, { members, roles, ownerRole, noun = ['
|
|
1456
|
-
const
|
|
1557
|
+
kit.memberList = (root, { members, roles, ownerRole, noun = ['person', 'people'], invite }) => {
|
|
1558
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1559
|
+
const paint = () => { root.innerHTML = `<div class="cx-hstack" style="justify-content:space-between">${ui.text(`${members.length} ${members.length === 1 ? noun[0] : noun[1]}`, 'heading')}${ui.button({ label: `${ui.icon('plus')}${invite.cta}`, variant: 'default', size: 'sm', attrs: 'data-invite' })}</div><div class="cn-item-group">${members.map((m, i) => ui.item({ media: ui.itemMedia(ui.avatar(m.name), 'default'), title: `${m.name}${m.pending ? ` ${ui.badge(T('Invitation sent'), { tone: 'wait' })}` : ''}`, description: m.email, actions: m.role === ownerRole ? ui.text(ownerRole, 'muted') : `<button type="button" class="cn-button cn-button-variant-outline cn-button-size-sm cx-sel-btn" aria-label="${T('Role of {0}', m.name)}" data-mi="${i}"></button>` })).join(ui.itemSeparator())}</div>`; $$('[data-mi]', root).forEach((b) => kit.selectMenu(b, { label: T('Role of {0}', members[+b.dataset.mi].name), value: members[+b.dataset.mi].role, options: roles.filter((r) => r.name !== ownerRole).map((r) => ({ value: r.name, label: r.name, description: r.description })), onChange: (v) => { members[+b.dataset.mi].role = v; } })); };
|
|
1457
1560
|
root.addEventListener('click', (e) => { if (e.target.closest('[data-invite]')) openModal('invite', 'auto'); });
|
|
1458
1561
|
const choosable = roles.filter((r) => r.name !== ownerRole);
|
|
1459
1562
|
CONTENT.invite = () => {
|
|
1460
1563
|
$('#mdl-title').textContent = invite.title; $('#mdl-desc').textContent = invite.description;
|
|
1461
|
-
body.innerHTML = `${ui.field({ id: 'inv-e', label: '
|
|
1462
|
-
foot.innerHTML = ui.button({ label: '
|
|
1564
|
+
body.innerHTML = `${ui.field({ id: 'inv-e', label: T('Email'), control: ui.input({ id: 'inv-e', type: 'email', placeholder: T('name@email.com') }) })}<div class="cx-vstack" role="radiogroup" aria-label="${T('Role')}" style="gap:8px">${ui.text(T('Role'), 'label')}${choosable.map((r, i) => ui.choice({ media: ui.itemMedia(ui.icon(r.icon || 'user')), title: r.name, description: r.description, checked: i === choosable.length - 1, attrs: `data-role="${r.name}"` })).join('')}</div>`;
|
|
1565
|
+
foot.innerHTML = ui.button({ label: T('Cancel'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: `${ui.icon('i-mail')}${T('Send invitation')}`, variant: 'default', size: 'default', attrs: 'data-inv-send', cls: 'cx-go' });
|
|
1463
1566
|
};
|
|
1464
1567
|
document.addEventListener('click', (e) => {
|
|
1465
1568
|
const ch = e.target.closest('[data-role]'); if (ch && body.contains(ch)) { $$('[data-role]', body).forEach((x) => { x.setAttribute('aria-checked', x === ch); x.tabIndex = x === ch ? 0 : -1; }); }
|
|
@@ -1468,14 +1571,18 @@ let wheelsNow = [];
|
|
|
1468
1571
|
paint();
|
|
1469
1572
|
};
|
|
1470
1573
|
// permissionMatrix({ roles:[name], rows:[{name, description, allow:[bool per role]}], lockedRole, onChange })
|
|
1471
|
-
kit.permissionMatrix = (root, { roles, rows, lockedRole, rowLabel = '
|
|
1574
|
+
kit.permissionMatrix = (root, { roles, rows, lockedRole, rowLabel = T0('Permission'), onChange = () => {} }) => {
|
|
1575
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1472
1576
|
const li = roles.indexOf(lockedRole);
|
|
1473
1577
|
root.innerHTML = `<div class="cx-dt-scroll cx-scroll" style="max-height:none"><table class="cx-dt cx-perm"><thead><tr><th>${ui.text(rowLabel, 'label')}</th>${roles.map((r) => `<th class="cx-perm-c">${ui.text(r, 'label')}</th>`).join('')}</tr></thead><tbody>${rows.map((row, pi) => `<tr><td>${ui.text(row.name, 'heading')}${row.description ? `<br>${ui.text(row.description, 'caption')}` : ''}</td>${roles.map((r, ri) => { const on = ri === li || !!row.allow[ri]; return `<td class="cx-perm-c"><button type="button" data-slot="checkbox" role="checkbox" class="cn-checkbox" aria-checked="${on}" data-state="${on ? 'checked' : 'unchecked'}" aria-label="${r}: ${row.name}" ${ri === li ? `disabled title="${lockedRole} siempre puede"` : ''} data-p="${pi}" data-r="${ri}"><span class="cn-checkbox-indicator"><svg class="cb-mark" viewBox="0 0 24 24">${CB_CHECK}</svg></span></button></td>`; }).join('')}</tr>`).join('')}</tbody></table></div>`;
|
|
1474
1578
|
root.addEventListener('click', (e) => { const c2 = e.target.closest('[data-p]'); if (!c2 || c2.disabled) return; const on = c2.getAttribute('aria-checked') !== 'true'; setCheck(c2, on); rows[+c2.dataset.p].allow[+c2.dataset.r] = on; onChange(rows); });
|
|
1475
1579
|
};
|
|
1476
1580
|
|
|
1477
1581
|
// ── Stamp card { owner, total, stamps, reward } ──
|
|
1478
|
-
kit.stampCard = (root, { owner, total, stamps, reward, store, title =
|
|
1582
|
+
kit.stampCard = (root, { owner, total, stamps, reward, store, title = "%owner's card", progress = '%n of %total · %left more for %reward', complete = 'Complete! Earned %reward.', count = '%n of %total stamps', redeem = 'Redeem reward', stamp = 'Stamp a visit' }) => {
|
|
1583
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1584
|
+
// the words are templates in English; said in the card's language before %owner… are filled
|
|
1585
|
+
title = T(title); progress = T(progress); complete = T(complete); count = T(count); redeem = T(redeem); stamp = T(stamp);
|
|
1479
1586
|
// the words are templates, so the card speaks the page's language: %owner %n %total %left %reward
|
|
1480
1587
|
const say = (tpl) => String(tpl).replace(/%(owner|total|left|reward|n)/g, (_, k) => ({ owner, n: stamps, total, left: total - stamps, reward })[k]);
|
|
1481
1588
|
const paint = (popIdx = -1) => { const done = stamps >= total; root.innerHTML = `<div data-slot="card" class="cn-card cx-stamps"><div class="cx-vstack" style="gap:2px;padding:16px 16px 0">${ui.text(store, 'caption')}${ui.text(say(title), 'heading')}</div><div class="cx-stamp-grid" role="img" aria-label="${say(count)}">${Array.from({ length: total }, (_, i) => `<span class="cx-stamp" ${i < stamps ? 'data-on' : ''} ${i === total - 1 ? 'data-gift' : ''} ${i === popIdx ? 'data-pop' : ''}>${i < stamps ? ui.icon('scissors') : i === total - 1 ? ui.icon('i-gift') : ''}</span>`).join('')}</div><div class="cx-vstack" style="gap:10px;padding:0 16px 16px">${ui.text(say(done ? complete : progress), done ? 'label' : 'muted', 'p')}${done ? ui.button({ label: `${ui.icon('i-gift')}${redeem}`, variant: 'default', size: 'default', cls: 'cx-fill', attrs: 'data-redeem' }) : ui.button({ label: `${ui.icon('plus')}${stamp}`, variant: 'outline', size: 'default', cls: 'cx-fill', attrs: 'data-stamp' })}</div></div>`; };
|
|
@@ -1485,12 +1592,16 @@ let wheelsNow = [];
|
|
|
1485
1592
|
|
|
1486
1593
|
// ── Notifications bell { items [{icon,tone,title,desc,time,unread}] } ──
|
|
1487
1594
|
kit.bell = (root, { items }) => {
|
|
1488
|
-
|
|
1489
|
-
|
|
1595
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1596
|
+
root.innerHTML = `<div class="cx-hstack" style="gap:12px">${ui.text(T('Notices'), 'heading')}<span class="cx-grow"></span><span class="cx-bell-wrap">${ui.button({ icon: 'i-bell', variant: 'outline', size: 'icon', cls: 'cx-has-indicator', attrs: 'data-bell aria-haspopup="dialog" aria-label="Avisos"' })}</span></div>${ui.text(T('The system bell: tap to see the notices.'), 'caption', 'p')}`;
|
|
1597
|
+
const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-bellpop'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', T('Notices')); pop.hidden = true; lib.appendChild(pop);
|
|
1490
1598
|
const btnEl = $('[data-bell]', root);
|
|
1491
1599
|
const unread = () => items.filter((i) => i.unread).length;
|
|
1492
|
-
|
|
1493
|
-
|
|
1600
|
+
// when the unread count GOES UP (not on the first paint, not when reading), the bell gives one small nudge
|
|
1601
|
+
let lastUnread = null;
|
|
1602
|
+
const nudge = () => { const n = unread(); if (lastUnread !== null && n > lastUnread) $('svg', btnEl)?.animate([{ rotate: '0deg' }, { rotate: '14deg' }, { rotate: '-10deg' }, { rotate: '6deg' }, { rotate: '0deg' }], { duration: ms('--t-move') * 2.5, easing: ease('--ease-out') }); lastUnread = n; };
|
|
1603
|
+
const syncDot = () => { nudge(); $('.cx-indicator', btnEl)?.remove(); if (unread()) btnEl.insertAdjacentHTML('beforeend', `<span class="cx-indicator cx-count-ind" data-tone="bad">${unread()}</span>`); btnEl.setAttribute('aria-label', unread() ? T('Notices, {0} unread', unread()) : T('Notices')); };
|
|
1604
|
+
const paint = () => { pop.innerHTML = `<div class="cx-hstack" style="justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--border)">${ui.text(T('Notices'), 'heading')}${unread() ? ui.button({ label: T('Mark all as read'), variant: 'ghost', size: 'xs', attrs: 'data-readall' }) : ''}</div>${items.length ? `<div class="cx-bell-list cx-scroll">${items.map((it, i) => `<button class="cn-item cn-item-variant-default cn-item-size-sm cx-bell-it" data-ni="${i}" ${it.unread ? 'data-unread' : ''}>${ui.itemMedia(`<span class="cx-tonal-ic" data-tone="${it.tone}">${ui.icon(it.icon)}</span>`, 'default')}<div class="cn-item-content"><div class="cn-item-title">${it.title}</div><p class="cn-item-description">${it.desc}</p></div><div class="cn-item-actions cx-vstack" style="width:auto;align-items:flex-end;gap:6px">${ui.text(it.time, 'caption')}${it.unread ? `<span class="cx-unread-dot" aria-label="${T('Unread')}"></span>` : ''}</div></button>`).join('')}</div>` : `<div class="cn-empty" style="padding:28px 12px"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-bell')}</div><div class="cn-empty-title">${T('All caught up')}</div></div></div>`}`; };
|
|
1494
1605
|
btnEl.addEventListener('click', () => { if (pop.dataset.state === 'open') return hide(pop, 120); paint(); pop.hidden = false; place(pop, btnEl, 'end', 8); show(pop); });
|
|
1495
1606
|
pop.addEventListener('click', (e) => { if (e.target.closest('[data-readall]')) { items.forEach((i) => i.unread = false); paint(); syncDot(); } const it = e.target.closest('[data-ni]'); if (it) { items[+it.dataset.ni].unread = false; paint(); syncDot(); } });
|
|
1496
1607
|
document.addEventListener('pointerdown', (e) => { if (pop.dataset.state === 'open' && !pop.contains(e.target) && !btnEl.contains(e.target)) hide(pop, 120); });
|
|
@@ -1499,26 +1610,28 @@ let wheelsNow = [];
|
|
|
1499
1610
|
|
|
1500
1611
|
// ── Queue (waitlist) { people [{name, service, since}] } ──
|
|
1501
1612
|
kit.queue = (root, { title, people, offerMinutes = 10 }) => {
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1613
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1614
|
+
const paint = () => { root.innerHTML = `<div class="cx-hstack" style="justify-content:space-between">${ui.text(title, 'heading')}${ui.badge(T('{0} waiting', people.length), { variant: 'secondary' })}</div><div class="cn-item-group">${people.map((p, i) => ui.item({ media: `<div class="cn-item-media"><span class="cx-q-pos">${i + 1}</span></div>`, title: p.name, description: p.offer ? T('Offered {0} · {1}', p.offer, p.service) : T('{0} · waiting since {1}', p.service, p.since), actions: p.offer ? `<span class="cx-q-timer" data-qi="${i}">${ui.text(`${String(Math.floor(p.left / 60)).padStart(2, '0')}:${String(p.left % 60).padStart(2, '0')}`, 'strong')}</span>` : ui.button({ label: T('Offer a time'), size: 'xs', attrs: `data-offer="${i}"` }) })).join(ui.itemSeparator())}</div>`; };
|
|
1615
|
+
root.addEventListener('click', (e) => { const b = e.target.closest('[data-offer]'); if (!b) return; const p = people[+b.dataset.offer]; p.offer = T('today 16:30'); p.left = offerMinutes * 60; paint(); });
|
|
1504
1616
|
setInterval(() => { let changed = false; people.forEach((p, i) => { if (p.offer && p.left > 0) { p.left--; const t = $(`[data-qi="${i}"] .cx-text`, root); if (t) t.textContent = `${String(Math.floor(p.left / 60)).padStart(2, '0')}:${String(p.left % 60).padStart(2, '0')}`; } }); }, 1000);
|
|
1505
1617
|
paint();
|
|
1506
1618
|
};
|
|
1507
1619
|
|
|
1508
1620
|
// ── Password field with strength ──
|
|
1509
|
-
kit.password = (root, { label = '
|
|
1510
|
-
const
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1621
|
+
kit.password = (root, { label = 'Password' } = {}) => {
|
|
1622
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1623
|
+
const RULES = [[T('8 characters or more'), (v) => v.length >= 8], [T('A number'), (v) => /\d/.test(v)], [T('A capital letter'), (v) => /[A-ZÁÉÍÓÚÑ]/.test(v)], [T('A symbol'), (v) => /[^\w\s]/.test(v)]];
|
|
1624
|
+
const NAMES_S = [T('Very weak'), T('Weak'), T('Fair'), T('Good'), T('Strong')], TONES = ['bad', 'bad', 'wait', 'ok', 'ok'];
|
|
1625
|
+
root.innerHTML = `${ui.field({ id: 'pw', label, control: `<div data-slot="input-group" class="cn-input-group"><div class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('i-lock')}</div><input class="cn-input cn-input-group-input" id="pw" type="password" autocomplete="new-password" aria-describedby="pw-s"><div class="cn-input-group-addon cn-input-group-addon-align-inline-end">${ui.button({ icon: 'i-eye', variant: 'ghost', size: 'icon-xs', attrs: `data-pw-toggle aria-label="${T('Show password')}" title="${T('Show')}"` })}</div></div>` })}<div class="cx-pw-meter" id="pw-s" aria-live="polite"><div class="cx-pw-bars">${Array.from({ length: 4 }, () => ui.progress(0, T('Strength'))).join('')}</div>${ui.badge(T('Type a password'), { tone: 'muted' })}</div><ul class="cx-pw-rules">${RULES.map(([t]) => `<li>${ui.icon('i-tick')}${ui.text(t, 'muted')}</li>`).join('')}</ul>`;
|
|
1513
1626
|
const inp = $('#pw', root);
|
|
1514
1627
|
inp.addEventListener('input', () => {
|
|
1515
1628
|
const v = inp.value, ok = RULES.map(([, f]) => f(v)), score = v ? Math.min(4, ok.filter(Boolean).length + (v.length >= 12 ? 1 : 0)) : 0;
|
|
1516
1629
|
$$('.cx-pw-bars .cn-progress-indicator', root).forEach((b, i) => { b.style.transform = `translateX(-${i < score ? 0 : 100}%)`; b.parentElement.dataset.tone = TONES[score]; });
|
|
1517
|
-
$('.cx-pw-meter .cn-badge', root).outerHTML = ui.badge(v ? NAMES_S[score] : '
|
|
1630
|
+
$('.cx-pw-meter .cn-badge', root).outerHTML = ui.badge(v ? NAMES_S[score] : T('Type a password'), { tone: v ? TONES[score] : 'muted' });
|
|
1518
1631
|
$$('.cx-pw-rules li', root).forEach((li, i) => li.toggleAttribute('data-ok', ok[i]));
|
|
1519
1632
|
});
|
|
1520
|
-
$('[data-pw-toggle]', root).addEventListener('click', (e) => { const b = e.currentTarget, showIt = inp.type === 'password'; inp.type = showIt ? 'text' : 'password'; b.innerHTML = ui.icon(showIt ? 'i-eyeoff' : 'i-eye'); b.setAttribute('aria-label', showIt ? '
|
|
1633
|
+
$('[data-pw-toggle]', root).addEventListener('click', (e) => { const b = e.currentTarget, showIt = inp.type === 'password'; inp.type = showIt ? 'text' : 'password'; b.innerHTML = ui.icon(showIt ? 'i-eyeoff' : 'i-eye'); b.setAttribute('aria-label', showIt ? T('Hide password') : T('Show password')); });
|
|
1521
1634
|
};
|
|
1522
1635
|
|
|
1523
1636
|
|
|
1524
|
-
export { CB_CHECK, kit, ui, $, $$, lib, island, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, place, show, hide, initTabs, makeWheel, today, addDays, DAY, MON, DAYLONG, sameDay, fmtDay, money0, fold, hue, ini, reformat, groupThousands, setCheck };
|
|
1637
|
+
export { t, langOf, words, relang, CB_CHECK, kit, ui, $, $$, lib, island, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, place, show, hide, initTabs, makeWheel, today, addDays, DAY, MON, DAYLONG, sameDay, fmtDay, money0, fold, hue, ini, reformat, groupThousands, setCheck };
|