@vmz/ui 0.0.4 → 0.1.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,19 +1,81 @@
1
1
  <template>
2
- <div class="vmz-ui-date-picker" data-vmz-ui="date-picker" data-invalid={invalidAttr}>
2
+ <div
3
+ class={'vmz-ui-date-picker' + (open ? ' is-opened' : '')}
4
+ data-vmz-ui="date-picker"
5
+ data-invalid={invalidAttr}
6
+ >
3
7
  <label if={label} class="vmz-ui-date-picker__label" for={controlId}>{label}</label>
4
- <input
8
+ <button
9
+ type="button"
5
10
  id={controlId}
6
11
  class="vmz-ui-date-picker__control"
7
- type="date"
8
- value={value}
9
- min={min}
10
- max={max}
11
12
  disabled={disabled}
13
+ aria-haspopup="dialog"
14
+ aria-expanded={open ? 'true' : 'false'}
15
+ aria-controls={panelId}
12
16
  aria-invalid={error ? 'true' : 'false'}
13
17
  aria-describedby={describedBy}
14
- onInput={onInput}
15
- onChange={onChange}
16
- />
18
+ onClick={toggle}
19
+ onKeyDown={onTriggerKeyDown}
20
+ >
21
+ <span class="vmz-ui-date-picker__value" data-vmz-date="value">{displayLabel}</span>
22
+ <span class="vmz-ui-date-picker__chev" aria-hidden="true"></span>
23
+ </button>
24
+ <div
25
+ if={open}
26
+ id={panelId}
27
+ class="vmz-ui-date-picker__panel"
28
+ role="dialog"
29
+ aria-modal="false"
30
+ aria-label={panelLabel}
31
+ tabindex="-1"
32
+ data-vmz-overlay="date-picker"
33
+ data-vmz-overlay-owner="date-picker"
34
+ data-vmz-focus="enter"
35
+ data-vmz-date="panel"
36
+ onKeyDown={onPanelKeyDown}
37
+ >
38
+ <div class="vmz-ui-date-picker__monthbar">
39
+ <button
40
+ type="button"
41
+ class="vmz-ui-date-picker__nav"
42
+ data-vmz-date="prev"
43
+ aria-label="Previous month"
44
+ onClick={prevMonth}
45
+ >‹</button>
46
+ <p class="vmz-ui-date-picker__month" data-vmz-date="month">{monthLabel}</p>
47
+ <button
48
+ type="button"
49
+ class="vmz-ui-date-picker__nav"
50
+ data-vmz-date="next"
51
+ aria-label="Next month"
52
+ onClick={nextMonth}
53
+ >›</button>
54
+ </div>
55
+ <div class="vmz-ui-date-picker__weekdays" aria-hidden="true">
56
+ <span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span><span>Su</span>
57
+ </div>
58
+ <div class="vmz-ui-date-picker__grid" role="grid" aria-labelledby={panelId}>
59
+ <button
60
+ each={days}
61
+ as="day"
62
+ key={day.key}
63
+ type="button"
64
+ role="gridcell"
65
+ class={
66
+ 'vmz-ui-date-picker__day' +
67
+ (day.inMonth ? '' : ' is-outside') +
68
+ (day.selected ? ' is-on' : '') +
69
+ (day.disabled ? ' is-disabled' : '')
70
+ }
71
+ data-vmz-date-iso={day.iso}
72
+ aria-selected={day.selected ? 'true' : 'false'}
73
+ aria-disabled={day.disabled ? 'true' : 'false'}
74
+ disabled={day.disabled}
75
+ onClick={onDayClick}
76
+ >{day.label}</button>
77
+ </div>
78
+ </div>
17
79
  <p if={description} id={descriptionId} class="vmz-ui-date-picker__description">{description}</p>
18
80
  <p if={error} id={errorId} class="vmz-ui-date-picker__error" role="alert">{error}</p>
19
81
  </div>
