@ziyarago/ui 0.15.3 → 0.17.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.
@@ -1,11 +1,16 @@
1
1
  // @ziyarago/ui · components, part 2 — Forms, Surfaces, Actions, Data, Navigation, Layout, CRM, Hotels.
2
2
  import { icon, cx, ddRef, pick, INPUT_ATTRS } from './components.js';
3
3
  import { trap } from './dialog.js';
4
- import { listKeys, openKeys, tabIdx, toTrigger, trigger } from './keys.js';
4
+ import { listKeys, openKeys, tabIdx, toTrigger, trigger, calKeys, dateKey } from './keys.js';
5
5
  const tipId = s => { let x = 5381; for (const c of String(s)) x = (x * 33 ^ c.charCodeAt(0)) >>> 0; return 'zg-tip-' + x.toString(36); };
6
- let tipN = 0;
6
+ let tipN = 0, cbN = 0;
7
+ const tipScroll = el => { if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); };
7
8
  // Same text twice on one page would repeat the id: the later tooltip gets a unique one (browser only, after mount).
8
9
  const tipRef = el => { const s = el && el.lastElementChild; if (!s || !s.id || typeof document === 'undefined') return; const same = document.querySelectorAll('[id="' + s.id + '"]'); if (same.length > 1 && same[0] !== s) { s.id += '-' + (++tipN); el.setAttribute('aria-describedby', s.id); } };
10
+ // WCAG 1.4.13: while the tip is shown (hover or keyboard focus) Escape hides it without moving focus; it comes back on the next hover or focus.
11
+ const tipEsc = el => ev => { if (ev.key === 'Escape') el.setAttribute('data-tip-off', ''); };
12
+ const tipShow = e => { const el = e.currentTarget; if (e.type === 'mouseenter' || e.type === 'focus') el.removeAttribute('data-tip-off'); if (!el._zgTip) { el._zgTip = tipEsc(el); document.addEventListener('keydown', el._zgTip); } };
13
+ const tipHide = (e, still) => { const el = e.currentTarget; if (el.matches(still)) return; if (el._zgTip) document.removeEventListener('keydown', el._zgTip); el._zgTip = null; el.removeAttribute('data-tip-off'); };
9
14
  import { t, getUiLocale, intlLocale } from '../i18n/index.js';
10
15
  const dloc = p => p.locale || (getUiLocale() === 'en' ? 'en-GB' : intlLocale());
