@muten/shadcn 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +88 -1
- package/globals.css +1 -0
- package/kit.css +329 -124
- package/motion.css +3 -0
- package/package.json +1 -1
- package/registry/accordion.muten +3 -3
- package/registry/agenda.js +19 -16
- package/registry/agenda.muten +10 -10
- 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 +197 -114
- 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.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>`;
|
|
@@ -431,10 +482,10 @@ let wheelsNow = [];
|
|
|
431
482
|
// kit.pickTime({ value, label, from, to, step }) → Promise<'HH:MM' | null>, in the adaptive modal with wheels
|
|
432
483
|
let tpCb = null, tpState = null, tpBack = null;
|
|
433
484
|
const tpSettle = () => requestAnimationFrame(() => requestAnimationFrame(() => { wheelsNow.forEach((w) => w.place()); $('.cx-wheel', body)?.focus({ preventScroll: true }); }));
|
|
434
|
-
kit.pickTime = ({ value = '09:00', label = '
|
|
485
|
+
kit.pickTime = ({ value = '09:00', label = T0('Time'), from = 6, to = 22, step = 15 } = {}) => new Promise((resolve) => {
|
|
435
486
|
tpCb?.(null); tpState = { value, label, from, to, step }; tpCb = resolve;
|
|
436
487
|
if (!open) { tpBack = null; openModal('timeWheel', 'auto'); return tpSettle(); }
|
|
437
|
-
// a modal is already open: the wheels become a step of it, and
|
|
488
|
+
// a modal is already open: the wheels become a step of it, and “Done” brings the form back untouched
|
|
438
489
|
const kept = { body: [...body.childNodes], foot: [...foot.childNodes], title: $('#mdl-title').textContent, desc: $('#mdl-desc').textContent, focus: document.activeElement };
|
|
439
490
|
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
491
|
easeHeight(mdl, () => CONTENT.timeWheel()); tpSettle();
|
|
@@ -443,12 +494,12 @@ let wheelsNow = [];
|
|
|
443
494
|
const { value, label, from: f, to: t, step } = tpState, [h0, m0] = value.split(':').map(Number);
|
|
444
495
|
const HS = Array.from({ length: t - f + 1 }, (_, i) => f + i), MS = Array.from({ length: 60 / step }, (_, i) => i * step);
|
|
445
496
|
let h = HS.includes(h0) ? h0 : HS[0], m = MS.includes(m0) ? m0 : 0;
|
|
446
|
-
$('#mdl-title').textContent = label; $('#mdl-desc').textContent = '
|
|
497
|
+
$('#mdl-title').textContent = label; $('#mdl-desc').textContent = T0('Drag each wheel, tap a value or use the arrows.');
|
|
447
498
|
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
499
|
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(); }, '
|
|
500
|
+
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
501
|
sum();
|
|
451
|
-
foot.innerHTML = ui.button({ label: tpBack ? '
|
|
502
|
+
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
503
|
};
|
|
453
504
|
document.addEventListener('click', (e) => {
|
|
454
505
|
const ok = e.target.closest('[data-tw-ok]'), no = e.target.closest('[data-tw-no]'); if ((!ok && !no) || !tpCb) return;
|
|
@@ -478,11 +529,14 @@ let wheelsNow = [];
|
|
|
478
529
|
|
|
479
530
|
|
|
480
531
|
// ── Calendar: mode 'single' | 'range'; months 1 | 2; onChange(value) ──
|
|
481
|
-
kit.calendar = (root,
|
|
532
|
+
kit.calendar = (root, opts = {}) => {
|
|
533
|
+
// its words default to the language of its root (the page's), English when there is none
|
|
534
|
+
const cw = kit.calWords(langOf(root));
|
|
535
|
+
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
536
|
let picking = false; // the month's title opens a year of months: far dates in two taps
|
|
483
537
|
let view = new Date((mode === 'range' ? value?.[0] : value) || today); view.setDate(1);
|
|
484
538
|
let sel = value, hover = null, focusDay = startOfDay((mode === 'range' ? value?.[0] : value) || today);
|
|
485
|
-
const WD = weekdays
|
|
539
|
+
const WD = weekdays;
|
|
486
540
|
const inRange = (d) => mode === 'range' && sel?.[0] && (sel[1] || hover) && d > startOfDay(sel[0]) && d < startOfDay(sel[1] || hover) ;
|
|
487
541
|
const isEdge = (d) => mode === 'range' ? (sameDay(d, sel?.[0]) || sameDay(d, sel?.[1])) : sameDay(d, sel);
|
|
488
542
|
// the arrows sit in the title's own row, so they can never land on the month's name
|
|
@@ -493,7 +547,7 @@ let wheelsNow = [];
|
|
|
493
547
|
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
548
|
const out = d.getMonth() !== first.getMonth(), off = min && d < startOfDay(min);
|
|
495
549
|
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="${
|
|
550
|
+
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
551
|
}).join('')}</div></div>`;
|
|
498
552
|
};
|
|
499
553
|
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 +563,11 @@ let wheelsNow = [];
|
|
|
509
563
|
focusDay = d; paint(true); onChange(sel);
|
|
510
564
|
};
|
|
511
565
|
root.addEventListener('click', (e) => {
|
|
512
|
-
|
|
566
|
+
// a month changed by its arrow slides in from that side (kit.css “Calendar”); the keyboard's PageUp/PageDown stays instant
|
|
567
|
+
const slide = (dir) => { const box = root.querySelector('.cx-cal-months, .cx-cal-picker'); if (box) box.dataset.slide = dir > 0 ? 'next' : 'prev'; };
|
|
568
|
+
const n = e.target.closest('[data-cal-nav]'); if (n) { view.setMonth(view.getMonth() + +n.dataset.calNav); paint(); slide(+n.dataset.calNav); return; }
|
|
513
569
|
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; }
|
|
570
|
+
const yr = e.target.closest('[data-cal-year]'); if (yr) { view.setFullYear(view.getFullYear() + +yr.dataset.calYear); paint(); slide(+yr.dataset.calYear); return; }
|
|
515
571
|
const mo = e.target.closest('[data-cal-month]'); if (mo) { view = new Date(view.getFullYear(), +mo.dataset.calMonth, 1); picking = false; paint(); return; }
|
|
516
572
|
const b = e.target.closest('[data-day]'); if (b && !b.disabled) pick(new Date(+b.dataset.day));
|
|
517
573
|
});
|
|
@@ -534,7 +590,7 @@ let wheelsNow = [];
|
|
|
534
590
|
// Day columns per person, the week, or a list (the phone keeps the day grid and the list). Two ways to use it:
|
|
535
591
|
// · a SHOWCASE (no app behind it): its own sheets open, book, block, move and cancel, all in memory;
|
|
536
592
|
// · 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
|
|
593
|
+
// happened: onOpen(event) when a appointment is tapped, onCreate({resource, day, start}) on a free hour, onBlock(block)
|
|
538
594
|
// when a block is asked, onMove(change) when one is dragged, onRange({from, to, view}) when the days shown
|
|
539
595
|
// change (so the app loads them). The app answers with update(next): new data, same day, view and person.
|
|
540
596
|
// The free hours can come from the server (free: [{resource, day, start, end}]): then they are the ONLY free hours,
|
|
@@ -589,7 +645,10 @@ let wheelsNow = [];
|
|
|
589
645
|
mon: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
|
590
646
|
},
|
|
591
647
|
};
|
|
592
|
-
//
|
|
648
|
+
// the date words of a language (months, days, the calendar's buttons) for any piece that shows a date
|
|
649
|
+
kit.words = (lang) => AG_WORDS[lang === 'es' ? 'es' : 'en'];
|
|
650
|
+
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 }; };
|
|
651
|
+
// a appointment's status as the agenda draws it: its tone (the colour family) and whether it still occupies its time
|
|
593
652
|
const AG_STATUS = { awaiting_payment: 'info', confirmed: 'ok', started: 'live', done: 'done', noshow: 'bad' };
|
|
594
653
|
|
|
595
654
|
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 +663,7 @@ let wheelsNow = [];
|
|
|
604
663
|
actions = null, createSheet = false, services = [], view: startView = 'day', sheet: sheetMode = 'built-in', drag: dragOn = true,
|
|
605
664
|
resize: resizeMode = 'on', moveAcross = 'on',
|
|
606
665
|
start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each', phoneStrip = '' } = opts;
|
|
607
|
-
// THE SHOP'S CLOCK: with a timeZone,
|
|
666
|
+
// THE SHOP'S CLOCK: with a timeZone, “today”, “now” and “the past” are the shop's, not the browser's (an owner
|
|
608
667
|
// travelling, or a phone set to another zone, still sees the shop's day)
|
|
609
668
|
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
669
|
const today = (() => { const d = clockNow(); d.setHours(0, 0, 0, 0); return d; })();
|
|
@@ -630,13 +689,13 @@ let wheelsNow = [];
|
|
|
630
689
|
const y = (m) => ((m - from * 60) / 60) * hourPx;
|
|
631
690
|
const weekDays = () => { const mon = addDays(day, -((day.getDay() + 6) % 7)); return Array.from({ length: 7 }, (_, i) => addDays(mon, i)); };
|
|
632
691
|
// 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
|
|
692
|
+
// kind: a room, a chair). The business goes first. A appointment belongs to its `resource` and also takes every agenda in
|
|
693
|
+
// `with` (a worker AND the room). `capacity` says how many appointments an agenda holds at once: 1 (the default) = they never
|
|
635
694
|
// overlap, 3 = a class of three, 0 = no limit. `overlap` "never" | "allow" overrides it for the whole agenda.
|
|
636
695
|
const kindOf = (r) => r?.kind || 'person';
|
|
637
696
|
const ordered = () => [...resources].sort((a, b) => (kindOf(a) === 'business' ? 0 : 1) - (kindOf(b) === 'business' ? 0 : 1));
|
|
638
697
|
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 (
|
|
698
|
+
// what an agenda is, under its name: its role (“Barber”), else its kind (the business, a room's group), plus how many fit
|
|
640
699
|
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
700
|
// a resource with hideEmpty (the business's own agenda, which only holds what nobody was given) shows its column
|
|
642
701
|
// only on days it has something; the rest of the agendas always show
|
|
@@ -646,10 +705,10 @@ let wheelsNow = [];
|
|
|
646
705
|
const occ = (e) => [e.resource, ...(Array.isArray(e.with) ? e.with : [])];
|
|
647
706
|
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
707
|
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
|
|
708
|
+
// the most appointments one agenda holds at once inside [st, st + dur) (except one, the appointment being moved)
|
|
650
709
|
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
710
|
const roomFor = (rid, d, st, dur, except) => { const cap = capOf(rid); return cap === 0 || peak(rid, d, st, dur, except) < cap; };
|
|
652
|
-
//
|
|
711
|
+
// appointments that break an agenda's capacity (data from the server that crossed): they say so, in words
|
|
653
712
|
let clash = new Set();
|
|
654
713
|
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
714
|
const blOn = (d, rid) => blocks.filter((b) => sameDay(b.day, d) && (!rid || b.resource === rid || b.resource === 'all'));
|
|
@@ -662,13 +721,13 @@ let wheelsNow = [];
|
|
|
662
721
|
const isClosed = (d) => closed.some((c) => sameDay(c, d)) || (!freeList && shown().every((r) => !availability(r.id, d).length));
|
|
663
722
|
// overlapping events share the column: each gets a lane and the lane count of its cluster
|
|
664
723
|
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
|
|
724
|
+
// an app's own words on an appointment (“At home”, “Arrived 10:02”, “Confirmed”): said next to its time, never as colour
|
|
666
725
|
const tagsOf = (e) => (Array.isArray(e.tags) ? e.tags.filter(Boolean).join(' · ') : '');
|
|
667
726
|
const SAY = ['started', 'awaiting_payment', 'noshow']; // the statuses a cita SAYS in words (no stripes, no glow)
|
|
668
727
|
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
728
|
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, '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
729
|
// 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,
|
|
730
|
+
// a day the business does not open: the column hatched, “Closed” in the middle (grid) or as the list's only line
|
|
672
731
|
const shutPill = () => `<span class="cx-ag-shutpill">${ui.icon('caloff')}${ui.text(w.closed, 'label')}</span>`;
|
|
673
732
|
const shutHtml = () => `<div class="cx-ag-off cx-ag-shut" style="top:0;height:100%">${shutPill()}</div>`;
|
|
674
733
|
const offHtml = (d, rid) => {
|
|
@@ -678,9 +737,9 @@ let wheelsNow = [];
|
|
|
678
737
|
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
738
|
return out.map(([a, b]) => `<div class="cx-ag-off" style="top:${y(a)}px;height:${y(b) - y(a)}px"></div>`).join('');
|
|
680
739
|
};
|
|
681
|
-
// the server's free windows, drawn as dashed
|
|
740
|
+
// the server's free windows, drawn as dashed “free” tiles you tap to book (they replace the bare slot buttons)
|
|
682
741
|
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
|
|
742
|
+
// PLACING: while the app's “new appointment” sheet is open, where the appointment will land, drawn dashed (placing {resource, day,
|
|
684
743
|
// start, dur, title, tint}); the app clears it when the sheet closes
|
|
685
744
|
// what already went by: today up to now, and whole past days, under a quiet veil (nothing to book there)
|
|
686
745
|
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 +747,12 @@ let wheelsNow = [];
|
|
|
688
747
|
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
748
|
// while loading, everything that would ask the server for something is shut (days, views, arrows, actions, hours)
|
|
690
749
|
// 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
|
|
750
|
+
// arrives leaves the arrows, the date and the views free (tapping “next” three times in a row must work)
|
|
692
751
|
let shownOnce = false;
|
|
693
752
|
const shut = () => (loading && !shownOnce ? ' disabled aria-disabled="true"' : '');
|
|
694
753
|
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
754
|
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}];
|
|
755
|
+
// the toolbar's own buttons are data: [{id, label, icon, primary}]; “block” and “new” keep their behaviour, any other id goes to onAction
|
|
697
756
|
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
757
|
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
758
|
const groupName = (k) => (k === 'business' ? w.business : k === 'person' ? w.team : resources.find((r) => kindOf(r) === k)?.group || k);
|
|
@@ -708,9 +767,9 @@ let wheelsNow = [];
|
|
|
708
767
|
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
768
|
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
769
|
};
|
|
711
|
-
// the week strip: each day with how many
|
|
770
|
+
// the week strip: each day with how many appointments it has (a plain number), closed days dimmed
|
|
712
771
|
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 ? '' : evs.slice(0, 4).map((e) => `<i style="background:${e.tint || 'var(--selected, var(--primary))'}"></i>`).join('')}</span>` : `<span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span>`}</button>`; }).join('')}</div>`;
|
|
713
|
-
//
|
|
772
|
+
// “Ahora”: on today, the appointment in progress (started, or whose time is now), first thing, one tap to open it
|
|
714
773
|
const nowCard = () => {
|
|
715
774
|
if (!sameDay(day, today)) return '';
|
|
716
775
|
const nowM = nowMin();
|
|
@@ -725,15 +784,19 @@ let wheelsNow = [];
|
|
|
725
784
|
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
785
|
const v0 = eff;
|
|
727
786
|
// 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
|
-
|
|
787
|
+
// NOW, across the whole week when today is in it (as every calendar draws it), one unbroken line from the hour's pill:
|
|
788
|
+
// the days already gone show it faint, the days ahead whole; it rides over everything in the grid (appointments, blocks).
|
|
789
|
+
// The dot marks where today's column starts, so day and week begin the same (pill, tail, line): in day view, or
|
|
790
|
+
// when today is the week's first column, the pill already stands there and no dot is drawn. Nothing while loading.
|
|
791
|
+
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>`; };
|
|
792
|
+
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))}</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
793
|
const offRid = rid || (shown().length === 1 && !freeList ? shown()[0].id : null);
|
|
731
794
|
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
795
|
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
796
|
// list = the phone's agenda: a solid row is sold, a dashed one is still free
|
|
734
|
-
// one
|
|
797
|
+
// one appointment as a row of the list (the day's list and the range's)
|
|
735
798
|
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
|
|
799
|
+
// RANGE: the days picked in the calendar, one after another, each with its appointments (a day with none says so)
|
|
737
800
|
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
801
|
const list = () => {
|
|
739
802
|
const rids = shown().map((r) => r.id), rows = [];
|
|
@@ -754,11 +817,11 @@ let wheelsNow = [];
|
|
|
754
817
|
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
818
|
};
|
|
756
819
|
// LOADING keeps the very layout it is about to show: the same toolbar, strip, column heads, hours and height,
|
|
757
|
-
// with grey
|
|
820
|
+
// with grey appointments where the real ones will land. Nothing jumps when the data arrives, nothing shrinks while it loads.
|
|
758
821
|
const SKEL = [[0.5, 1], [2, 1.5], [4.5, 1], [6, 2], [8, 1]];
|
|
759
822
|
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
823
|
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
|
|
824
|
+
// the top band keeps what it had (the “Ahora” card, the queue) as grey shapes, so its height holds too
|
|
762
825
|
let hadNow = false, hadQueue = false;
|
|
763
826
|
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
827
|
// the days shown, so an app can load exactly those (it is told once per change)
|
|
@@ -777,7 +840,7 @@ let wheelsNow = [];
|
|
|
777
840
|
else return onAction(k);
|
|
778
841
|
paint();
|
|
779
842
|
}
|
|
780
|
-
// a week column says its day and, under it, how many
|
|
843
|
+
// a week column says its day and, under it, how many appointments it holds and who waits for a free hour (closed: “Closed”)
|
|
781
844
|
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
845
|
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
846
|
const paint = () => {
|
|
@@ -799,7 +862,7 @@ let wheelsNow = [];
|
|
|
799
862
|
};
|
|
800
863
|
// drag to move · drag the bottom edge to resize (15 min). A ghost shows where it lands and turns red where the
|
|
801
864
|
// 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,
|
|
865
|
+
// With server free hours, “free” means inside one of them or inside the appointment's own time.
|
|
803
866
|
// the past and the closed days are never free, whatever the hours say
|
|
804
867
|
const gone = (d, st) => { const k = new Date(d); k.setHours(0, 0, 0, 0); return k < today || (sameDay(k, today) && st < nowMin()); };
|
|
805
868
|
const fits = (rid, d, st, dur, except) => {
|
|
@@ -820,7 +883,7 @@ let wheelsNow = [];
|
|
|
820
883
|
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
884
|
else {
|
|
822
885
|
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
|
|
886
|
+
// moveAcross "off": over another person's column the appointment stays in its own (only its time changes)
|
|
824
887
|
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
888
|
const cr = col.getBoundingClientRect(), m = Math.round((((cy - d.dy - cr.top) / hourPx) * 60 + from * 60) / 15) * 15;
|
|
826
889
|
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 +912,7 @@ let wheelsNow = [];
|
|
|
849
912
|
if (commit && !d.ok && changed) island('error', w.notFreeLong, 2000);
|
|
850
913
|
// back to where it was: the ghost flies home, then goes
|
|
851
914
|
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: '
|
|
915
|
+
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
916
|
};
|
|
854
917
|
root.addEventListener('pointerdown', (e) => {
|
|
855
918
|
if (!dragOn || loading) return;
|
|
@@ -927,7 +990,7 @@ let wheelsNow = [];
|
|
|
927
990
|
fitKey = ''; fitSeen.clear(); paint(); })).observe(root);
|
|
928
991
|
|
|
929
992
|
// ── the event sheet (showcase): detail · lifecycle · payment · move (second step) · cancel (second step).
|
|
930
|
-
// In app mode a tapped
|
|
993
|
+
// In app mode a tapped appointment goes to onOpen and the app opens its own sheet. ──
|
|
931
994
|
let open1 = null, step = 'detail', moveTo = null;
|
|
932
995
|
const openEvent = (ev) => { if (!ev) return; if (appMode && onOpen) return onOpen(ev); open1 = ev; step = 'detail'; moveTo = null; sheetOf('agEvent'); };
|
|
933
996
|
const sheet = () => {
|
|
@@ -1006,8 +1069,8 @@ let wheelsNow = [];
|
|
|
1006
1069
|
// ── block time: the form is the agenda's; the block itself is the app's when it listens (onBlock), else local ──
|
|
1007
1070
|
let blockOpen = false;
|
|
1008
1071
|
// THE TITLE OPENS A MONTH, floating under it (on a phone, the sheet from below): one day, or a range of days
|
|
1009
|
-
// (
|
|
1010
|
-
//
|
|
1072
|
+
// (“Several days”: the appointments of each day one after another), and the month's title opens a year of months.
|
|
1073
|
+
// “See which”: each press scrolls to the next overlapping appointment and marks it for a moment
|
|
1011
1074
|
let clashAt = 0;
|
|
1012
1075
|
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
1076
|
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' });
|
|
@@ -1110,10 +1173,10 @@ let wheelsNow = [];
|
|
|
1110
1173
|
const TICK = { sending: 'clock', sent: 'i-tick', delivered: 'i-ticks', read: 'i-ticks' };
|
|
1111
1174
|
kit.bubble = (m) => {
|
|
1112
1175
|
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="
|
|
1176
|
+
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>` : ''}`
|
|
1177
|
+
: 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
1178
|
: 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: '
|
|
1179
|
+
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
1180
|
};
|
|
1118
1181
|
// audio bubbles play (a demo clock drives the Progress atom)
|
|
1119
1182
|
document.addEventListener('click', (e) => {
|
|
@@ -1123,7 +1186,7 @@ let wheelsNow = [];
|
|
|
1123
1186
|
let t = b._pos || 0; b.innerHTML = ui.icon('pause');
|
|
1124
1187
|
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
1188
|
});
|
|
1126
|
-
const typing = () => `<div class="cx-chat-msg" data-who="in" data-typing><div class="cx-chat-bubble cx-typing" aria-label="
|
|
1189
|
+
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
1190
|
|
|
1128
1191
|
// ── More atoms: Card, Alert and the InputGroup button, so nothing below is drawn by hand ──
|
|
1129
1192
|
ui.card = ({ title = '', description = '', action = '', content = '', footer = '', size = 'sm', cls = '', attrs = '' }) =>
|
|
@@ -1134,7 +1197,7 @@ let wheelsNow = [];
|
|
|
1134
1197
|
`<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
1198
|
|
|
1136
1199
|
// ── Action menu: the library's dropdown for verbs (archive, delete…), items [{id, label, icon, danger}] ──
|
|
1137
|
-
kit.actionMenu = (trigger, { items, label = '
|
|
1200
|
+
kit.actionMenu = (trigger, { items, label = T0('Options'), onPick }) => {
|
|
1138
1201
|
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
1202
|
trigger.setAttribute('aria-haspopup', 'menu'); trigger.setAttribute('aria-expanded', 'false');
|
|
1140
1203
|
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 +1211,8 @@ let wheelsNow = [];
|
|
|
1148
1211
|
|
|
1149
1212
|
// ── Composer: shadcn's InputGroup with a Textarea and a block-end addon for its buttons ──
|
|
1150
1213
|
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:
|
|
1214
|
+
kit.composer = ({ placeholder = T0('Write a message'), attach = true, emoji = true, templates = [], hint = '' } = {}) =>
|
|
1215
|
+
`<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
1216
|
const autosize = (ta) => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; };
|
|
1154
1217
|
const wireComposer = (root, onSend, templates = []) => {
|
|
1155
1218
|
const form = $('[data-composer]', root), ta = $('textarea', form), send = $('[data-send]', form);
|
|
@@ -1159,10 +1222,10 @@ let wheelsNow = [];
|
|
|
1159
1222
|
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } });
|
|
1160
1223
|
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
1224
|
const tb = $('[data-templates]', form);
|
|
1162
|
-
if (tb) kit.actionMenu(tb, { label: '
|
|
1225
|
+
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
1226
|
const eb = $('[data-emoji]', form);
|
|
1164
1227
|
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);
|
|
1228
|
+
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
1229
|
pop.innerHTML = EMOJI.map((x) => ui.button({ label: x, variant: 'ghost', size: 'icon', attrs: `data-emo="${x}" aria-label="${x}"` })).join('');
|
|
1167
1230
|
eb.addEventListener('click', () => { if (pop.dataset.state === 'open') return hide(pop, 120); pop.hidden = false; place(pop, eb, 'start', 8); show(pop); });
|
|
1168
1231
|
pop.addEventListener('click', (e) => { const b = e.target.closest('[data-emo]'); if (b) insert(b.dataset.emo); });
|
|
@@ -1176,14 +1239,15 @@ let wheelsNow = [];
|
|
|
1176
1239
|
// and the person panel are options; a conversation is { id, name, channel, handle, state, control, unread, last,
|
|
1177
1240
|
// lastBy, time, window, archived, messages }. A message can be text/image/audio/card (kit.bubble), a day, a
|
|
1178
1241
|
// system line { system, icon } or an event card { event: { icon, title, subtitle, rows, total, state, actions } }.
|
|
1179
|
-
kit.messaging = (root, { title = '
|
|
1242
|
+
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') }) => {
|
|
1243
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1180
1244
|
let cur = conversations[0].id, filter = filters[0].id, q = '', archived = false, armed = null;
|
|
1181
1245
|
const conv = () => conversations.find((c) => c.id === cur);
|
|
1182
1246
|
const inView = (c) => !!c.archived === archived && (!q || fold(`${c.name} ${c.last}`).includes(fold(q)));
|
|
1183
1247
|
const visible = () => conversations.filter((c) => inView(c) && filters.find((f) => f.id === filter).match(c));
|
|
1184
1248
|
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
1249
|
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: '
|
|
1250
|
+
const animIn = (el) => el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: ease('--ease-out') });
|
|
1187
1251
|
const clock = () => { const n = new Date(); return `${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}`; };
|
|
1188
1252
|
|
|
1189
1253
|
// ── list ──
|
|
@@ -1191,7 +1255,7 @@ let wheelsNow = [];
|
|
|
1191
1255
|
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
1256
|
const paintList = () => {
|
|
1193
1257
|
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 ? '
|
|
1258
|
+
$('.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
1259
|
$('.cx-mx-count', root).textContent = `${conversations.filter((c) => !!c.archived === archived).length} ${archived ? 'archivadas' : 'conversaciones'}`;
|
|
1196
1260
|
$('[data-mx-arch]', root).innerHTML = archived ? `${ui.icon('i-inbox')}Bandeja` : `${ui.icon('i-archive')}Archivadas`;
|
|
1197
1261
|
paintChips();
|
|
@@ -1209,22 +1273,22 @@ let wheelsNow = [];
|
|
|
1209
1273
|
const paintQuick = () => {
|
|
1210
1274
|
const slot = $('.cx-mx-quick', root), c = conv();
|
|
1211
1275
|
easeHeight(slot, () => {
|
|
1212
|
-
slot.innerHTML = armed ? ui.alert({ icon: armed.icon, title:
|
|
1213
|
-
: `<div class="cx-mx-qrow cx-noscrollbar" role="group" aria-label="
|
|
1276
|
+
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' })
|
|
1277
|
+
: `<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
1278
|
});
|
|
1215
1279
|
};
|
|
1216
1280
|
const paintThread = () => {
|
|
1217
1281
|
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"
|
|
1282
|
+
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
1283
|
armed = null;
|
|
1220
1284
|
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:
|
|
1285
|
+
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
1286
|
+ `<div class="cx-mx-control"></div>`
|
|
1223
1287
|
+ `<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: '
|
|
1288
|
+
+ `<div class="cx-mx-compose"><div class="cx-mx-quick"></div>${kit.composer({ placeholder: T('Write a message…'), templates, hint })}</div>`;
|
|
1225
1289
|
paintControl(); paintQuick();
|
|
1226
1290
|
wireComposer($('.cx-mx-compose', root), send, templates);
|
|
1227
|
-
kit.actionMenu($('[data-mx-more]', root), { items: () => [{ id: 'archive', label: c.archived ? '
|
|
1291
|
+
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
1292
|
const ch = $('.cx-chat', root); ch.scrollTop = ch.scrollHeight;
|
|
1229
1293
|
};
|
|
1230
1294
|
|
|
@@ -1233,26 +1297,26 @@ let wheelsNow = [];
|
|
|
1233
1297
|
const p = profile(c) || {};
|
|
1234
1298
|
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
1299
|
+ (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 ? '
|
|
1300
|
+
+ (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>` : '')
|
|
1301
|
+
+ (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>` : '')
|
|
1302
|
+
+ `<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
1303
|
};
|
|
1240
1304
|
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 = '
|
|
1305
|
+
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' }); };
|
|
1306
|
+
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' }); };
|
|
1307
|
+
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
1308
|
|
|
1245
1309
|
// ── behaviour ──
|
|
1246
1310
|
const openConv = (id) => { cur = id; const c = conv(); if (c) c.unread = 0; root.dataset.view = 'thread'; paintList(); paintThread(); paintCtx(); };
|
|
1247
1311
|
const act = (what) => {
|
|
1248
1312
|
const c = conv(); if (!c) return;
|
|
1249
|
-
if (what === 'archive') { c.archived = !c.archived; island('done', c.archived ? '
|
|
1313
|
+
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
1314
|
if (what === 'delete') openModal('mxDelete', 'auto');
|
|
1251
1315
|
};
|
|
1252
1316
|
const pushMsg = (m) => { const ch = $('.cx-chat', root); ch.insertAdjacentHTML('beforeend', msgHtml(m)); animIn(ch.lastElementChild); ch.scrollTop = ch.scrollHeight; };
|
|
1253
1317
|
const send = (text, extra = {}) => {
|
|
1254
1318
|
const c = conv(), id = 'm' + Date.now(), time = clock();
|
|
1255
|
-
const m = { id, out: true, text, time, status: 'sending', tag: '
|
|
1319
|
+
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
1320
|
if (c.control === 'bot' || c.control === 'need') { c.control = 'me'; if (c.state === 'attention') c.state = ''; paintControl(); syncHead(); }
|
|
1257
1321
|
pushMsg(m);
|
|
1258
1322
|
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 +1327,7 @@ let wheelsNow = [];
|
|
|
1263
1327
|
};
|
|
1264
1328
|
|
|
1265
1329
|
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')}
|
|
1330
|
+
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
1331
|
root.dataset.view = 'list';
|
|
1268
1332
|
|
|
1269
1333
|
root.addEventListener('input', (e) => { if (e.target.matches('[data-mx-q]')) { q = e.target.value; paintList(); } });
|
|
@@ -1276,15 +1340,15 @@ let wheelsNow = [];
|
|
|
1276
1340
|
if (t('[data-mx-connect]')) return openModal('mxConnect', 'auto');
|
|
1277
1341
|
if (t('[data-mx-profile]')) return openModal('mxProfile', 'auto');
|
|
1278
1342
|
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', '
|
|
1343
|
+
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
1344
|
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
1345
|
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
1346
|
if (t('[data-mx-q-no]')) { armed = null; paintQuick(); return; }
|
|
1283
1347
|
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
1348
|
});
|
|
1285
1349
|
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('
|
|
1350
|
+
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(); }
|
|
1351
|
+
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
1352
|
});
|
|
1289
1353
|
// layout by the component's own width, not the window's: wide = 3 panes, mid = person in a sheet, narrow = one pane
|
|
1290
1354
|
kit.watchWidth(root, [[720, 'narrow'], [1060, 'mid']], 'wide');
|
|
@@ -1335,20 +1399,23 @@ let wheelsNow = [];
|
|
|
1335
1399
|
};
|
|
1336
1400
|
|
|
1337
1401
|
// ── Chatbot: { name, avatar, greeting, suggestions, respond(text) → {text, actions} } ──
|
|
1338
|
-
|
|
1339
|
-
|
|
1402
|
+
// avatar: an image, logo or GIF url in place of the drawn face (the face stays when there is none)
|
|
1403
|
+
kit.chatbot = (root, { name, greeting, suggestions, respond, avatar = '' }) => {
|
|
1404
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1405
|
+
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>';
|
|
1406
|
+
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
1407
|
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: '
|
|
1408
|
+
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
1409
|
let stop = null;
|
|
1343
1410
|
const ask = (text) => {
|
|
1344
1411
|
if (stop) return; add(kit.bubble({ out: true, text }));
|
|
1345
1412
|
$('.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', '
|
|
1413
|
+
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>`);
|
|
1414
|
+
const send = $('[data-send]', root); send.innerHTML = ui.icon('pause'); send.disabled = false; send.setAttribute('aria-label', T('Stop')); send.dataset.stop = '';
|
|
1348
1415
|
setTimeout(() => {
|
|
1349
1416
|
const ans = respond(text); thinking.remove();
|
|
1350
1417
|
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', '
|
|
1418
|
+
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
1419
|
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
1420
|
stop = () => { clearInterval(tick); cancelled = true; done(); };
|
|
1354
1421
|
}, 700);
|
|
@@ -1358,7 +1425,7 @@ let wheelsNow = [];
|
|
|
1358
1425
|
const s = e.target.closest('[data-sug]'); if (s) return ask(s.dataset.sug);
|
|
1359
1426
|
const st = e.target.closest('[data-stop]'); if (st && stop) { e.preventDefault(); stop(); return; }
|
|
1360
1427
|
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')
|
|
1428
|
+
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
1429
|
}, true);
|
|
1363
1430
|
add(kit.bubble({ text: greeting }));
|
|
1364
1431
|
};
|
|
@@ -1370,13 +1437,14 @@ let wheelsNow = [];
|
|
|
1370
1437
|
|
|
1371
1438
|
// ── Bar chart { title, data [[label, value]], fmt } ──
|
|
1372
1439
|
kit.barChart = (root, { title, data, fmt = (v) => v, periods }) => {
|
|
1440
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1373
1441
|
const paint = (rows) => {
|
|
1374
1442
|
const max = Math.max(...rows.map(([, v]) => v)) * 1.1, W = 100, H = 160, bw = W / rows.length;
|
|
1375
1443
|
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="
|
|
1444
|
+
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
1445
|
<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
1446
|
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:
|
|
1447
|
+
$$('.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
1448
|
};
|
|
1381
1449
|
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
1450
|
root.addEventListener('pointerout', (e) => { if (e.target.closest('.cx-chart-bar')) { tip.dataset.state = 'closed'; tip.hidden = true; } });
|
|
@@ -1386,23 +1454,24 @@ let wheelsNow = [];
|
|
|
1386
1454
|
|
|
1387
1455
|
// ── Checklist { title, steps [{title, desc, done, action}] } ──
|
|
1388
1456
|
kit.checklist = (root, { title, steps }) => {
|
|
1457
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1389
1458
|
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
1459
|
root.addEventListener('click', (e) => { const b = e.target.closest('[data-step]'); if (!b) return; steps[+b.dataset.step].done = true; easeHeight(root, paint); });
|
|
1391
1460
|
paint();
|
|
1392
1461
|
};
|
|
1393
1462
|
|
|
1394
|
-
// ── Dirty bar: watch a form; show
|
|
1463
|
+
// ── Dirty bar: watch a form; show “unsaved changes” while it differs from its saved state ──
|
|
1395
1464
|
kit.dirtyBar = (form, { onSave }) => {
|
|
1396
1465
|
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
1466
|
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('
|
|
1467
|
+
const bar = document.createElement('div'); bar.className = 'cx-dirty'; bar.setAttribute('role', 'region'); bar.setAttribute('aria-label', tx(form, 'Unsaved changes'));
|
|
1468
|
+
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
1469
|
form.appendChild(bar);
|
|
1401
1470
|
const check = () => bar.toggleAttribute('data-open', snap() !== saved);
|
|
1402
1471
|
form.addEventListener('input', check); form.addEventListener('click', () => setTimeout(check));
|
|
1403
1472
|
bar.addEventListener('click', (e) => {
|
|
1404
1473
|
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 = '
|
|
1474
|
+
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
1475
|
});
|
|
1407
1476
|
return { reset: () => { saved = snap(); check(); } };
|
|
1408
1477
|
};
|
|
@@ -1410,8 +1479,9 @@ let wheelsNow = [];
|
|
|
1410
1479
|
|
|
1411
1480
|
// ── Weekly hours { days: [{name, open, ranges:[[from,to]]}] } ──
|
|
1412
1481
|
kit.weeklyHours = (root, { days }) => {
|
|
1482
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1413
1483
|
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="
|
|
1484
|
+
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
1485
|
root.addEventListener('click', (e) => {
|
|
1416
1486
|
const day = e.target.closest('[data-wd]'); if (!day) return; const d = days[+day.dataset.wd];
|
|
1417
1487
|
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 +1496,19 @@ let wheelsNow = [];
|
|
|
1426
1496
|
|
|
1427
1497
|
// ── Plan cards { cycle: ['month','year'], plans [{id,name,price:{month,year},desc,features,current}] } ──
|
|
1428
1498
|
kit.plans = (root, { plans, yearlyNote = '' }) => {
|
|
1499
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1429
1500
|
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="
|
|
1501
|
+
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
1502
|
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
1503
|
paint();
|
|
1433
1504
|
};
|
|
1434
1505
|
|
|
1435
1506
|
// ── Card field (number with brand, expiry, CVC) ──
|
|
1436
1507
|
kit.cardField = (root, { onSubmit }) => {
|
|
1508
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1437
1509
|
const BRANDS = [[/^4/, 'Visa'], [/^(5[1-5]|2[2-7])/, 'Mastercard'], [/^3[47]/, 'Amex']];
|
|
1438
1510
|
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: '
|
|
1511
|
+
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
1512
|
const n = $('#cc-n', root), ex = $('#cc-e', root), cvc = $('#cc-c', root), msg = $('#cc-msg', root), help = msg.innerHTML;
|
|
1441
1513
|
const brand = () => BRANDS.find(([re]) => re.test(n.value.replace(/\D/g, '')))?.[1] || '';
|
|
1442
1514
|
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 +1516,23 @@ let wheelsNow = [];
|
|
|
1444
1516
|
cvc.addEventListener('input', () => { cvc.value = cvc.value.replace(/\D/g, ''); });
|
|
1445
1517
|
$('[data-cc-save]', root).addEventListener('click', () => {
|
|
1446
1518
|
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')}
|
|
1519
|
+
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.') : '';
|
|
1520
|
+
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
1521
|
if (!err) onSubmit?.(num.slice(-4));
|
|
1450
1522
|
});
|
|
1451
1523
|
};
|
|
1452
1524
|
|
|
1453
1525
|
// ── Member list (any group: team, workspace, family plan…) + invite; Permission matrix (any rows × any roles) ──
|
|
1454
1526
|
// 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
|
|
1527
|
+
kit.memberList = (root, { members, roles, ownerRole, noun = ['person', 'people'], invite }) => {
|
|
1528
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1529
|
+
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
1530
|
root.addEventListener('click', (e) => { if (e.target.closest('[data-invite]')) openModal('invite', 'auto'); });
|
|
1458
1531
|
const choosable = roles.filter((r) => r.name !== ownerRole);
|
|
1459
1532
|
CONTENT.invite = () => {
|
|
1460
1533
|
$('#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: '
|
|
1534
|
+
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>`;
|
|
1535
|
+
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
1536
|
};
|
|
1464
1537
|
document.addEventListener('click', (e) => {
|
|
1465
1538
|
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 +1541,18 @@ let wheelsNow = [];
|
|
|
1468
1541
|
paint();
|
|
1469
1542
|
};
|
|
1470
1543
|
// permissionMatrix({ roles:[name], rows:[{name, description, allow:[bool per role]}], lockedRole, onChange })
|
|
1471
|
-
kit.permissionMatrix = (root, { roles, rows, lockedRole, rowLabel = '
|
|
1544
|
+
kit.permissionMatrix = (root, { roles, rows, lockedRole, rowLabel = T0('Permission'), onChange = () => {} }) => {
|
|
1545
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1472
1546
|
const li = roles.indexOf(lockedRole);
|
|
1473
1547
|
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
1548
|
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
1549
|
};
|
|
1476
1550
|
|
|
1477
1551
|
// ── Stamp card { owner, total, stamps, reward } ──
|
|
1478
|
-
kit.stampCard = (root, { owner, total, stamps, reward, store, title =
|
|
1552
|
+
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' }) => {
|
|
1553
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1554
|
+
// the words are templates in English; said in the card's language before %owner… are filled
|
|
1555
|
+
title = T(title); progress = T(progress); complete = T(complete); count = T(count); redeem = T(redeem); stamp = T(stamp);
|
|
1479
1556
|
// the words are templates, so the card speaks the page's language: %owner %n %total %left %reward
|
|
1480
1557
|
const say = (tpl) => String(tpl).replace(/%(owner|total|left|reward|n)/g, (_, k) => ({ owner, n: stamps, total, left: total - stamps, reward })[k]);
|
|
1481
1558
|
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 +1562,16 @@ let wheelsNow = [];
|
|
|
1485
1562
|
|
|
1486
1563
|
// ── Notifications bell { items [{icon,tone,title,desc,time,unread}] } ──
|
|
1487
1564
|
kit.bell = (root, { items }) => {
|
|
1488
|
-
|
|
1489
|
-
|
|
1565
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1566
|
+
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')}`;
|
|
1567
|
+
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
1568
|
const btnEl = $('[data-bell]', root);
|
|
1491
1569
|
const unread = () => items.filter((i) => i.unread).length;
|
|
1492
|
-
|
|
1493
|
-
|
|
1570
|
+
// when the unread count GOES UP (not on the first paint, not when reading), the bell gives one small nudge
|
|
1571
|
+
let lastUnread = null;
|
|
1572
|
+
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; };
|
|
1573
|
+
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')); };
|
|
1574
|
+
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
1575
|
btnEl.addEventListener('click', () => { if (pop.dataset.state === 'open') return hide(pop, 120); paint(); pop.hidden = false; place(pop, btnEl, 'end', 8); show(pop); });
|
|
1495
1576
|
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
1577
|
document.addEventListener('pointerdown', (e) => { if (pop.dataset.state === 'open' && !pop.contains(e.target) && !btnEl.contains(e.target)) hide(pop, 120); });
|
|
@@ -1499,26 +1580,28 @@ let wheelsNow = [];
|
|
|
1499
1580
|
|
|
1500
1581
|
// ── Queue (waitlist) { people [{name, service, since}] } ──
|
|
1501
1582
|
kit.queue = (root, { title, people, offerMinutes = 10 }) => {
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1583
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1584
|
+
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>`; };
|
|
1585
|
+
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
1586
|
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
1587
|
paint();
|
|
1506
1588
|
};
|
|
1507
1589
|
|
|
1508
1590
|
// ── Password field with strength ──
|
|
1509
|
-
kit.password = (root, { label = '
|
|
1510
|
-
const
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1591
|
+
kit.password = (root, { label = 'Password' } = {}) => {
|
|
1592
|
+
const T = (k, ...a) => tx(root, k, ...a);
|
|
1593
|
+
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)]];
|
|
1594
|
+
const NAMES_S = [T('Very weak'), T('Weak'), T('Fair'), T('Good'), T('Strong')], TONES = ['bad', 'bad', 'wait', 'ok', 'ok'];
|
|
1595
|
+
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
1596
|
const inp = $('#pw', root);
|
|
1514
1597
|
inp.addEventListener('input', () => {
|
|
1515
1598
|
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
1599
|
$$('.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] : '
|
|
1600
|
+
$('.cx-pw-meter .cn-badge', root).outerHTML = ui.badge(v ? NAMES_S[score] : T('Type a password'), { tone: v ? TONES[score] : 'muted' });
|
|
1518
1601
|
$$('.cx-pw-rules li', root).forEach((li, i) => li.toggleAttribute('data-ok', ok[i]));
|
|
1519
1602
|
});
|
|
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 ? '
|
|
1603
|
+
$('[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
1604
|
};
|
|
1522
1605
|
|
|
1523
1606
|
|
|
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 };
|
|
1607
|
+
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 };
|