@@ -21,6 +83,7 @@
21
83
 
22
84
  <style>
23
85
  .vmz-ui-date-picker {
86
+ position: relative;
24
87
  display: flex;
25
88
  flex-direction: column;
26
89
  gap: 0.35rem;
@@ -33,20 +96,29 @@
33
96
  }
34
97
 
35
98
  .vmz-ui-date-picker__control {
99
+ display: inline-flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ gap: 0.55rem;
103
+ width: 100%;
104
+ box-sizing: border-box;
36
105
  font: inherit;
106
+ text-align: left;
37
107
  padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
38
108
  border: 1px solid var(--vmz-action-primary-background);
39
109
  border-radius: 2px;
40
- background: transparent;
110
+ background: var(--vmz-surface-paper, transparent);
41
111
  color: inherit;
42
112
  outline: none;
113
+ cursor: pointer;
43
114
  }
44
115
 
45
116
  .vmz-ui-date-picker[data-invalid='true'] .vmz-ui-date-picker__control {
46
117
  border-color: var(--vmz-status-danger-accent);
47
118
  }
48
119
 
49
- .vmz-ui-date-picker__control:focus-visible {
120
+ .vmz-ui-date-picker__control:focus-visible,
121
+ .vmz-ui-date-picker.is-opened .vmz-ui-date-picker__control {
50
122
  box-shadow: 0 0 0 2px var(--vmz-focus-ring);
51
123
  }
52
124
 
@@ -55,6 +127,132 @@
55
127
  cursor: not-allowed;
56
128
  }
57
129
 