11
16
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
@@ -35,7 +40,7 @@ export const more = {
35
40
  /* ── Actions ── */
36
41
  Dropdown: { state: { open: false }, render: ({ h, props: p, state, set }) => h('div', { class: 'zg-dd' }, h('button', { type: 'button', class: cx('zg-btn', p.size && 'zg-btn--' + p.size), 'aria-haspopup': 'menu', 'aria-expanded': String(state.open && !p.disabled), disabled: p.disabled, onClick: () => set({ open: !state.open }), onKeydown: openKeys(state.open && !p.disabled, () => set({ open: true })) }, p.icon ? icon(h, p.icon, 16) : null, p.label, icon(h, 'chevron-down', 14)), state.open && !p.disabled ? h('div', { class: 'zg-menu', role: 'menu', 'data-zg-dd': '', ref: ddRef(() => set({ open: false }), { focus: true }), onKeydown: listKeys('[role=menuitem]', { wrap: true, onTab: e => { const t = trigger(e.currentTarget); t && t.focus(); set({ open: false }); } }) }, ...(p.items || []).map((it, i, all) => it.separator ? h('div', { key: i, class: 'zg-menu__sep', role: 'separator' }) : h('button', { key: i, type: 'button', role: 'menuitem', tabIndex: tabIdx(all.map(x => ({ disabled: x.separator || x.disabled })), () => false)(i), disabled: it.disabled, 'aria-disabled': it.disabled ? 'true' : undefined, class: cx('zg-menu__i', it.tone === 'danger' && 'zg-menu__i--danger'), onClick: e => { toTrigger(e); set({ open: false }); it.onClick && it.onClick(it); p.onSelect && p.onSelect(it.value); } }, it.icon ? icon(h, it.icon, 16) : null, it.label, it.shortcut ? h('span', { class: 'zg-kbd' }, it.shortcut) : null))) : null) },
37
42
  Menu: { render: ({ h, props: p }) => p.open === false ? null : h('div', { class: 'zg-menu zg-menu--static', role: 'menu', 'data-zg-dd': '', ref: p.open === true && p.onClose ? ddRef(p.onClose, { self: true, focus: true }) : undefined, onKeydown: listKeys('[role=menuitem]', { wrap: true, onTab: () => p.onClose && p.onClose() }) }, ...(p.items || []).map((it, i, all) => it.separator ? h('div', { key: i, class: 'zg-menu__sep', role: 'separator' }) : h('button', { key: i, type: 'button', role: 'menuitem', tabIndex: tabIdx(all.map(x => ({ disabled: x.separator || x.disabled })), () => false)(i), disabled: it.disabled, 'aria-disabled': it.disabled ? 'true' : undefined, class: cx('zg-menu__i', it.tone === 'danger' && 'zg-menu__i--danger'), onClick: () => { it.onClick && it.onClick(it); p.onSelect && p.onSelect(it.value); } }, it.icon ? icon(h, it.icon, 16) : null, it.label, it.shortcut ? h('span', { class: 'zg-kbd' }, it.shortcut) : null))) },
38
- Tooltip: { render: ({ h, props: p, children }) => h('span', { class: cx('zg-tip', p.placement && p.placement !== 'top' && 'zg-tip--' + p.placement), 'data-tip': p.text, tabIndex: 0, 'aria-describedby': p.text ? tipId(p.text) : undefined, ref: p.text ? tipRef : undefined }, ...children, p.text ? h('span', { id: tipId(p.text), role: 'tooltip', class: 'zg-sr' }, p.text) : null) },
43
+ Tooltip: { render: ({ h, props: p, children }) => h('span', { class: cx('zg-tip', p.placement && p.placement !== 'top' && 'zg-tip--' + p.placement), 'data-tip': p.text, tabIndex: 0, 'aria-describedby': p.text ? tipId(p.text) : undefined, ref: p.text ? tipRef : undefined, onMouseenter: tipShow, onFocus: tipShow, onMouseleave: e => tipHide(e, ':focus-visible'), onBlur: e => tipHide(e, ':hover') }, ...children, p.text ? h('span', { id: tipId(p.text), role: 'tooltip', class: 'zg-sr' }, p.text) : null) },
39
44
  Popover: { state: { open: false }, render: ({ C, h, props: p, state, set, children }) => h('span', { class: 'zg-dd' }, h(C.Button, { size: p.size, onClick: () => set({ open: !state.open }) }, p.label), state.open ? h('div', { class: 'zg-pop zg-glass', 'data-zg-dd': '', ref: ddRef(() => set({ open: false })) }, ...children) : null) },
40
45
  CopyButton: { state: { done: false, err: false }, render: ({ C, h, props: p, state, set }) => { const reset = () => setTimeout(() => set({ done: false, err: false }), 1500), fail = e => { set({ done: false, err: true }); p.onError && p.onError(e); reset(); }, ok = () => { set({ done: true, err: false }); p.onCopy && p.onCopy(p.value); reset(); };
41
46
  const msg = state.err ? t('CopyButton', 'failed') : state.done ? t('CopyButton', 'copied') : '';
@@ -62,21 +67,28 @@ export const more = {
62
67
  FileUpload: { state: { drag: false }, render: ({ h, props: p, state, set }) => { const ac = String(p.accept || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean), okType = f => !ac.length || ac.some(a => a[0] === '.' ? f.name.toLowerCase().endsWith(a) : a.endsWith('/*') ? (f.type || '').toLowerCase().startsWith(a.slice(0, -1)) : (f.type || '').toLowerCase() === a), rej = (f, r) => p.onReject && p.onReject(f, r),
63
68
  take = list => { const ok = []; for (const f of list) { if (!okType(f)) rej(f, 'type'); else if (p.maxSize && f.size > p.maxSize) rej(f, 'size'); else ok.push(f); } if (!p.multiple) ok.slice(1).forEach(f => rej(f, 'count')); const out = p.multiple ? ok : ok.slice(0, 1); if (out.length && p.onChange) p.onChange(out); }, pick = e => { const i = e.currentTarget.querySelector('input'); if (i) i.click(); };
64
69
  return h('div', { class: 'zg-field' }, p.label ? h('span', { class: 'zg-label' }, p.label) : null, h('div', { class: 'zg-fdrop', role: 'button', tabIndex: 0, 'aria-label': p.title || t('FileUpload', 'title'), 'data-drag': state.drag ? '' : undefined, onClick: pick, onKeydown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(e); } }, onDragenter: e => { e.preventDefault(); if (!state.drag) set({ drag: true }); }, onDragover: e => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; if (!state.drag) set({ drag: true }); }, onDragleave: e => { if (!e.currentTarget.contains(e.relatedTarget)) set({ drag: false }); }, onDrop: e => { e.preventDefault(); set({ drag: false }); take([...(e.dataTransfer ? e.dataTransfer.files : [])]); } }, icon(h, p.image ? 'image-up' : 'upload', 24), h('b', null, p.title || t('FileUpload', 'title')), h('span', { class: 'zg-hint' }, p.hint || t('FileUpload', 'hint')), h('input', { type: 'file', multiple: p.multiple, accept: p.accept, tabIndex: -1, 'aria-hidden': 'true', style: { display: 'none' }, onClick: e => e.stopPropagation(), onChange: e => { take([...e.target.files]); e.target.value = ''; } })), p.error ? h('span', { class: 'zg-hint zg-hint--error', role: 'alert' }, p.error) : null); } },
65
- Combobox: { state: { open: false, q: '' }, render: ({ h, props: p, state, set }) => { const cur = (p.options || []).find(o => o.value === p.modelValue), f = (p.options || []).filter(o => !state.q || (p.filter ? p.filter(o, state.q) : o.label.toLowerCase().includes(state.q.toLowerCase())));
66
- return lab(h, p, h('div', { class: 'zg-select' }, field(h, p, icon(h, 'search', 16), h('input', { value: state.open && !p.disabled ? state.q : (cur ? cur.label : ''), disabled: p.disabled, placeholder: p.placeholder || t('Combobox', 'placeholder'), onFocus: () => set({ open: true, q: '' }), onInput: e => set({ q: e.target.value, open: true }) }), [p.clearable && cur && !p.disabled ? h('button', { key: 'x', type: 'button', class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('Combobox', 'clear'), onMouseDown: e => e.preventDefault(), onClick: () => { set({ open: false, q: '' }); p.onChange && p.onChange(null); } }, icon(h, 'x', 14)) : null, icon(h, 'chevron-down', 16)]),
67
- state.open && !p.disabled ? h('div', { class: 'zg-select__list', role: 'listbox', 'aria-label': p.label || p.placeholder, 'data-zg-dd': '', ref: ddRef(() => set({ open: false, q: '' })) }, ...(f.length ? f.map(o => h('button', { key: o.value, type: 'button', role: 'option', class: 'zg-select__opt', disabled: o.disabled, 'aria-disabled': o.disabled ? 'true' : undefined, 'aria-selected': String(o.value === p.modelValue), onMouseDown: e => e.preventDefault(), onClick: () => { set({ open: false, q: '' }); p.onChange && p.onChange(o.value); } }, o.label)) : [h('div', { key: 'e', class: 'zg-empty' }, p.emptyText || '—')])) : null)); } },
70
+ Combobox: { state: { open: false, q: '', ai: -1, id: '' }, render: ({ h, props: p, state, set }) => { const all = p.options || [], cur = all.find(o => o.value === p.modelValue), f = all.filter(o => !state.q || (p.filter ? p.filter(o, state.q) : o.label.toLowerCase().includes(state.q.toLowerCase()))), open = state.open && !p.disabled, id = state.id, oid = i => id + '-o' + i, lid = id + '-l';
71
+ const op = o => set({ open: true, id: id || 'zg-cb' + (++cbN), ...o }), pickO = o => { set({ open: false, q: '', ai: -1 }); p.onChange && p.onChange(o.value); };
72
+ const step = d => { const ok = f.map((o, i) => i).filter(i => !f[i].disabled); if (!ok.length) return; const k = ok.indexOf(state.ai); op({ ai: ok[k < 0 ? (d > 0 ? 0 : ok.length - 1) : (k + d + ok.length) % ok.length] }); };
73
+ const keys = e => { if (e.altKey || e.ctrlKey || e.metaKey || p.disabled) return;
74
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!open) { const ok = f.map((o, i) => i).filter(i => !f[i].disabled), i = f.findIndex(o => o.value === p.modelValue && !o.disabled); op({ q: '', ai: i >= 0 ? i : ok.length ? (e.key === 'ArrowDown' ? ok[0] : ok[ok.length - 1]) : -1 }); } else step(e.key === 'ArrowDown' ? 1 : -1); }
75
+ else if (e.key === 'Enter' && open && f[state.ai] && !f[state.ai].disabled) { e.preventDefault(); pickO(f[state.ai]); } };
76
+ return lab(h, p, h('div', { class: 'zg-select' }, field(h, p, icon(h, 'search', 16), h('input', { role: 'combobox', 'aria-autocomplete': 'list', 'aria-haspopup': 'listbox', 'aria-expanded': String(open), 'aria-controls': open ? lid : undefined, 'aria-activedescendant': open && f[state.ai] ? oid(state.ai) : undefined, autoComplete: 'off', value: open ? state.q : (cur ? cur.label : ''), disabled: p.disabled, placeholder: p.placeholder || t('Combobox', 'placeholder'), onFocus: () => op({ q: '', ai: -1 }), onKeydown: keys, onInput: e => op({ q: e.target.value, ai: -1 }) }), [p.clearable && cur && !p.disabled ? h('button', { key: 'x', type: 'button', class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('Combobox', 'clear'), onMouseDown: e => e.preventDefault(), onClick: () => { set({ open: false, q: '', ai: -1 }); p.onChange && p.onChange(null); } }, icon(h, 'x', 14)) : null, icon(h, 'chevron-down', 16)]),
77
+ open ? h('div', { id: lid, class: 'zg-select__list', role: 'listbox', 'aria-label': p.label || p.placeholder, 'data-zg-dd': '', ref: ddRef(() => set({ open: false, q: '', ai: -1 })) }, ...(f.length ? f.map((o, i) => h('button', { key: o.value, id: oid(i), type: 'button', role: 'option', tabIndex: -1, class: 'zg-select__opt', 'data-active': i === state.ai ? '' : undefined, ref: i === state.ai ? tipScroll : undefined, disabled: o.disabled, 'aria-disabled': o.disabled ? 'true' : undefined, 'aria-selected': String(o.value === p.modelValue), onMouseDown: e => e.preventDefault(), onClick: () => pickO(o) }, o.label)) : [h('div', { key: 'e', class: 'zg-empty' }, p.emptyText || '—')])) : null)); } },
68
78
  MultiSelect: { state: { open: false }, render: ({ h, props: p, state, set }) => { const v = p.modelValue || [], tog = x => p.onChange && p.onChange(v.includes(x) ? v.filter(i => i !== x) : [...v, x]);
69
- return h('div', { class: 'zg-field' }, p.label ? h('span', { class: 'zg-label' }, p.label) : null, h('div', { class: 'zg-select' }, h('button', { type: 'button', class: 'zg-btn zg-select__trigger', 'aria-haspopup': 'listbox', 'aria-expanded': String(state.open && !p.disabled), disabled: p.disabled, style: { height: 'var(--zg-h-lg)' }, onClick: () => set({ open: !state.open }) }, h('span', null, v.length ? t('MultiSelect', 'selected', { n: v.length }) : (p.placeholder || '—')), icon(h, 'chevron-down', 16)),
70
- state.open && !p.disabled ? h('div', { class: 'zg-select__list', role: 'listbox', 'aria-label': p.label || p.placeholder, 'aria-multiselectable': 'true', 'data-zg-dd': '', ref: ddRef(() => set({ open: false })) }, ...(p.options || []).map(o => h('button', { key: o.value, type: 'button', role: 'option', class: 'zg-select__opt', disabled: o.disabled, 'aria-disabled': o.disabled ? 'true' : undefined, 'aria-selected': String(v.includes(o.value)), onClick: () => tog(o.value) }, h('span', { class: 'zg-check__box', style: { width: '18px', height: '18px', borderRadius: '6px', background: v.includes(o.value) ? 'var(--zg-brand)' : 'transparent' } }, v.includes(o.value) ? icon(h, 'check', 12) : null), h('span', { style: { flex: 1, marginInlineStart: '10px' } }, o.label)))) : null)); } },
79
+ return h('div', { class: 'zg-field' }, p.label ? h('span', { class: 'zg-label' }, p.label) : null, h('div', { class: 'zg-select' }, h('button', { type: 'button', class: 'zg-btn zg-select__trigger', 'aria-haspopup': 'listbox', 'aria-expanded': String(state.open && !p.disabled), disabled: p.disabled, style: { height: 'var(--zg-h-lg)' }, onClick: () => set({ open: !state.open }), onKeydown: openKeys(state.open && !p.disabled, () => set({ open: true })) }, h('span', null, v.length ? t('MultiSelect', 'selected', { n: v.length }) : (p.placeholder || '—')), icon(h, 'chevron-down', 16)),
80
+ state.open && !p.disabled ? h('div', { class: 'zg-select__list', role: 'listbox', 'aria-label': p.label || p.placeholder, 'aria-multiselectable': 'true', 'data-zg-dd': '', ref: ddRef(() => set({ open: false }), { focus: true }), onKeydown: listKeys('[role=option]', { onTab: e => { const t = trigger(e.currentTarget); t && t.focus(); set({ open: false }); } }) }, ...(p.options || []).map((o, i, all) => h('button', { key: o.value, type: 'button', role: 'option', tabIndex: tabIdx(all, x => v.includes(x.value))(i), class: 'zg-select__opt', disabled: o.disabled, 'aria-disabled': o.disabled ? 'true' : undefined, 'aria-selected': String(v.includes(o.value)), onClick: () => tog(o.value) }, h('span', { class: 'zg-check__box', style: { width: '18px', height: '18px', borderRadius: '6px', background: v.includes(o.value) ? 'var(--zg-brand)' : 'transparent' } }, v.includes(o.value) ? icon(h, 'check', 12) : null), h('span', { style: { flex: 1, marginInlineStart: '10px' } }, o.label)))) : null)); } },
71
81
  PhoneInput: { render: ({ C, h, props: p }) => { const CC = p.countries || [{ value: '+966', label: 'SA +966' }, { value: '+998', label: 'UZ +998' }, { value: '+7', label: 'RU +7' }, { value: '+90', label: 'TR +90' }, { value: '+20', label: 'EG +20' }], v = p.modelValue || {};
72
82
  return h('div', { class: 'zg-field' }, p.label ? h('span', { class: 'zg-label' }, p.label) : null, h('div', { style: { display: 'grid', gridTemplateColumns: '130px minmax(0,1fr)', gap: '8px', alignItems: 'start' } }, h(C.Select, { options: CC, modelValue: v.code || CC[0].value, onChange: c => p.onChange && p.onChange({ ...v, code: c }) }), field(h, p, icon(h, 'phone', 16), h('input', { type: 'tel', inputMode: 'tel', value: v.number || '', placeholder: p.placeholder ?? '90 123 45 67', onInput: e => p.onChange && p.onChange({ ...v, number: e.target.value }) }))), typeof p.error === 'string' && p.error ? h('span', { class: 'zg-hint zg-hint--error', role: 'alert' }, p.error) : p.hint ? h('span', { class: 'zg-hint' }, p.hint) : null); } },