130
+ .vmz-ui-date-picker__value {
131
+ flex: 1 1 auto;
132
+ min-width: 0;
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ white-space: nowrap;
136
+ }
137
+
138
+ .vmz-ui-date-picker__chev {
139
+ flex: 0 0 auto;
140
+ width: 0.45rem;
141
+ height: 0.45rem;
142
+ border-right: 1.5px solid currentColor;
143
+ border-bottom: 1.5px solid currentColor;
144
+ transform: rotate(45deg) translate(-1px, -2px);
145
+ opacity: 0.7;
146
+ }
147
+
148
+ .vmz-ui-date-picker.is-opened .vmz-ui-date-picker__chev {
149
+ transform: rotate(225deg) translate(-1px, -1px);
150
+ }
151
+
152
+ .vmz-ui-date-picker__panel {
153
+ position: absolute;
154
+ z-index: 40;
155
+ top: calc(100% + 0.25rem);
156
+ left: 0;
157
+ right: 0;
158
+ box-sizing: border-box;
159
+ padding: 0.65rem 0.75rem 0.75rem;
160
+ border: 1px solid var(--vmz-action-primary-background);
161
+ border-radius: 2px;
162
+ background: var(--vmz-surface-paper, #fff);
163
+ color: inherit;
164
+ box-shadow: 0 10px 24px color-mix(in srgb, #000 16%, transparent);
165
+ outline: none;
166
+ }
167
+
168
+ .vmz-ui-date-picker__monthbar {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: space-between;
172
+ gap: 0.5rem;
173
+ margin-bottom: 0.55rem;
174
+ }
175
+
176
+ .vmz-ui-date-picker__month {
177
+ margin: 0;
178
+ font: inherit;
179
+ font-weight: 600;
180
+ text-align: center;
181
+ flex: 1 1 auto;
182
+ }
183
+
184
+ .vmz-ui-date-picker__nav {
185
+ flex: 0 0 auto;
186
+ width: 2rem;
187
+ height: 2rem;
188
+ font: inherit;
189
+ font-size: 1.15rem;
190
+ line-height: 1;
191
+ border: 1px solid transparent;
192
+ border-radius: 2px;
193
+ background: transparent;
194
+ color: inherit;
195
+ cursor: pointer;
196
+ outline: none;
197
+ }
198
+
199
+ .vmz-ui-date-picker__nav:hover,
200
+ .vmz-ui-date-picker__nav:focus-visible {
201
+ border-color: var(--vmz-action-primary-background);
202
+ box-shadow: 0 0 0 2px var(--vmz-focus-ring);
203
+ }
204
+
205
+ .vmz-ui-date-picker__weekdays {
206
+ display: grid;
207
+ grid-template-columns: repeat(7, 1fr);
208
+ gap: 0.15rem;
209
+ margin-bottom: 0.25rem;
210
+ font-size: 0.75rem;
211
+ opacity: 0.7;
212
+ text-align: center;
213
+ }
214
+
215
+ .vmz-ui-date-picker__grid {
216
+ display: grid;
217
+ grid-template-columns: repeat(7, 1fr);
218
+ gap: 0.15rem;
219
+ }
220
+
221
+ .vmz-ui-date-picker__day {
222
+ aspect-ratio: 1;
223
+ min-height: 2rem;
224
+ font: inherit;
225
+ font-size: 0.9rem;
226
+ border: 0;
227
+ border-radius: 2px;
228
+ background: transparent;
229
+ color: inherit;
230
+ cursor: pointer;
231
+ outline: none;
232
+ }
233
+
234
+ .vmz-ui-date-picker__day:hover,
235
+ .vmz-ui-date-picker__day:focus-visible {
236
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 18%, transparent);
237
+ box-shadow: 0 0 0 2px var(--vmz-focus-ring);
238
+ }
239
+
240
+ .vmz-ui-date-picker__day.is-outside {
241
+ opacity: 0.4;
242
+ }
243
+
244
+ .vmz-ui-date-picker__day.is-on {
245
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 28%, transparent);
246
+ font-weight: 600;
247
+ }
248
+
249
+ .vmz-ui-date-picker__day.is-disabled,
250
+ .vmz-ui-date-picker__day:disabled {
251
+ opacity: 0.35;
252
+ cursor: not-allowed;
253
+ box-shadow: none;
254
+ }
255
+
58
256
  .vmz-ui-date-picker__description {
59
257
  margin: 0;
60
258
  opacity: 0.75;
@@ -71,8 +269,10 @@
71
269
  <script client>
72
270
  /**
73
271
  * VMZ UI — DatePicker.
74
- * WebSurface thin gate: native date input. Parent owns ISO value (YYYY-MM-DD).
75
- * No calendar overlay runtime; richer picker stays out of @vmz/ui core until needed.
272
+ * Owned calendar overlay (trigger + dialog grid). Never native <input type="date"> popup:
273
+ * OS date UI cannot carry Style Theme / density / overlay ownership.
274
+ * Parent owns ISO value (YYYY-MM-DD) via onChange Event-like { target: { value } }.
275
+ * Not @vmz/ui-scheduler — month grid only; no recurrence / resource layout.
76
276
  */
77
277
  export default class DatePicker {
78
278
  public label: string = '';
@@ -87,7 +287,305 @@ export default class DatePicker {
87
287
  public descriptionId: string = 'vmz-date-desc';
88
288
  public errorId: string = 'vmz-date-err';
89
289
  public describedBy: string = '';
90
- public onInput: ((e: Event) => void) | null = null;
91
- public onChange: ((e: Event) => void) | null = null;
290
+ public onInput: ((e: { target: { value: string } }) => void) | null = null;
291
+ public onChange: ((e: { target: { value: string } }) => void) | null = null;
292
+
293
+ open = false;
294
+ displayLabel = '—';
295
+ panelId = 'vmz-date-panel';
296
+ panelLabel = 'Choose date';
297
+ monthLabel = '';
298
+ viewYear = 0;
299
+ viewMonth = 0;
300
+ days: Array<{
301
+ key: string;
302
+ iso: string;
303
+ label: string;
304
+ inMonth: boolean;
305
+ selected: boolean;
306
+ disabled: boolean;
307
+ }> = [];
308
+
309
+ _docKeyHandler = null;
310
+ _docPointerHandler = null;
311
+ _observer = null;
312
+
313
+ onMount() {
314
+ this.panelId = `${this.controlId}-panel`;
315
+ this.syncFromValue(true);
316
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
317
+ const root = this.__vmzDomRoot;
318
+ if (!root || typeof root.querySelector !== 'function') return;
319
+ this._observer = new MutationObserver(() => this._syncOverlay());
320
+ this._observer.observe(root, { childList: true, subtree: true });
321
+ }
322
+
323
+ onDestroy() {
324
+ if (this._observer) {
325
+ this._observer.disconnect();
326
+ this._observer = null;
327
+ }
328
+ this._teardownDoc();
329
+ }
330
+
331
+ syncFromValue(resetView: boolean) {
332
+ const parsed = this._parseIso(this.value);
333
+ this.displayLabel = parsed ? this.value : '—';
334
+ if (resetView) {
335
+ const base = parsed || this._todayParts();
336
+ this.viewYear = base.y;
337
+ this.viewMonth = base.m;
338
+ }
339
+ this._rebuildDays();
340
+ }
341
+
342
+ toggle() {
343
+ if (this.disabled) return;
344
+ if (this.open) this.close();
345
+ else this.openPanel();
346
+ }
347
+
348
+ openPanel() {
349
+ this.syncFromValue(true);
350
+ this.open = true;
351
+ }
352
+
353
+ close() {
354
+ this.open = false;
355
+ }
356
+
357
+ prevMonth() {
358
+ if (this.viewMonth <= 1) {
359
+ this.viewYear -= 1;
360
+ this.viewMonth = 12;
361
+ } else {
362
+ this.viewMonth -= 1;
363
+ }
364
+ this._rebuildDays();
365
+ }
366
+
367
+ nextMonth() {
368
+ if (this.viewMonth >= 12) {
369
+ this.viewYear += 1;
370
+ this.viewMonth = 1;
371
+ } else {
372
+ this.viewMonth += 1;
373
+ }
374
+ this._rebuildDays();
375
+ }
376
+
377
+ choose(iso: string) {
378
+ if (!iso || this._isOutOfRange(iso)) return;
379
+ this.close();
380
+ if (iso === this.value) return;
381
+ const ev = { target: { value: iso } };
382
+ if (typeof this.onChange === 'function') this.onChange(ev);
383
+ if (typeof this.onInput === 'function') this.onInput(ev);
384
+ }
385
+
386
+ onDayClick(ev: Event) {
387
+ const t = ev?.target as HTMLElement | null;
388
+ const el =
389
+ t && typeof t.closest === 'function'
390
+ ? (t.closest('[data-vmz-date-iso]') as HTMLElement | null)
391
+ : ((ev?.currentTarget as HTMLElement | null) ?? null);
392
+ const iso = el && typeof el.getAttribute === 'function' ? el.getAttribute('data-vmz-date-iso') : null;
393
+ if (iso) this.choose(iso);
394
+ }
395
+
396
+ onTriggerKeyDown(ev: KeyboardEvent) {
397
+ if (!ev || this.disabled) return;
398
+ if ((ev.key === 'ArrowDown' || ev.key === 'Enter' || ev.key === ' ') && !this.open) {
399
+ ev.preventDefault();
400
+ this.openPanel();
401
+ } else if (ev.key === 'Escape' && this.open) {
402
+ ev.preventDefault();
403
+ this.close();
404
+ }
405
+ }
406
+
407
+ onPanelKeyDown(ev: KeyboardEvent) {
408
+ if (!ev) return;
409
+ if (ev.key === 'Escape') {
410
+ ev.preventDefault();
411
+ this.close();
412
+ return;
413
+ }
414
+ const root = this.__vmzDomRoot;
415
+ const panel =
416
+ root && typeof root.querySelector === 'function'
417
+ ? root.querySelector('[data-vmz-overlay="date-picker"]')
418
+ : null;
419
+ if (!panel) return;
420
+ const nodes = [...panel.querySelectorAll('[data-vmz-date-iso]')].filter(
421
+ (n) => n instanceof HTMLElement && !(n as HTMLButtonElement).disabled,
422
+ );
423
+ if (!nodes.length) return;
424
+ const idx = nodes.indexOf(document.activeElement as Element);
425
+ if (ev.key === 'ArrowRight') {
426
+ ev.preventDefault();
427
+ (nodes[(idx + 1 + nodes.length) % nodes.length] as HTMLElement).focus();
428
+ } else if (ev.key === 'ArrowLeft') {
429
+ ev.preventDefault();
430
+ (nodes[(idx - 1 + nodes.length) % nodes.length] as HTMLElement).focus();
431
+ } else if (ev.key === 'ArrowDown') {
432
+ ev.preventDefault();
433
+ (nodes[Math.min(idx + 7, nodes.length - 1)] as HTMLElement).focus();
434
+ } else if (ev.key === 'ArrowUp') {
435
+ ev.preventDefault();
436
+ (nodes[Math.max(idx - 7, 0)] as HTMLElement).focus();
437
+ } else if (ev.key === 'Home') {
438
+ ev.preventDefault();
439
+ (nodes[0] as HTMLElement).focus();
440
+ } else if (ev.key === 'End') {
441
+ ev.preventDefault();
442
+ (nodes[nodes.length - 1] as HTMLElement).focus();
443
+ } else if (ev.key === 'Enter' || ev.key === ' ') {
444
+ const active = document.activeElement as HTMLElement | null;
445
+ const iso = active?.getAttribute?.('data-vmz-date-iso');
446
+ if (iso) {
447
+ ev.preventDefault();
448
+ this.choose(iso);
449
+ }
450
+ }
451
+ }
452
+
453
+ _rebuildDays() {
454
+ const y = this.viewYear || this._todayParts().y;
455
+ const m = this.viewMonth || this._todayParts().m;
456
+ this.viewYear = y;
457
+ this.viewMonth = m;
458
+ this.monthLabel = `${this._monthName(m)} ${y}`;
459
+ this.panelLabel = `Choose date — ${this.monthLabel}`;
460
+
461
+ const firstDow = (new Date(y, m - 1, 1).getDay() + 6) % 7; // Mon=0
462
+ const dim = new Date(y, m, 0).getDate();
463
+ const prevDim = new Date(y, m - 1, 0).getDate();
464
+ const cells: typeof this.days = [];
465
+
466
+ for (let i = 0; i < 42; i++) {
467
+ let cy = y;
468
+ let cm = m;
469
+ let cd = 0;
470
+ let inMonth = true;
471
+ if (i < firstDow) {
472
+ inMonth = false;
473
+ cd = prevDim - firstDow + i + 1;
474
+ cm = m === 1 ? 12 : m - 1;
475
+ cy = m === 1 ? y - 1 : y;
476
+ } else if (i >= firstDow + dim) {
477
+ inMonth = false;
478
+ cd = i - firstDow - dim + 1;
479
+ cm = m === 12 ? 1 : m + 1;
480
+ cy = m === 12 ? y + 1 : y;
481
+ } else {
482
+ cd = i - firstDow + 1;
483
+ }
484
+ const iso = this._toIso(cy, cm, cd);
485
+ cells.push({
486
+ key: iso,
487
+ iso,
488
+ label: String(cd),
489
+ inMonth,
490
+ selected: iso === this.value,
491
+ disabled: this._isOutOfRange(iso),
492
+ });
493
+ }
494
+ this.days = cells;
495
+ this.displayLabel = this._parseIso(this.value) ? this.value : '—';
496
+ }
497
+
498
+ _parseIso(iso: string) {
499
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
500
+ if (!m) return null;
501
+ const y = Number(m[1]);
502
+ const mo = Number(m[2]);
503
+ const d = Number(m[3]);
504
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
505
+ return { y, m: mo, d };
506
+ }
507
+
508
+ _toIso(y: number, m: number, d: number) {
509
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
510
+ }
511
+
512
+ _todayParts() {
513
+ const now = new Date();
514
+ return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
515
+ }
516
+
517
+ _monthName(m: number) {
518
+ const names = [
519
+ 'January',
520
+ 'February',
521
+ 'March',
522
+ 'April',
523
+ 'May',
524
+ 'June',
525
+ 'July',
526
+ 'August',
527
+ 'September',
528
+ 'October',
529
+ 'November',
530
+ 'December',
531
+ ];
532
+ return names[m - 1] || String(m);
533
+ }
534
+
535
+ _isOutOfRange(iso: string) {
536
+ if (this.min && iso < this.min) return true;
537
+ if (this.max && iso > this.max) return true;
538
+ return false;
539
+ }
540
+
541
+ _syncOverlay() {
542
+ const root = this.__vmzDomRoot;
543
+ const panel =
544
+ root && typeof root.querySelector === 'function'
545
+ ? root.querySelector('[data-vmz-overlay="date-picker"][data-vmz-focus="enter"]')
546
+ : null;
547
+ if (panel && this.open) this._bindDoc(panel as HTMLElement);
548
+ else if (!panel) this._teardownDoc();
549
+ this.displayLabel = this._parseIso(this.value) ? this.value : '—';
550
+ }
551
+
552
+ _bindDoc(panel: HTMLElement) {
553
+ if (typeof document === 'undefined') return;
554
+ if (!this._docKeyHandler) {
555
+ this._docKeyHandler = (ev: KeyboardEvent) => {
556
+ if (ev.key === 'Escape') {
557
+ ev.preventDefault();
558
+ this.close();
559
+ }
560
+ };
561
+ document.addEventListener('keydown', this._docKeyHandler);
562
+ }
563
+ if (!this._docPointerHandler) {
564
+ const root = this.__vmzDomRoot;
565
+ this._docPointerHandler = (ev: Event) => {
566
+ const t = ev.target;
567
+ if (!(t instanceof Node)) return;
568
+ if (root && typeof root.contains === 'function' && root.contains(t)) return;
569
+ this.close();
570
+ };
571
+ document.addEventListener('pointerdown', this._docPointerHandler, true);
572
+ }
573
+ const selected = panel.querySelector('[data-vmz-date-iso][aria-selected="true"]:not([disabled])');
574
+ const first = panel.querySelector('[data-vmz-date-iso]:not([disabled])');
575
+ const focusTarget = (selected || first) as HTMLElement | null;
576
+ if (focusTarget) focusTarget.focus();
577
+ else panel.focus();
578
+ }
579
+
580
+ _teardownDoc() {
581
+ if (typeof document !== 'undefined') {
582
+ if (this._docKeyHandler) document.removeEventListener('keydown', this._docKeyHandler);
583
+ if (this._docPointerHandler) {
584
+ document.removeEventListener('pointerdown', this._docPointerHandler, true);
585
+ }
586
+ }
587
+ this._docKeyHandler = null;
588
+ this._docPointerHandler = null;
589
+ }
92
590
  }
93
591
  </script>
@@ -244,7 +244,11 @@ export default class Select {
244
244
  }
245
245
 
246
246
  onOptionClick(ev: Event) {
247
- const el = ev?.currentTarget as HTMLElement | null;
247
+ const t = ev?.target as Element | null;
248
+ const el =
249
+ (t && typeof (t as HTMLElement).closest === 'function'
250
+ ? ((t as HTMLElement).closest('[data-vmz-option]') as HTMLElement | null)
251
+ : null) || (ev?.currentTarget as HTMLElement | null);
248
252
  const id = el && typeof el.getAttribute === 'function' ? el.getAttribute('data-vmz-option') : null;
249
253
  if (id) this.choose(id);
250
254
  }