73
- Calendar: { state: { ym: null }, render: ({ h, props: p, state, set }) => { const s = pd(p.modelValue), b = state.ym ? new Date(state.ym) : (s || new Date()), y = b.getFullYear(), m = b.getMonth(), loc = dloc(p), ws = p.weekStartsOn ?? 1, off = (new Date(y, m, 1).getDay() - ws + 7) % 7, dn = new Date(y, m + 1, 0).getDate(), today = new Date();
83
+ Calendar: { state: { ym: null, fd: null, today: null }, render: ({ h, props: p, state, set }) => { const s = pd(p.modelValue), now = pd(p.today) || (state.today ? new Date(state.today) : null), b = state.ym ? new Date(state.ym) : (s || now || new Date()), y = b.getFullYear(), m = b.getMonth(), loc = dloc(p), ws = p.weekStartsOn ?? 1, off = (new Date(y, m, 1).getDay() - ws + 7) % 7, dn = new Date(y, m + 1, 0).getDate(), today = now, ml = new Intl.DateTimeFormat(loc, { month: 'long', year: 'numeric' }).format(b);
74
84
  const wd = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(loc, { weekday: 'narrow' }).format(new Date(2023, 0, 1 + ((i + ws) % 7))));
75
- const same = (a, d) => a && a.getFullYear() === y && a.getMonth() === m && a.getDate() === d, ev = p.events || {}, lo = pd(p.min), hi = pd(p.max), day0 = x => new Date(x.getFullYear(), x.getMonth(), x.getDate()), blocked = d => { const t = new Date(y, m, d); return !!((lo && t < day0(lo)) || (hi && t > day0(hi)) || (p.disabledDate && p.disabledDate(t))); };
76
- return h('div', { class: 'zg-cal' }, h('div', { class: 'zg-cal__h' }, h('button', { type: 'button', class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('Calendar', 'prev'), onClick: () => set({ ym: new Date(y, m - 1, 1).getTime() }) }, icon(h, 'chevron-left', 16)), h('b', null, new Intl.DateTimeFormat(loc, { month: 'long', year: 'numeric' }).format(b)), h('button', { type: 'button', class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('Calendar', 'next'), onClick: () => set({ ym: new Date(y, m + 1, 1).getTime() }) }, icon(h, 'chevron-right', 16))),
77
- h('div', { class: 'zg-cal__g' }, ...wd.map((d, i) => h('span', { key: 'w' + i, class: 'zg-cal__w' }, d)), ...Array.from({ length: off }, (_, i) => h('span', { key: 'o' + i })), ...Array.from({ length: dn }, (_, i) => { const d = i + 1, key = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; return h('button', { key: d, type: 'button', class: 'zg-cal__d', 'aria-pressed': String(!!same(s, d)), 'data-today': String(same(today, d)), 'data-ev': ev[key] ? '' : undefined, title: ev[key] || undefined, disabled: blocked(d) || undefined, onClick: () => !blocked(d) && p.onChange && p.onChange(new Date(y, m, d)) }, d); }))); } },
78
- DatePicker: { state: { open: false }, render: ({ C, h, props: p, state, set }) => lab(h, p, h('div', { class: 'zg-select' }, h('button', { type: 'button', class: 'zg-btn zg-select__trigger', 'aria-haspopup': 'dialog', 'aria-expanded': String(state.open && !p.disabled), disabled: p.disabled, style: { height: 'var(--zg-h-lg)' }, onClick: () => set({ open: !state.open }) }, h('span', { style: { display: 'flex', gap: '10px', alignItems: 'center' } }, icon(h, 'calendar', 16), p.modelValue ? new Intl.DateTimeFormat(dloc(p), { dateStyle: 'medium' }).format(pd(p.modelValue) || new Date()) : (p.placeholder || t('DatePicker', 'placeholder'))), h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, p.clearable && p.modelValue && !p.disabled ? h('span', { role: 'button', tabIndex: 0, class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('DatePicker', 'clear'), onClick: e => { e.stopPropagation(); p.onChange && p.onChange(null); }, onKeydown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); p.onChange && p.onChange(null); } } }, icon(h, 'x', 14)) : null, icon(h, 'chevron-down', 16))),
79
- state.open && !p.disabled ? h('div', { class: 'zg-select__list', 'data-zg-dd': '', ref: ddRef(() => set({ open: false })), style: { padding: '12px' } }, h(C.Calendar, { modelValue: p.modelValue, locale: p.locale, weekStartsOn: p.weekStartsOn, min: p.min, max: p.max, disabledDate: p.disabledDate, onChange: d => { set({ open: false }); p.onChange && p.onChange(d); } })) : null)) },
85
+ const same = (a, d) => a && a.getFullYear() === y && a.getMonth() === m && a.getDate() === d, ev = p.events || {}, lo = pd(p.min), hi = pd(p.max), day0 = x => new Date(x.getFullYear(), x.getMonth(), x.getDate()), blockedAt = t => !!((lo && t < day0(lo)) || (hi && t > day0(hi)) || (p.disabledDate && p.disabledDate(t))), blocked = d => blockedAt(new Date(y, m, d));
86
+ const mk = d => dateKey(new Date(y, m, d)), fk = state.fd && state.fd.slice(0, 7) === mk(1).slice(0, 7) ? +state.fd.slice(8) : 0;
87
+ const fd = [fk, same(s, s && s.getDate()) ? s.getDate() : 0, same(today, today && today.getDate()) ? today.getDate() : 0, 1].find(d => d && !blocked(d)) || Array.from({ length: dn }, (_, i) => i + 1).find(d => !blocked(d));
88
+ return h('div', { class: 'zg-cal', ref: p.today == null && state.today == null ? (el => { if (el) Promise.resolve().then(() => set({ today: Date.now() })); }) : undefined }, h('div', { class: 'zg-cal__h' }, h('button', { type: 'button', class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('Calendar', 'prev'), onClick: () => set({ ym: new Date(y, m - 1, 1).getTime() }) }, icon(h, 'chevron-left', 16)), h('b', { 'aria-live': 'polite' }, ml), h('button', { type: 'button', class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('Calendar', 'next'), onClick: () => set({ ym: new Date(y, m + 1, 1).getTime() }) }, icon(h, 'chevron-right', 16))),
89
+ h('div', { class: 'zg-cal__g', role: 'group', 'aria-label': ml, onKeydown: calKeys({ ws, blocked: blockedAt, show: (t, k) => set({ ym: new Date(t.getFullYear(), t.getMonth(), 1).getTime(), fd: k }) }) }, ...wd.map((d, i) => h('span', { key: 'w' + i, class: 'zg-cal__w' }, d)), ...Array.from({ length: off }, (_, i) => h('span', { key: 'o' + i })), ...Array.from({ length: dn }, (_, i) => { const d = i + 1, key = mk(d); return h('button', { key, type: 'button', class: 'zg-cal__d', tabIndex: d === fd ? 0 : -1, 'data-date': key, 'aria-pressed': String(!!same(s, d)), 'aria-current': same(today, d) ? 'date' : undefined, 'data-today': String(!!same(today, d)), 'data-ev': ev[key] ? '' : undefined, title: ev[key] || undefined, disabled: blocked(d) || undefined, onFocus: () => state.fd !== key && set({ fd: key }), onClick: e => { if (blocked(d)) return; if (e.currentTarget.closest('[data-zg-pick]')) toTrigger(e); p.onChange && p.onChange(new Date(y, m, d)); } }, d); }))); } },
90
+ DatePicker: { state: { open: false }, render: ({ C, h, props: p, state, set }) => lab(h, p, h('div', { class: 'zg-select' }, h('button', { type: 'button', class: 'zg-btn zg-select__trigger', 'aria-haspopup': 'dialog', 'aria-expanded': String(state.open && !p.disabled), disabled: p.disabled, style: { height: 'var(--zg-h-lg)' }, onClick: () => set({ open: !state.open }), onKeydown: openKeys(state.open && !p.disabled, () => set({ open: true })) }, h('span', { style: { display: 'flex', gap: '10px', alignItems: 'center' } }, icon(h, 'calendar', 16), p.modelValue ? new Intl.DateTimeFormat(dloc(p), { dateStyle: 'medium' }).format(pd(p.modelValue) || new Date()) : (p.placeholder || t('DatePicker', 'placeholder'))), h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, p.clearable && p.modelValue && !p.disabled ? h('span', { role: 'button', tabIndex: 0, class: 'zg-ibtn zg-ibtn--sm zg-ibtn--ghost', 'aria-label': t('DatePicker', 'clear'), onClick: e => { e.stopPropagation(); p.onChange && p.onChange(null); }, onKeydown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); p.onChange && p.onChange(null); } } }, icon(h, 'x', 14)) : null, icon(h, 'chevron-down', 16))),
91
+ state.open && !p.disabled ? h('div', { class: 'zg-select__list', role: 'dialog', 'aria-label': p.label || p.placeholder || t('DatePicker', 'placeholder'), 'data-zg-dd': '', 'data-zg-pick': '', ref: ddRef(() => set({ open: false }), { focus: true }), style: { padding: '12px' }, onFocusout: e => { const r = e.currentTarget.parentElement; if (e.relatedTarget && r && !r.contains(e.relatedTarget)) set({ open: false }); } }, h(C.Calendar, { modelValue: p.modelValue, locale: p.locale, weekStartsOn: p.weekStartsOn, min: p.min, max: p.max, today: p.today, disabledDate: p.disabledDate, onChange: d => { set({ open: false }); p.onChange && p.onChange(d); } })) : null)) },
80
92
  TimePicker: { render: ({ C, h, props: p }) => { const [H0, M0] = (p.modelValue || '09:00').split(':'), H = String(H0 ?? '00').padStart(2, '0'), M = String(M0 ?? '00').padStart(2, '0'); const hs = Array.from({ length: 24 }, (_, i) => { const v = String(i).padStart(2, '0'); return { value: v, label: v }; }), st = Math.max(1, Math.min(60, Math.floor(+p.minuteStep) || 15)), mv = Array.from({ length: Math.ceil(60 / st) }, (_, i) => String(i * st).padStart(2, '0')); if (!mv.includes(M)) { mv.push(M); mv.sort(); } const ms = mv.map(v => ({ value: v, label: v }));
81
93
  return h('div', { class: 'zg-field' }, p.label ? h('span', { class: 'zg-label' }, p.label) : null, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } }, h('div', { style: { flex: 1 } }, h(C.Select, { options: hs, disabled: p.disabled, modelValue: H, onChange: v => p.onChange && p.onChange(v + ':' + M) })), h('b', null, ':'), h('div', { style: { flex: 1 } }, h(C.Select, { options: ms, disabled: p.disabled, modelValue: M, onChange: v => p.onChange && p.onChange(H + ':' + v) })))); } },
82
94
  FormField: { render: ({ h, props: p, children }) => h('div', { class: 'zg-field' }, p.label ? h('span', { class: 'zg-label' }, p.label, p.required ? h('span', { style: { color: 'var(--zg-red)' } }, ' *') : null) : null, ...children, p.error ? h('span', { class: 'zg-hint zg-hint--error' }, p.error) : p.hint ? h('span', { class: 'zg-hint' }, p.hint) : null) },
package/src/core/keys.js CHANGED
@@ -53,3 +53,40 @@ export const openKeys = (isOpen, open) => e => { if (!isOpen && !mod(e) && (e.ke
53
53
 
54
54
  /** Focus the entry point of a freshly opened popup: the item with tabindex 0, else the first one. */
55
55
  export const focusIn = el => { const t = el.querySelector('[tabindex="0"]') || el.querySelector('button:not([disabled])'); if (t && t.focus) t.focus({ preventScroll: true }); };
56
+
57
+ const pad2 = n => String(n).padStart(2, '0');
58
+ /** YYYY-MM-DD of a local date, the value of a day cell's data-date attribute. */
59
+ export const dateKey = d => d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
60
+ /**
61
+ * Month grid of day buttons carrying data-date="YYYY-MM-DD" and a roving tabindex. Put it on the grid element.
62
+ * Arrows move one day (Left/Right mirrored in RTL) or one week, Home/End jump to the start/end of the week, PageUp/PageDown change month (Shift: year).
63
+ * Blocked days are skipped. When the target day is in another month, show(date, key) must make that month visible; the cell is focused once it renders.
64
+ */
65
+ export const calKeys = o => e => {
66
+ if (mod(e)) return;
67
+ const root = e.currentTarget, cell = e.target.closest && e.target.closest('[data-date]');
68
+ if (!cell) return;
69
+ const [Y, M, D] = cell.getAttribute('data-date').split('-').map(Number), ws = o.ws ?? 1, rtl = isRtl(root), day = (n) => new Date(Y, M - 1, D + n);
70
+ const month = n => { const f = new Date(Y, M - 1 + n, 1); return new Date(f.getFullYear(), f.getMonth(), Math.min(D, new Date(f.getFullYear(), f.getMonth() + 1, 0).getDate())); };
71
+ const wk = (new Date(Y, M - 1, D).getDay() - ws + 7) % 7;
72
+ let t, unit = 1;
73
+ switch (e.key) {
74
+ case 'ArrowRight': t = day(rtl ? -1 : 1); unit = rtl ? -1 : 1; break;
75
+ case 'ArrowLeft': t = day(rtl ? 1 : -1); unit = rtl ? 1 : -1; break;
76
+ case 'ArrowDown': t = day(7); unit = 7; break;
77
+ case 'ArrowUp': t = day(-7); unit = -7; break;
78
+ case 'Home': t = day(-wk); break;
79
+ case 'End': t = day(6 - wk); unit = -1; break;
80
+ case 'PageUp': t = month(e.shiftKey ? -12 : -1); break;
81
+ case 'PageDown': t = month(e.shiftKey ? 12 : 1); break;
82
+ default: return;
83
+ }
84
+ e.preventDefault();
85
+ for (let i = 0; i < 62 && o.blocked && o.blocked(t); i++) t = new Date(t.getFullYear(), t.getMonth(), t.getDate() + unit);
86
+ if (o.blocked && o.blocked(t)) return;
87
+ const k = dateKey(t), find = () => root.querySelector('[data-date="' + k + '"]:not(:disabled)');
88
+ const el = find();
89
+ if (el) { el.focus(); return; }
90
+ o.show(t, k);
91
+ let n = 0; const tick = () => { const x = find(); if (x) x.focus(); else if (++n < 12) setTimeout(tick, 16); }; setTimeout(tick, 0);
92
+ };
package/src/internal.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // It re-exports the helpers the component definitions are built from, so a plugin does not copy them.
3
3
  export { cx, pick, INPUT_ATTRS, DZ, dzp, dzf, dzb, ddRef, icon, fmt } from './core/components.js';
4
4
  export { trap, life } from './core/dialog.js';
5
- export { rove, tabIdx, listKeys, openKeys, focusIn, toTrigger, trigger } from './core/keys.js';
5
+ export { rove, tabIdx, listKeys, openKeys, focusIn, toTrigger, trigger, calKeys, dateKey } from './core/keys.js';
6
6
  export { rules } from './core/components4.js';
7
7
  export { page } from './core/pages.js';
8
8
  export { t, getUiLocale, intlLocale, defineMessages } from './i18n/index.js';
package/src/meta/data.js CHANGED
@@ -6,6 +6,33 @@ const TONE = "'brand' | 'sky' | 'success' | 'warning' | 'danger' | 'neutral'";
6
6
  const size = (d, extra) => ({ type: SIZE, values: SZ, default: d || 'md', description: extra || 'Component scale.' });
7
7
  const delta = (d) => ({ type: 'string', description: d || 'Change indicator such as "+12%" or "-3.4%". A leading "-" renders as a decrease, anything else as an increase.' });
8
8
 
9
+ const SERIES = '{ key: string; label: string; values: { x: string | number | Date; y: number | null }[]; tone?: \'brand\' | \'success\' | \'warning\' | \'danger\' | \'info\' | \'neutral\'; dashed?: boolean }[]';
10
+ const CHART_TONE = "'brand' | 'success' | 'warning' | 'danger' | 'info' | 'neutral'";
11
+ const chartCommon = (kind) => ({
12
+ unit: { type: "'count' | 'currency' | 'percent'", values: ['count', 'currency', 'percent'], default: 'count', description: 'Number format of the value axis, tooltip and ' + (kind === 'donut' ? 'centre total' : 'legend') + '. count: grouped digits, compact on the axis (1.2k, 1.2M, localized by Intl); currency: needs currency; percent: values are percent points (12 means 12%).' },
13
+ currency: { type: 'string', description: 'ISO 4217 code for unit="currency", for example UZS, SAR or USD. The axis stays compact without the symbol; tooltip and legend show the full amount.' },
14
+ yFormat: { type: '(n: number) => string', description: 'Custom formatter for every number (axis, tooltip' + (kind === 'donut' ? ', total' : '') + '). Overrides unit and currency.' },
15
+ locale: { type: 'string', description: 'BCP 47 tag for numbers and dates, for example "ar" or "uz-UZ". Defaults to the kit locale (en ru uz ar tr and the rest). An RTL language also mirrors the chart.' },
16
+ dir: { type: "'ltr' | 'rtl'", values: ['ltr', 'rtl'], description: 'Text direction. Defaults to the direction of the page (RTL mirrors the axis, the plot and the tooltip).' },
17
+ label: { type: 'string', description: 'Accessible name, for example "Revenue". The generated summary (series, period, minimum, maximum, last value) follows it in the aria-label.' },
18
+ height: { type: 'number', description: kind === 'donut' ? 'Diameter in px. Overrides size.' : 'Chart height in px (without the legend). Overrides size. Horizontal bars grow with the number of categories when it is not set.' },
19
+ size: { type: SIZE, values: SZ, default: 'md', description: kind === 'donut' ? 'Preset diameter: xs 120, sm 160, md 200, lg 240, xl 300 px.' : 'Preset height: xs 140, sm 180, md 240, lg 320, xl 400 px.' },
20
+ loading: { type: 'boolean', default: false, description: 'Shows a skeleton in the shape of the chart.' },
21
+ emptyText: { type: 'string', description: 'Text of the empty state. Default is the translated "No data for this period".' },
22
+ emptyIcon: { type: 'string', description: 'Icon name shown above the empty text. None by default.' },
23
+ legend: { type: 'boolean', default: kind === 'donut' ? true : 'true when there is more than one series', description: 'Text legend of toggle buttons (aria-pressed). A button hides and shows its ' + (kind === 'donut' ? 'slice' : 'series') + '; the scale is recomputed.' },
24
+ });
25
+ const chartEvents = (kind) => ({
26
+ pointClick: { line: 'Click or Enter on a point. Called with { seriesKey, series, index, x, label, y }: the series under the pointer (or the keyboard series), the index in the shared x list, the original x value, its formatted label and y.', donut: 'Click or Enter on a slice. Called with { key, label, value, share, index }; share is 0 to 1.' }[kind === 'donut' ? 'donut' : 'line'],
27
+ legendToggle: 'A legend button was pressed. Called with { key, label, hidden } where hidden is the new state.',
28
+ });
29
+ const chartNotes = [
30
+ 'Pure inline SVG, no dependencies. Colors come from the --zg-chart-* tokens (light, dark, gold and tamam), so there is nothing to configure per theme.',
31
+ 'Empty data (no points, only null values, or only zeros for bars and donut) shows the empty state instead of an axis of zeros. A null y is a gap in the line, never a zero. All values equal: the axis starts at 0 and leaves headroom.',
32
+ 'Accessibility: the plot is one focusable role="img" with a generated aria-label, a visually hidden data table, and a live region that reads the point while you move. Keys: Left and Right (mirrored in RTL) move between points, Up and Down between series, Home and End jump, Escape hides the tooltip, Enter fires pointClick.',
33
+ 'The chart fills the width of its container (ResizeObserver, client only) and renders at 640 px on the server.',
34
+ ];
35
+
9
36
  export default {
10
37
  Card: {
11
38
  description: 'Glass surface container with an optional title header, used to group related content.',
@@ -212,6 +239,56 @@ export default {
212
239
  events: { menu: 'Called with the clicked menu item label (the string from menu); the menu closes afterwards.' },
213
240
  },
214
241
 
242
+ LineChart: {
243
+ description: 'Line chart for time series with several series, a localized time axis with automatic label step, "nice" value ticks, gaps for null values, tooltip and keyboard navigation.',
244
+ props: {
245
+ series: { type: SERIES, required: true, description: 'Series to draw. x is a Date, an ISO date string (day, week, month are detected from the spacing), a number or a category string; the series share one x list, a missing x or a null y is a gap. tone is brand, success, warning, danger, info or neutral; without it the series palette (--zg-chart-1 to 6) is used. dashed draws the line dashed, for example the previous week.' },
246
+ ...chartCommon('line'),
247
+ yMin: { type: 'number', description: 'Fixed lower end of the value axis (otherwise nice ticks from the data, from 0 unless the data stays far above it).' },
248
+ yMax: { type: 'number', description: 'Fixed upper end of the value axis.' },
249
+ showPoints: { type: 'boolean', description: 'Dots on every point. Default: on up to 40 points; isolated points (between gaps) always get a dot.' },
250
+ },
251
+ events: chartEvents('line'),
252
+ notes: chartNotes,
253
+ },
254
+
255
+ AreaChart: {
256
+ description: 'Line chart with a gradient fill under each line, made from the series color; same data, axes, tooltip and keyboard behavior as LineChart.',
257
+ props: {
258
+ series: { type: SERIES, required: true, description: 'Series to draw, see LineChart. The fill goes down to the zero line (or the bottom of the plot when the axis does not include 0); each run between gaps is filled separately.' },
259
+ ...chartCommon('area'),
260
+ yMin: { type: 'number', description: 'Fixed lower end of the value axis.' },
261
+ yMax: { type: 'number', description: 'Fixed upper end of the value axis.' },
262
+ showPoints: { type: 'boolean', description: 'Dots on every point. Default: on up to 40 points; isolated points always get a dot.' },
263
+ },
264
+ events: chartEvents('line'),
265
+ notes: chartNotes,
266
+ },
267
+
268
+ BarChart: {
269
+ description: 'Vertical or horizontal bar chart, grouped or stacked, for categories such as days, 24 hours or revenue per branch; same axes, formats and accessibility as LineChart.',
270
+ props: {
271
+ series: { type: SERIES, required: true, description: 'One entry per bar series. x is the category (string, number or Date); categories keep their order of first appearance, dates are sorted. Negative values draw below the zero line.' },
272
+ orientation: { type: "'vertical' | 'horizontal'", values: ['vertical', 'horizontal'], default: 'vertical', description: 'Horizontal puts the categories on the left (right in RTL) and grows with the number of categories.' },
273
+ stacked: { type: 'boolean', default: false, description: 'Stacks the series of a category (positives and negatives stack apart) instead of placing them side by side; the tooltip adds a total.' },
274
+ ...chartCommon('bar'),
275
+ yMin: { type: 'number', description: 'Fixed lower end of the value axis.' },
276
+ yMax: { type: 'number', description: 'Fixed upper end of the value axis.' },
277
+ },
278
+ events: chartEvents('line'),
279
+ notes: chartNotes,
280
+ },
281
+
282
+ DonutChart: {
283
+ description: 'Donut chart of shares (sales channels, payment methods, plans) with a text legend, the total in the centre and a legend toggle.',
284
+ props: {
285
+ data: { type: "{ key: string; label: string; value: number; tone?: " + CHART_TONE + " }[]", required: true, description: 'Slices. Only positive values draw; others keep their legend row at 0. All zero or empty shows the empty state.' },
286
+ ...chartCommon('donut'),
287
+ },
288
+ events: chartEvents('donut'),
289
+ notes: chartNotes,
290
+ },
291
+
215
292
  Ring: {
216
293
  description: 'Circular progress gauge with a percentage in the center and a tick scale around it.',
217
294
  props: {
package/src/meta/forms.js CHANGED
@@ -207,7 +207,7 @@ export default {
207
207
  size: size(),
208
208
  },
209
209
  events: change('Called with the value of the chosen option.'),
210
- notes: ['Selecting from the list is the only way to set a value: free text is never emitted.', 'The list closes on outside click and on Escape.'],
210
+ notes: ['Selecting from the list is the only way to set a value: free text is never emitted.', 'The list closes on outside click and on Escape.', 'Keyboard (WAI-ARIA combobox): the input has role=combobox with aria-expanded, aria-controls and aria-activedescendant; focus stays in the input, Arrow Down and Up open the list and move the active option (wraps, skips disabled), Enter chooses it, Escape closes.'],
211
211
  },
212
212
  TreeSelect: {
213
213
  description: 'Dropdown with a searchable tree. Use for hierarchical choices such as regions, categories or org units.',
@@ -359,11 +359,12 @@ export default {
359
359
  min: { type: 'Date | string | number', description: 'Earliest selectable day; earlier days are disabled.' },
360
360
  max: { type: 'Date | string | number', description: 'Latest selectable day; later days are disabled.' },
361
361
  disabledDate: { type: '(date: Date) => boolean', description: 'Return true to disable a specific day.' },
362
+ today: { type: 'Date | string | number', description: 'The day marked as today. By default it is read from the browser clock after mount (nothing is marked in server-rendered HTML), so server and client never disagree near midnight or across time zones. Pass it to mark a day in the HTML too.' },
362
363
  events: { type: 'Record<string, string>', default: '{}', description: 'Marked days: keys are YYYY-MM-DD, values are the tooltip text.' },
363
364
  size: size('Day cell and header size.'),
364
365
  },
365
366
  events: change('Called with a Date object (local time) for the clicked day.'),
366
- notes: ['The event payload is a Date, not a string: convert to ISO yourself if the model is a string.'],
367
+ notes: ['The event payload is a Date, not a string: convert to ISO yourself if the model is a string.', 'Keyboard: one tab stop on the day grid; arrows move by day or week, Home and End to the week start and end, PageUp and PageDown change month (Shift: year), Enter or Space picks. Without a selected date the visible month is the current month, which server and client can disagree on right at a month boundary; pass modelValue or today to make it deterministic.'],
367
368
  },
368
369
  RangeCalendar: {
369
370
  description: 'Month grid for picking a date range with hover preview. Use for stays, trips and report periods.',
@@ -387,6 +388,7 @@ export default {
387
388
  min: { type: 'Date | string | number', forwarded: true, description: 'Earliest selectable day, forwarded to the calendar.' },
388
389
  max: { type: 'Date | string | number', forwarded: true, description: 'Latest selectable day, forwarded to the calendar.' },
389
390
  disabledDate: { type: '(date: Date) => boolean', forwarded: true, description: 'Return true to disable a specific day, forwarded to the calendar.' },
391
+ today: { type: 'Date | string | number', forwarded: true, description: 'The day marked as today in the calendar; by default read from the browser clock after mount. Forwarded to the calendar.' },
390
392
  required: { type: 'boolean', default: false, forwarded: true, description: 'Adds a red asterisk to the label. Does not validate.' },
391
393
  label, hint, error,
392
394
  placeholder: placeholder('Shown when no date is set. Defaults to "Select date".'),
@@ -394,7 +396,7 @@ export default {
394
396
  size: size(),
395
397
  },
396
398
  events: change('Called with a Date object (local midnight) for the chosen day. The popup closes after picking.'),
397
- notes: ['The event payload is a Date, not a string: convert to ISO yourself if the model is a string.', 'The popup closes on outside click and on Escape.'],
399
+ notes: ['The event payload is a Date, not a string: convert to ISO yourself if the model is a string.', 'The popup closes on outside click and on Escape (focus returns to the trigger). Arrow Down or Up on the trigger opens it and focus moves into the calendar, which uses the Calendar keys.'],
398
400
  },
399
401
  TimePicker: {
400
402
  description: 'Hour and minute selects for a time of day. Use for opening hours and appointment times.',
@@ -4,7 +4,7 @@ export const GROUPS = [
4
4
  { id: 'layout', title: 'Layout', blurb: 'Stacks, grids and page structure.', names: ['Box', 'Center', 'HStack', 'VStack', 'Grid', 'Divider', 'ScrollArea', 'SplitLayout', 'AppShell', 'PageHeader', 'AuthLayout', 'SettingsSection', 'LiquidGlass', 'Footer', 'Sortable', 'Portal'] },
5
5
  { id: 'actions', title: 'Actions', blurb: 'Buttons and things you press.', names: ['Button', 'IconButton', 'Pressable', 'Fab', 'CopyButton', 'ToggleGroup', 'SegmentedControl', 'FilterChips', 'Chip', 'Tag', 'Badge', 'ActionRail', 'ActionCard', 'NotificationBell'] },
6
6
  { id: 'forms', title: 'Forms', blurb: 'Inputs, pickers and form building blocks.', names: ['TextField', 'Textarea', 'NumberInput', 'PasswordInput', 'SearchInput', 'PhoneInput', 'CodeInput', 'ChipInput', 'InlineEdit', 'RichEditor', 'Select', 'MultiSelect', 'GroupedSelect', 'Combobox', 'TreeSelect', 'PillSelect', 'Checkbox', 'Switch', 'RadioGroup', 'OptionCard', 'OptionCards', 'Slider', 'RangeSlider', 'Rating', 'ColorPicker', 'Calendar', 'RangeCalendar', 'DatePicker', 'TimePicker', 'FileUpload', 'ImageUpload', 'FormField', 'ValidatedField', 'LoginForm', 'StepForm', 'Stepper'] },
7
- { id: 'data', title: 'Data display', blurb: 'Tables, stats, charts and lists.', names: ['Card', 'DataTable', 'DataGroup', 'Descriptions', 'Timeline', 'TreeView', 'CalendarView', 'KpiTile', 'KpiInline', 'StatsCard', 'StatRow', 'StatLine', 'StatStrip', 'CountryRow', 'VersusBar', 'SegBar', 'MiniChart', 'ChartCard', 'Ring', 'OccupancyChart', 'SalesFunnel', 'SystemStatus', 'UserCell', 'IconTile', 'StatusIcon', 'HeroBanner', 'TabTiles', 'PricingCard', 'IntegrationCard'] },
7
+ { id: 'data', title: 'Data display', blurb: 'Tables, stats, charts and lists.', names: ['Card', 'DataTable', 'DataGroup', 'Descriptions', 'Timeline', 'TreeView', 'CalendarView', 'KpiTile', 'KpiInline', 'StatsCard', 'StatRow', 'StatLine', 'StatStrip', 'CountryRow', 'VersusBar', 'SegBar', 'MiniChart', 'ChartCard', 'LineChart', 'AreaChart', 'BarChart', 'DonutChart', 'Ring', 'OccupancyChart', 'SalesFunnel', 'SystemStatus', 'UserCell', 'IconTile', 'StatusIcon', 'HeroBanner', 'TabTiles', 'PricingCard', 'IntegrationCard'] },
8
8
  { id: 'feedback', title: 'Feedback', blurb: 'Alerts, progress and states.', names: ['Alert', 'Banner', 'Toast', 'Snackbar', 'Progress', 'Spinner', 'Skeleton', 'EmptyState', 'ErrorPage', 'StatusPage', 'DataState', 'LoadingOverlay'] },
9
9
  { id: 'overlay', title: 'Overlay', blurb: 'Dialogs, sheets and popovers.', names: ['Modal', 'Drawer', 'ConfirmDialog', 'Popconfirm', 'StepDialog', 'FullscreenDialog', 'BottomSheet', 'Actionsheet', 'Popover', 'HoverCard', 'Tooltip', 'Dropdown', 'Menu', 'ContextMenu', 'CommandBar', 'CommandPalette', 'ImageViewer', 'Tour', 'TourPopover', 'Screensaver'] },
10
10
  { id: 'navigation', title: 'Navigation', blurb: 'Menus, tabs and paging.', names: ['TopNav', 'SideNav', 'SubNav', 'Tabs', 'PillTabs', 'Breadcrumbs', 'Pagination', 'Accordion'] },
@@ -155,7 +155,7 @@ export default {
155
155
  size: size('bubble padding and font'),
156
156
  },
157
157
  slots: { default: 'The element the tooltip is attached to.' },
158
- notes: ['Pure CSS bubble; it cannot contain markup. For rich content use HoverCard.'],
158
+ notes: ['Pure CSS bubble; it cannot contain markup. For rich content use HoverCard.', 'Escape hides the bubble while it is shown (hover or focus) without moving focus; it stays visible while the pointer is over the trigger or the bubble, and returns on the next hover or focus.'],
159
159
  },
160
160
 
161
161
  Dropdown: {
@@ -257,11 +257,12 @@ export default {
257
257
  },
258
258
 
259
259
  Screensaver: {
260
- description: 'Full-screen idle screen with a large clock, date and the brand mark; show it after inactivity.',
260
+ description: 'Full-screen idle screen with a large clock, date and an optional logo; show it after inactivity.',
261
261
  props: {
262
262
  open: open('screensaver'),
263
263
  locale: { type: 'string', default: 'en-GB', description: 'BCP 47 locale for the date line.' },
264
264
  hint: { type: 'string', description: 'Small hint under the date, e.g. "Tap to continue".' },
265
+ logo: { type: 'string', description: 'URL of an image shown above the clock. Use an absolute URL or a root-relative path such as "/brand/logo.svg". Nothing is shown when it is not set, so the screensaver never requests a missing file.' },
265
266
  idleMs: { type: 'number', default: 0, description: 'When greater than 0 and the screensaver is closed, a hidden sentinel listens for pointer, key, wheel and touch activity and calls idle after this many milliseconds without any. 0 disables it.' },
266
267
  },
267
268
  events: { close: 'Called on click or any key (including Escape) while open.', idle: 'Called once the idleMs period passes without activity while closed; set open to true in the handler.' },
package/src/meta/tiers.js CHANGED
@@ -4,7 +4,7 @@ export const PRO = {
4
4
  layout: ['LiquidGlass', 'Sortable'],
5
5
  actions: ['Fab', 'ActionRail', 'ActionCard', 'NotificationBell'],
6
6
  forms: ['ChipInput', 'InlineEdit', 'RichEditor', 'GroupedSelect', 'TreeSelect', 'PillSelect', 'OptionCard', 'OptionCards', 'RangeSlider', 'ColorPicker', 'RangeCalendar', 'ImageUpload', 'ValidatedField', 'StepForm', 'Stepper'],
7
- data: ['DataGroup', 'TreeView', 'CalendarView', 'KpiInline', 'StatRow', 'StatLine', 'StatStrip', 'CountryRow', 'VersusBar', 'SegBar', 'ChartCard', 'OccupancyChart', 'SalesFunnel', 'SystemStatus', 'UserCell', 'HeroBanner', 'TabTiles', 'PricingCard', 'IntegrationCard'],
7
+ data: ['DataGroup', 'TreeView', 'CalendarView', 'KpiInline', 'StatRow', 'StatLine', 'StatStrip', 'CountryRow', 'VersusBar', 'SegBar', 'ChartCard', 'LineChart', 'AreaChart', 'BarChart', 'DonutChart', 'OccupancyChart', 'SalesFunnel', 'SystemStatus', 'UserCell', 'HeroBanner', 'TabTiles', 'PricingCard', 'IntegrationCard'],
8
8
  overlay: ['StepDialog', 'FullscreenDialog', 'Actionsheet', 'HoverCard', 'CommandBar', 'ImageViewer', 'Tour', 'TourPopover', 'Screensaver'],
9
9
  media: ['Gallery', 'Map', 'ProfileHeader', 'ChatAi', 'Carousel'],
10
10
  domain: ['HotelCard', 'TourCard', 'PrayerTimes', 'RamadanTracker', 'QiblaCompass', 'BookingCard', 'GuestCard', 'RoomGrid', 'RoomCalendar', 'LeadCard', 'DealCard', 'PipelineBoard', 'ContactPanel', 'KanbanColumn', 'KanbanCard', 'CallRow', 'CallCard', 'IncomingCall', 'AuthShowcase'],
@@ -6,7 +6,7 @@ import { components, registerComponents, subscribeUiLocale, getUiVersion } from
6
6
 
7
7
  const C = {};
8
8
  const useIsoLayoutEffect = typeof document === 'undefined' ? useEffect : useLayoutEffect;
9
- const EV = { keydown: 'KeyDown', keyup: 'KeyUp', dragenter: 'DragEnter', dragover: 'DragOver', dragleave: 'DragLeave', pointerdown: 'PointerDown', pointerup: 'PointerUp', pointermove: 'PointerMove', mousedown: 'MouseDown', mouseup: 'MouseUp', mouseenter: 'MouseEnter', mouseleave: 'MouseLeave', mousemove: 'MouseMove', mouseover: 'MouseOver', mouseout: 'MouseOut', dragstart: 'DragStart', dragend: 'DragEnd', dblclick: 'DoubleClick', contextmenu: 'ContextMenu', pointerenter: 'PointerEnter', pointerleave: 'PointerLeave', pointercancel: 'PointerCancel', touchstart: 'TouchStart', touchend: 'TouchEnd', touchmove: 'TouchMove', focusin: 'FocusIn', focusout: 'FocusOut' };
9
+ const EV = { keydown: 'KeyDown', keyup: 'KeyUp', dragenter: 'DragEnter', dragover: 'DragOver', dragleave: 'DragLeave', pointerdown: 'PointerDown', pointerup: 'PointerUp', pointermove: 'PointerMove', mousedown: 'MouseDown', mouseup: 'MouseUp', mouseenter: 'MouseEnter', mouseleave: 'MouseLeave', mousemove: 'MouseMove', mouseover: 'MouseOver', mouseout: 'MouseOut', dragstart: 'DragStart', dragend: 'DragEnd', dblclick: 'DoubleClick', contextmenu: 'ContextMenu', pointerenter: 'PointerEnter', pointerleave: 'PointerLeave', pointercancel: 'PointerCancel', touchstart: 'TouchStart', touchend: 'TouchEnd', touchmove: 'TouchMove', focusin: 'Focus', focusout: 'Blur' };
10
10
  const hh = (t, p, ...k) => {
11
11
  if (p && typeof t === 'string') { const q = {}; for (const key in p) { const m = /^on([A-Za-z]+)$/.exec(key), n = m && EV[m[1].toLowerCase()]; q[n ? 'on' + n : key] = p[key]; } p = q; }
12
12
  if (p && 'class' in p) { const { class: cls, ...rest } = p; p = { ...rest, className: cls }; }
@@ -0,0 +1,68 @@
1
+ /* LineChart, AreaChart, BarChart, DonutChart (@ziyarago/ui-pro). Colors only from tokens: the series palette (--zg-chart-1..6), the tones and the grid / axis / label colors are defined here
2
+ per theme and palette (gold, tamam). Tokens are declared on .zg-chart so palette references resolve where the chart is. */
3
+ .zg-chart{
4
+ --zg-chart-brand:var(--zg-brand);--zg-chart-info:var(--zg-sky);--zg-chart-success:oklch(0.76 0.15 150);--zg-chart-warning:oklch(0.8 0.14 80);--zg-chart-danger:oklch(0.7 0.18 25);--zg-chart-neutral:oklch(0.72 0.012 70);
5
+ --zg-chart-1:var(--zg-chart-brand);--zg-chart-2:var(--zg-chart-info);--zg-chart-3:var(--zg-chart-success);--zg-chart-4:oklch(0.72 0.13 300);--zg-chart-5:oklch(0.76 0.14 45);--zg-chart-6:oklch(0.72 0.15 350);
6
+ --zg-chart-grid:oklch(1 0 0 / .07);--zg-chart-axis:oklch(1 0 0 / .24);--zg-chart-label:var(--zg-text-3);--zg-chart-surface:var(--zg-pop);--zg-chart-band:oklch(1 0 0 / .06);
7
+ }
8
+ [data-palette="tamam"] .zg-chart{--zg-chart-success:oklch(0.76 0.13 175);--zg-chart-1:var(--zg-chart-brand);--zg-chart-2:var(--zg-chart-info);--zg-chart-3:oklch(0.72 0.13 300);--zg-chart-4:oklch(0.78 0.14 75);--zg-chart-5:oklch(0.72 0.15 350);--zg-chart-6:oklch(0.7 0.14 255)}
9
+ [data-theme="light"] .zg-chart{
10
+ --zg-chart-brand:oklch(0.55 0.09 66);--zg-chart-info:oklch(0.5 0.11 230);--zg-chart-success:oklch(0.48 0.13 150);--zg-chart-warning:oklch(0.52 0.12 70);--zg-chart-danger:oklch(0.5 0.18 25);--zg-chart-neutral:oklch(0.5 0.012 70);
11
+ --zg-chart-4:oklch(0.5 0.15 300);--zg-chart-5:oklch(0.55 0.14 45);--zg-chart-6:oklch(0.5 0.17 350);
12
+ --zg-chart-grid:oklch(0.3 0.02 60 / .26);--zg-chart-axis:oklch(0.3 0.02 60 / .75);--zg-chart-band:oklch(0.3 0.02 60 / .06);
13
+ }
14
+ [data-theme="light"][data-palette="tamam"] .zg-chart,[data-theme="light"] [data-palette="tamam"] .zg-chart,[data-palette="tamam"] [data-theme="light"] .zg-chart{--zg-chart-brand:oklch(0.5 0.17 142);--zg-chart-info:oklch(0.5 0.1 200);--zg-chart-success:oklch(0.48 0.12 175);--zg-chart-3:oklch(0.5 0.15 300);--zg-chart-4:oklch(0.55 0.13 70);--zg-chart-5:oklch(0.5 0.17 350);--zg-chart-6:oklch(0.5 0.15 255)}
15
+
16
+ .zg-chart{position:relative;display:block;width:100%;min-width:0;color:var(--zg-text-2);font:12px/1.3 var(--zg-font)}
17
+ .zg-chart__box{position:relative;max-width:100%}
18
+ .zg-chart__plot{position:relative;display:block;max-width:100%;touch-action:pan-y;outline:0;border-radius:10px;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;cursor:crosshair}
19
+ .zg-chart__plot:focus-visible{box-shadow:0 0 0 3px var(--zg-brand-soft),0 0 0 1px var(--zg-brand)}
20
+ .zg-chart__svg{display:block;width:100%;height:auto;max-width:100%;overflow:visible}
21
+ .zg-chart__grid{stroke:var(--zg-chart-grid);stroke-width:1;shape-rendering:crispEdges}
22
+ .zg-chart__zero{stroke:var(--zg-chart-axis)}
23
+ .zg-chart__tick{fill:var(--zg-chart-label);font-size:11px;font-family:inherit;font-variant-numeric:tabular-nums}
24
+ .zg-chart__line{stroke:var(--c);stroke-width:2.25;stroke-linecap:round;stroke-linejoin:round}
25
+ .zg-chart__gs{stop-color:var(--c)}
26
+ .zg-chart__pt{fill:var(--c);stroke:var(--zg-chart-surface);stroke-width:1.5}
27
+ .zg-chart__pt--on{stroke-width:2}
28
+ .zg-chart__guide{stroke:var(--zg-chart-axis);stroke-width:1;stroke-dasharray:3 3;shape-rendering:crispEdges}
29
+ .zg-chart__band{fill:var(--zg-chart-band)}
30
+ .zg-chart__bar{fill:var(--c)}
31
+ .zg-chart__bar--on{fill:color-mix(in oklch,var(--c) 82%,var(--zg-text-1))}
32
+ .zg-chart__slice{fill:var(--c);stroke:none}
33
+ .zg-chart__slice--on{fill:color-mix(in oklch,var(--c) 82%,var(--zg-text-1))}
34
+ .zg-chart__track{stroke:var(--zg-s2)}
35
+ .zg-chart__total{fill:var(--zg-text-1);font-weight:600;font-family:inherit;font-variant-numeric:tabular-nums}
36
+ .zg-chart__totall{fill:var(--zg-chart-label);font-size:11px;font-family:inherit}
37
+ .zg-chart__tip{position:absolute;z-index:3;box-sizing:border-box;pointer-events:none;background:var(--zg-pop);color:var(--zg-text-1);border-radius:12px;box-shadow:var(--zg-shadow-1),inset 0 0 0 1px var(--zg-stroke);padding:8px 12px;font-size:12px}
38
+ .zg-chart__tipt{color:var(--zg-text-2);margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
39
+ .zg-chart__tipr{display:flex;align-items:center;gap:8px;line-height:20px}
40
+ .zg-chart__tipr--on .zg-chart__tipl{color:var(--zg-text-1)}
41
+ .zg-chart__tipl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--zg-text-2)}
42
+ .zg-chart__tipv{font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
43
+ .zg-chart__sw{flex:none;width:10px;height:10px;border-radius:3px;background:var(--c)}
44
+ .zg-chart__sw--dash{width:16px;height:0;border-radius:0;background:none;border-top:2.5px dashed var(--c)}
45
+ .zg-chart__legend{display:flex;flex-wrap:wrap;gap:4px 8px;margin:10px 0 0;padding:0;list-style:none}
46
+ .zg-chart__lg{display:inline-flex;align-items:center;gap:8px;min-height:28px;padding:2px 8px;border:0;border-radius:8px;background:none;color:var(--zg-text-2);font:inherit;font-size:13px;cursor:pointer;text-align:start}
47
+ .zg-chart__lg:hover{background:var(--zg-s2)}
48
+ .zg-chart__lg:focus-visible{outline:0;box-shadow:0 0 0 3px var(--zg-brand-soft),0 0 0 1px var(--zg-brand)}
49
+ .zg-chart__lg[aria-pressed="false"]{color:var(--zg-text-3)}
50
+ .zg-chart__lg[aria-pressed="false"] .zg-chart__lgt{text-decoration:line-through}
51
+ .zg-chart__lg[aria-pressed="false"] .zg-chart__sw{background:none;box-shadow:inset 0 0 0 2px var(--c)}
52
+ .zg-chart__lg[aria-pressed="false"] .zg-chart__sw--dash{box-shadow:none}
53
+ .zg-chart__lgv{margin-inline-start:auto;padding-inline-start:12px;font-variant-numeric:tabular-nums;color:var(--zg-text-2);white-space:nowrap}
54
+ .zg-chart--donut{display:flex;flex-wrap:wrap;align-items:center;gap:12px 28px}
55
+ .zg-chart--donut .zg-chart__box{flex:none;max-width:100%}
56
+ .zg-chart--donut .zg-chart__legend{flex:1 1 200px;min-width:0;flex-direction:column;flex-wrap:nowrap;gap:2px;margin:0}
57
+ .zg-chart--donut .zg-chart__lg{width:100%}
58
+ .zg-chart__empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;box-sizing:border-box;padding:16px;text-align:center;color:var(--zg-text-3);border-radius:16px;box-shadow:inset 0 0 0 1px var(--zg-stroke)}
59
+ .zg-chart__emptyt{margin:0;font-size:13px}
60
+ .zg-chart--donut.zg-chart--empty,.zg-chart--donut.zg-chart--loading{display:block}
61
+ .zg-chart__sk{display:block;max-width:100%}
62
+ .zg-chart--donut .zg-chart__sk{margin-inline:auto}
63
+ .zg-chart__skg{stroke:var(--zg-s2);stroke-width:1;vector-effect:non-scaling-stroke}
64
+ .zg-chart__skp{stroke:var(--zg-s3);stroke-linecap:round;animation:zg-chart-pulse 1.4s ease-in-out infinite}
65
+ .zg-chart__skbars{display:flex;align-items:flex-end;gap:8px}
66
+ .zg-chart__skbars .zg-skel{flex:1;border-radius:6px 6px 2px 2px}
67
+ @keyframes zg-chart-pulse{50%{opacity:.45}}
68
+ @media (prefers-reduced-motion:reduce){.zg-chart__skp{animation:none}}
@@ -113,3 +113,10 @@
113
113
  .zg-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
114
114
  .zg-select__opt:focus-visible{outline:3px solid var(--zg-brand-soft);outline-offset:-3px;background:var(--zg-s2)}
115
115
  .zg-seg button:focus-visible,.zg-tabs button:focus-visible,.zg-code-b pre:focus-visible{outline:3px solid var(--zg-brand-soft);outline-offset:2px}
116
+ .zg-select__opt[data-active],.zg-select__opt[data-active]:hover{background:var(--zg-s2);outline:3px solid var(--zg-brand-soft);outline-offset:-3px}
117
+ .zg-tip:hover::after,.zg-tip:focus-visible::after{pointer-events:auto}
118
+ .zg-tip:hover::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:10px}
119
+ .zg-tip--bottom:hover::before{bottom:auto;top:100%}
120
+ .zg-tip--left:hover::before,.zg-tip--right:hover::before{top:0;bottom:0;height:auto;width:10px}
121
+ .zg-tip--left:hover::before{left:auto;right:100%}.zg-tip--right:hover::before{right:auto;left:100%}
122
+ .zg-tip[data-tip-off]::after{opacity:0!important;pointer-events:none!important}.zg-tip[data-tip-off]::before{display:none}
@@ -8,6 +8,7 @@
8
8
  @import './components6.css';
9
9
  @import './components7.css';
10
10
  @import './components8.css';
11
+ @import './charts.css';
11
12
  @import './sizes/typography.css';
12
13
  @import './sizes/layout.css';
13
14
  @import './sizes/actions.css';
package/types/react.d.ts CHANGED
@@ -382,6 +382,7 @@ export const UiButton: ComponentType<UiButtonProps>;
382
382
  /**
383
383
  * Month grid for picking one date. Use inline, or inside a popover via DatePicker.
384
384
  * Note: The event payload is a Date, not a string: convert to ISO yourself if the model is a string.
385
+ * Note: Keyboard: one tab stop on the day grid; arrows move by day or week, Home and End to the week start and end, PageUp and PageDown change month (Shift: year), Enter or Space picks. Without a selected date the visible month is the current month, which server and client can disagree on right at a month boundary; pass modelValue or today to make it deterministic.
385
386
  */
386
387
  export interface UiCalendarProps {
387
388
  /** Selected date: a Date, ISO string or timestamp. Also sets the visible month. A date-only string like "2026-10-21" is read as a local date, so the day does not shift with the time zone. */
@@ -402,6 +403,8 @@ export interface UiCalendarProps {
402
403
  max?: Date | string | number;
403
404
  /** Return true to disable a specific day. */
404
405
  disabledDate?: (date: Date) => boolean;
406
+ /** The day marked as today. By default it is read from the browser clock after mount (nothing is marked in server-rendered HTML), so server and client never disagree near midnight or across time zones. Pass it to mark a day in the HTML too. */
407
+ today?: Date | string | number;
405
408
  /**
406
409
  * Marked days: keys are YYYY-MM-DD, values are the tooltip text.
407
410
  * @default {}
@@ -630,6 +633,7 @@ export const UiCodeInput: ComponentType<UiCodeInputProps>;
630
633
  * Searchable single-choice dropdown. Use for long option lists where typing to filter is faster than scrolling.
631
634
  * Note: Selecting from the list is the only way to set a value: free text is never emitted.
632
635
  * Note: The list closes on outside click and on Escape.
636
+ * Note: Keyboard (WAI-ARIA combobox): the input has role=combobox with aria-expanded, aria-controls and aria-activedescendant; focus stays in the input, Arrow Down and Up open the list and move the active option (wraps, skips disabled), Enter chooses it, Escape closes.
633
637
  */
634
638
  export interface UiComboboxProps {
635
639
  /** Value of the selected option. */
@@ -926,7 +930,7 @@ export const UiDataTable: ComponentType<UiDataTableProps>;
926
930
  /**
927
931
  * Field with a calendar popup for choosing a date. Use in forms where a single date is needed.
928
932
  * Note: The event payload is a Date, not a string: convert to ISO yourself if the model is a string.
929
- * Note: The popup closes on outside click and on Escape.
933
+ * Note: The popup closes on outside click and on Escape (focus returns to the trigger). Arrow Down or Up on the trigger opens it and focus moves into the calendar, which uses the Calendar keys.
930
934
  */
931
935
  export interface UiDatePickerProps {
932
936
  /** Selected date (ISO string recommended, e.g. 2026-10-21). */
@@ -961,6 +965,11 @@ export interface UiDatePickerProps {
961
965
  * @remarks Forwarded to the inner element.
962
966
  */
963
967
  disabledDate?: (date: Date) => boolean;
968
+ /**
969
+ * The day marked as today in the calendar; by default read from the browser clock after mount. Forwarded to the calendar.
970
+ * @remarks Forwarded to the inner element.
971
+ */
972
+ today?: Date | string | number;
964
973
  /**
965
974
  * Adds a red asterisk to the label. Does not validate.
966
975
  * @default false
@@ -3663,6 +3672,7 @@ export const UiToggleGroup: ComponentType<UiToggleGroupProps>;
3663
3672
  /**
3664
3673
  * Short text bubble shown on hover or focus of the wrapped element; use for icon-only controls and hints.
3665
3674
  * Note: Pure CSS bubble; it cannot contain markup. For rich content use HoverCard.
3675
+ * Note: Escape hides the bubble while it is shown (hover or focus) without moving focus; it stays visible while the pointer is over the trigger or the bubble, and returns on the next hover or focus.
3666
3676
  */
3667
3677
  export interface UiTooltipProps {
3668
3678
  /** Tooltip text. */
package/types/vue.d.ts CHANGED
@@ -402,6 +402,7 @@ export const UiButton: UiVueComponent<UiButtonProps, UiButtonSlots>;
402
402
  /**
403
403
  * Month grid for picking one date. Use inline, or inside a popover via DatePicker.
404
404
  * Note: The event payload is a Date, not a string: convert to ISO yourself if the model is a string.
405
+ * Note: Keyboard: one tab stop on the day grid; arrows move by day or week, Home and End to the week start and end, PageUp and PageDown change month (Shift: year), Enter or Space picks. Without a selected date the visible month is the current month, which server and client can disagree on right at a month boundary; pass modelValue or today to make it deterministic.
405
406
  */
406
407
  export interface UiCalendarProps {
407
408
  /** Selected date: a Date, ISO string or timestamp. Also sets the visible month. A date-only string like "2026-10-21" is read as a local date, so the day does not shift with the time zone. */
@@ -422,6 +423,8 @@ export interface UiCalendarProps {
422
423
  max?: Date | string | number;
423
424
  /** Return true to disable a specific day. */
424
425
  disabledDate?: (date: Date) => boolean;
426
+ /** The day marked as today. By default it is read from the browser clock after mount (nothing is marked in server-rendered HTML), so server and client never disagree near midnight or across time zones. Pass it to mark a day in the HTML too. */
427
+ today?: Date | string | number;
425
428
  /**
426
429
  * Marked days: keys are YYYY-MM-DD, values are the tooltip text.
427
430
  * @default {}
@@ -656,6 +659,7 @@ export const UiCodeInput: UiVueComponent<UiCodeInputProps, UiCodeInputSlots>;
656
659
  * Searchable single-choice dropdown. Use for long option lists where typing to filter is faster than scrolling.
657
660
  * Note: Selecting from the list is the only way to set a value: free text is never emitted.
658
661
  * Note: The list closes on outside click and on Escape.
662
+ * Note: Keyboard (WAI-ARIA combobox): the input has role=combobox with aria-expanded, aria-controls and aria-activedescendant; focus stays in the input, Arrow Down and Up open the list and move the active option (wraps, skips disabled), Enter chooses it, Escape closes.
659
663
  */
660
664
  export interface UiComboboxProps {
661
665
  /** Value of the selected option. */
@@ -963,7 +967,7 @@ export const UiDataTable: UiVueComponent<UiDataTableProps, UiDataTableSlots>;
963
967
  /**
964
968
  * Field with a calendar popup for choosing a date. Use in forms where a single date is needed.
965
969
  * Note: The event payload is a Date, not a string: convert to ISO yourself if the model is a string.
966
- * Note: The popup closes on outside click and on Escape.
970
+ * Note: The popup closes on outside click and on Escape (focus returns to the trigger). Arrow Down or Up on the trigger opens it and focus moves into the calendar, which uses the Calendar keys.
967
971
  */
968
972
  export interface UiDatePickerProps {
969
973
  /** Selected date (ISO string recommended, e.g. 2026-10-21). */
@@ -998,6 +1002,11 @@ export interface UiDatePickerProps {
998
1002
  * @remarks Forwarded to the inner element.
999
1003
  */
1000
1004
  disabledDate?: (date: Date) => boolean;
1005
+ /**
1006
+ * The day marked as today in the calendar; by default read from the browser clock after mount. Forwarded to the calendar.
1007
+ * @remarks Forwarded to the inner element.
1008
+ */
1009
+ today?: Date | string | number;
1001
1010
  /**
1002
1011
  * Adds a red asterisk to the label. Does not validate.
1003
1012
  * @default false
@@ -3692,6 +3701,7 @@ export const UiToggleGroup: UiVueComponent<UiToggleGroupProps, UiToggleGroupSlot
3692
3701
  /**
3693
3702
  * Short text bubble shown on hover or focus of the wrapped element; use for icon-only controls and hints.
3694
3703
  * Note: Pure CSS bubble; it cannot contain markup. For rich content use HoverCard.
3704
+ * Note: Escape hides the bubble while it is shown (hover or focus) without moving focus; it stays visible while the pointer is over the trigger or the bubble, and returns on the next hover or focus.
3695
3705
  */
3696
3706
  export interface UiTooltipProps {
3697
3707
  /** Tooltip text. */