tyrell-components 1.0.0-TC58 → 1.0.0-TC59

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.
Files changed (65) hide show
  1. package/css/tyrell-colors-static.css +14 -0
  2. package/css/tyrell-theme.css +1 -0
  3. package/css/tyrell.css +8 -6
  4. package/dist/tyrell-colors-static.css +1 -1
  5. package/dist/tyrell-theme.css +1 -1
  6. package/dist/tyrell.js +1 -1
  7. package/lib/base/ty-component.d.ts +8 -12
  8. package/lib/base/ty-component.js +38 -9
  9. package/lib/components/button.d.ts +10 -0
  10. package/lib/components/button.js +22 -1
  11. package/lib/components/calendar-month.d.ts +7 -1
  12. package/lib/components/calendar-month.js +32 -18
  13. package/lib/components/calendar-navigation.js +19 -6
  14. package/lib/components/calendar.d.ts +24 -5
  15. package/lib/components/calendar.js +83 -37
  16. package/lib/components/checkbox.d.ts +5 -0
  17. package/lib/components/checkbox.js +18 -5
  18. package/lib/components/copy.d.ts +2 -0
  19. package/lib/components/copy.js +15 -31
  20. package/lib/components/date-picker.d.ts +7 -26
  21. package/lib/components/date-picker.js +14 -84
  22. package/lib/components/file-upload.js +6 -2
  23. package/lib/components/input.d.ts +0 -9
  24. package/lib/components/input.js +17 -40
  25. package/lib/components/modal.js +59 -18
  26. package/lib/components/popup.js +56 -5
  27. package/lib/components/radio.d.ts +6 -0
  28. package/lib/components/radio.js +12 -28
  29. package/lib/components/select.d.ts +29 -11
  30. package/lib/components/select.js +43 -57
  31. package/lib/components/switch.d.ts +4 -0
  32. package/lib/components/switch.js +18 -5
  33. package/lib/components/tabs.js +1 -1
  34. package/lib/components/textarea.d.ts +1 -0
  35. package/lib/components/textarea.js +21 -8
  36. package/lib/index.d.ts +1 -0
  37. package/lib/index.js +1 -0
  38. package/lib/styles/button.js +1 -0
  39. package/lib/styles/calendar-month.js +1 -1
  40. package/lib/styles/calendar.js +3 -0
  41. package/lib/styles/checkbox.js +11 -5
  42. package/lib/styles/date-picker.js +5 -10
  43. package/lib/styles/input.js +5 -9
  44. package/lib/styles/option.d.ts +1 -1
  45. package/lib/styles/option.js +4 -0
  46. package/lib/styles/popup.d.ts +1 -1
  47. package/lib/styles/popup.js +2 -0
  48. package/lib/styles/radio.js +10 -4
  49. package/lib/styles/select-base.js +5 -10
  50. package/lib/styles/select.d.ts +1 -1
  51. package/lib/styles/select.js +4 -0
  52. package/lib/styles/switch.js +10 -5
  53. package/lib/styles/tag.js +2 -1
  54. package/lib/utils/calendar-utils.d.ts +49 -0
  55. package/lib/utils/calendar-utils.js +63 -6
  56. package/lib/utils/scroll-lock.d.ts +10 -4
  57. package/lib/utils/scroll-lock.js +102 -2
  58. package/lib/utils/validity.d.ts +27 -1
  59. package/lib/utils/validity.js +68 -16
  60. package/lib/version.d.ts +1 -1
  61. package/lib/version.js +1 -1
  62. package/package.json +1 -1
  63. package/dist/tyrell.js.map +0 -1
  64. package/lib/utils/error-flavor.d.ts +0 -8
  65. package/lib/utils/error-flavor.js +0 -1
@@ -1,6 +1,5 @@
1
1
  import { ensureStyles } from '../utils/styles.js';
2
2
  import { syncCustomFlavorSheet } from '../utils/flavor-sheet.js';
3
- import { ERROR_FLAVOR } from '../utils/error-flavor.js';
4
3
  import { datePickerStyles, datePickerCustomFlavorCss } from '../styles/date-picker.js';
5
4
  import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
6
5
  import { computeAnchoredPosition, placementToAnchored } from '../utils/positioning.js';
@@ -9,6 +8,7 @@ import { isMobileTouch } from '../utils/mobile.js';
9
8
  import { TyComponent } from '../base/ty-component.js';
10
9
  import { DEFAULT_SIZE } from '../types/common.js';
11
10
  import { nativeMessage } from '../utils/validity.js';
11
+ import { parseDateTime, toISODay, wallClockDate } from '../utils/calendar-utils.js';
12
12
  const CALENDAR_ICON_SVG = `<svg stroke='currentColor' fill='none' stroke-width='2' viewBox='0 0 24 24' width='16' height='16' xmlns='http://www.w3.org/2000/svg'><rect x='3' y='4' width='18' height='18' rx='2' ry='2'></rect><line x1='16' y1='2' x2='16' y2='6'></line><line x1='8' y1='2' x2='8' y2='6'></line><line x1='3' y1='10' x2='21' y2='10'></line></svg>`;
13
13
  const CLEAR_ICON_SVG = `<svg stroke='currentColor' fill='none' stroke-width='2' viewBox='0 0 24 24' width='14' height='14' xmlns='http://www.w3.org/2000/svg'><line x1='18' y1='6' x2='6' y2='18'></line><line x1='6' y1='6' x2='18' y2='18'></line></svg>`;
14
14
  const SCHEDULE_ICON_SVG = `<svg stroke='currentColor' fill='none' stroke-width='2' viewBox='0 0 24 24' width='16' height='16' xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='10'></circle><polyline points='12,6 12,12 16,14'></polyline></svg>`;
@@ -22,41 +22,6 @@ elementIds.set(element, id);
22
22
  }
23
23
  return id;
24
24
  }
25
- const LOCAL_VALUE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?$/;
26
- function wallClockDate(c) {
27
- const date = new Date(0);
28
- date.setUTCFullYear(c.year, c.month - 1, c.day);
29
- date.setUTCHours(c.hour || 0, c.minute || 0, c.second || 0);
30
- return date;
31
- }
32
- function parseValue(value) {
33
- if (value === null || value === undefined || value === '')
34
- return null;
35
- if (typeof value === 'string') {
36
- const local = LOCAL_VALUE.exec(value);
37
- if (local) {
38
- const [year, month, day, hour, minute, second] = local.slice(1, 7).map((g) => Number(g ?? 0));
39
- const components = { year, month, day, hour, minute, second };
40
- const check = wallClockDate(components);
41
- const real = check.getUTCMonth() === month - 1 && check.getUTCDate() === day &&
42
- hour < 24 && minute < 60 && second < 60;
43
- return real ? components : null;
44
- }
45
- }
46
- const dateObj = typeof value === 'number' || typeof value === 'string' ? new Date(value) : null;
47
- if (!dateObj || isNaN(dateObj.getTime())) {
48
- return null;
49
- }
50
- return {
51
- year: dateObj.getFullYear(),
52
- month: dateObj.getMonth() + 1,
53
- day: dateObj.getDate(),
54
- hour: dateObj.getHours(),
55
- minute: dateObj.getMinutes(),
56
- second: dateObj.getSeconds(),
57
- epoch: dateObj.getTime(),
58
- };
59
- }
60
25
  function componentsToDateObject(components) {
61
26
  const { year, month, day, hour, minute, second } = components;
62
27
  if (!year || !month || !day)
@@ -248,7 +213,6 @@ if (this._initialized)
248
213
  this._state.open = false;
249
214
  else
250
215
  this.initializeState();
251
- this.syncErrorFlavor();
252
216
  this.render();
253
217
  this.updateFormValue();
254
218
  this._localeObserver = observeLocaleChanges(this, () => {
@@ -271,10 +235,6 @@ onPropertiesChanged(changes) {
271
235
  for (const { name, newValue } of changes) {
272
236
  switch (name) {
273
237
  case 'flavor':
274
- if (newValue !== 'danger') {
275
- this.removeAttribute(ERROR_FLAVOR);
276
- this.syncErrorFlavor();
277
- }
278
238
  this._syncCustomFlavor();
279
239
  break;
280
240
  case 'size':
@@ -287,9 +247,7 @@ break;
287
247
  case 'noClear':
288
248
  case 'format':
289
249
  case 'locale':
290
- break;
291
250
  case 'error':
292
- this.syncErrorFlavor();
293
251
  break;
294
252
  case 'value': {
295
253
  if (!this._initialized || this._syncing)
@@ -297,7 +255,7 @@ break;
297
255
  this._state = {
298
256
  withTime: this._state.withTime,
299
257
  open: this._state.open,
300
- ...parseValue(newValue),
258
+ ...parseDateTime(newValue),
301
259
  };
302
260
  this.syncFormValue();
303
261
  break;
@@ -374,7 +332,7 @@ return formatDisplayValue(c, this.getProperty('format') || 'long', getEffectiveL
374
332
  }
375
333
  initializeState() {
376
334
  this._state = {
377
- ...parseValue(this.getProperty('value')),
335
+ ...parseDateTime(this.getProperty('value')),
378
336
  withTime: this.getProperty('with-time'),
379
337
  open: false,
380
338
  };
@@ -401,27 +359,6 @@ this._syncing = false;
401
359
  }
402
360
  }
403
361
  }
404
- syncErrorFlavor() {
405
- if (!this._connected)
406
- return;
407
- const flavor = this.getProperty('flavor');
408
- const replaced = this.getAttribute(ERROR_FLAVOR);
409
- if (this.getProperty('error')) {
410
- if (replaced === null && (flavor === 'default' || flavor === 'neutral')) {
411
- this.setAttribute(ERROR_FLAVOR, this.getAttribute('flavor') ?? '');
412
- this.setProperty('flavor', 'danger');
413
- }
414
- }
415
- else if (replaced !== null) {
416
- this.removeAttribute(ERROR_FLAVOR);
417
- if (flavor !== 'danger')
418
- return;
419
- if (!replaced)
420
- this.removeAttribute('flavor');
421
- else
422
- this.setProperty('flavor', replaced);
423
- }
424
- }
425
362
  handleTimeInputChange(hour, minute) {
426
363
  if (!this.hasDate())
427
364
  return;
@@ -476,15 +413,7 @@ set size(v) { this.setProperty('size', v); }
476
413
  get placement() { return this.getProperty('placement'); }
477
414
  set placement(v) { this.setProperty('placement', v); }
478
415
  get flavor() { return this.getProperty('flavor'); }
479
- set flavor(v) {
480
- this.removeAttribute(ERROR_FLAVOR);
481
- this.setProperty('flavor', v);
482
- }
483
- attributeChangedCallback(name, oldValue, newValue) {
484
- if (name === 'flavor' && newValue === 'danger' && oldValue === newValue)
485
- this.removeAttribute(ERROR_FLAVOR);
486
- super.attributeChangedCallback(name, oldValue, newValue);
487
- }
416
+ set flavor(v) { this.setProperty('flavor', v); }
488
417
  get label() { return this.getProperty('label'); }
489
418
  set label(v) { this.setProperty('label', v); }
490
419
  get placeholder() { return this.getProperty('placeholder'); }
@@ -514,7 +443,7 @@ set error(v) { this.setProperty('error', v); }
514
443
  get value() {
515
444
  if (this._initialized)
516
445
  return this.getProperty('value') || null;
517
- const parsed = parseValue(this.getProperty('value'));
446
+ const parsed = parseDateTime(this.getProperty('value'));
518
447
  return parsed && componentsToOutputValue(parsed, !!this.getProperty('with-time'), !!this.getProperty('native'));
519
448
  }
520
449
  set value(val) {
@@ -658,7 +587,7 @@ const contentWrapper = document.createElement('div');
658
587
  contentWrapper.className = 'calendar-content';
659
588
  const calendar = document.createElement('ty-calendar');
660
589
  if (this._state.year && this._state.month && this._state.day) {
661
- calendar.setAttribute('year', this._state.year.toString());
590
+ calendar.setAttribute('year', String(this._state.year).padStart(4, '0'));
662
591
  calendar.setAttribute('month', this._state.month.toString());
663
592
  calendar.setAttribute('day', this._state.day.toString());
664
593
  }
@@ -682,14 +611,15 @@ contentWrapper.appendChild(this.renderTimeSection());
682
611
  }
683
612
  dialog.appendChild(contentWrapper);
684
613
  new MutationObserver(() => {
685
- if (this._state.open && !dialog.open && this.isConnected)
614
+ if (this._state.open && !dialog.open && dialog.isConnected && this.isConnected)
686
615
  this.closeDropdown();
687
616
  }).observe(dialog, { attributes: true, attributeFilter: ['open'] });
688
617
  return dialog;
689
618
  }
690
619
  calendarFlavor() {
691
- const replaced = this.getAttribute(ERROR_FLAVOR);
692
- const flavor = replaced ?? this.getProperty('flavor');
620
+ if (this.getProperty('error'))
621
+ return 'danger';
622
+ const flavor = this.getProperty('flavor');
693
623
  return flavor && flavor !== 'default' ? flavor : null;
694
624
  }
695
625
  renderNativeInput() {
@@ -715,7 +645,7 @@ if (!input.value) {
715
645
  this.clearValue();
716
646
  }
717
647
  else {
718
- const parsed = parseValue(input.value);
648
+ const parsed = parseDateTime(input.value);
719
649
  if (parsed) {
720
650
  const { hour, minute, second } = this._state.withTime ? parsed : this._state;
721
651
  const components = { ...parsed, hour, minute, second, epoch: undefined };
@@ -754,8 +684,8 @@ const withTime = this._state.withTime;
754
684
  input.type = withTime ? 'datetime-local' : 'date';
755
685
  input.disabled = !!this.getProperty('disabled');
756
686
  input.required = !!this.getProperty('required');
757
- const min = this.getProperty('min');
758
- const max = this.getProperty('max');
687
+ const min = toISODay(this.getProperty('min'));
688
+ const max = toISODay(this.getProperty('max'));
759
689
  if (min)
760
690
  input.min = withTime ? `${min}T00:00` : min;
761
691
  else
@@ -910,7 +840,7 @@ return;
910
840
  }
911
841
  const calendar = this.shadowRoot.querySelector('ty-calendar');
912
842
  if (calendar && hasValue) {
913
- calendar.setAttribute('year', this._state.year.toString());
843
+ calendar.setAttribute('year', String(this._state.year).padStart(4, '0'));
914
844
  calendar.setAttribute('month', this._state.month.toString());
915
845
  calendar.setAttribute('day', this._state.day.toString());
916
846
  }
@@ -2,6 +2,7 @@ import { TyComponent } from "../base/ty-component.js";
2
2
  import { ensureStyles } from "../utils/styles.js";
3
3
  import { fileUploadStyles } from "../styles/file-upload.js";
4
4
  import { nativeMessage } from "../utils/validity.js";
5
+ const DEFAULT_PLACEHOLDER = "Drop files here or click to browse";
5
6
  const UPLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`;
6
7
  const FILE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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"/><polyline points="14 2 14 8 20 8"/></svg>`;
7
8
  const X_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
@@ -381,9 +382,12 @@ const hintEl = container.querySelector(".upload-hint");
381
382
  if (this._files.length > 0) {
382
383
  hintEl.innerHTML = `<strong>${this._files.length} file${this._files.length !== 1 ? "s" : ""} selected</strong>`;
383
384
  }
384
- else {
385
+ else if (!this.placeholder || this.placeholder === DEFAULT_PLACEHOLDER) {
385
386
  hintEl.innerHTML = `Drop files here or <span class="browse-link">click to browse</span>`;
386
387
  }
388
+ else {
389
+ hintEl.textContent = this.placeholder;
390
+ }
387
391
  const subHintEl = container.querySelector(".upload-sub-hint");
388
392
  if (this.accept && this._files.length === 0) {
389
393
  subHintEl.textContent = `Accepted: ${this.accept}`;
@@ -434,7 +438,7 @@ default: "",
434
438
  placeholder: {
435
439
  type: "string",
436
440
  visual: true,
437
- default: "Drop files here or click to browse",
441
+ default: DEFAULT_PLACEHOLDER,
438
442
  },
439
443
  error: {
440
444
  type: "string",
@@ -151,14 +151,6 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
151
151
  protected onConnect(): void;
152
152
  /** Custom (non-built-in) flavors — see utils/flavor-sheet.ts. */
153
153
  private _syncCustomFlavor;
154
- /**
155
- * `error` turns a neutral field danger; clearing it (the author, or
156
- * validation dropping the message it showed) puts back exactly the flavor
157
- * attribute the author had, or none, kept meanwhile in ERROR_FLAVOR (same
158
- * as ty-select and ty-date-picker). Runs once connected: while the element
159
- * upgrades, `error` may come before the `flavor` written next to it.
160
- */
161
- private syncErrorFlavor;
162
154
  protected onDisconnect(): void;
163
155
  /** Handle property changes — called BEFORE render. */
164
156
  protected onPropertiesChanged(changes: PropertyChange[]): void;
@@ -209,7 +201,6 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
209
201
  set size(v: Size);
210
202
  get flavor(): Flavor;
211
203
  set flavor(v: Flavor);
212
- attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
213
204
  get currency(): string;
214
205
  set currency(v: string);
215
206
  get locale(): string;
@@ -2,7 +2,6 @@ import { DEFAULT_SIZE } from '../types/common.js';
2
2
  import { TyComponent } from '../base/ty-component.js';
3
3
  import { ensureStyles } from '../utils/styles.js';
4
4
  import { syncCustomFlavorSheet } from '../utils/flavor-sheet.js';
5
- import { ERROR_FLAVOR } from '../utils/error-flavor.js';
6
5
  import { inputStyles, inputCustomFlavorCss } from '../styles/input.js';
7
6
  import { formatNumber, parseNumericValue, shouldFormat as shouldFormatType } from '../utils/number-format.js';
8
7
  import { getEffectiveLocale, observeLocaleChanges } from '../utils/locale.js';
@@ -60,32 +59,14 @@ this._localeObserver = observeLocaleChanges(this, () => {
60
59
  this.render();
61
60
  });
62
61
  this._syncCustomFlavor();
63
- this.syncErrorFlavor();
62
+ if (this._rendered) {
63
+ this.render();
64
+ this.setupEventListeners();
65
+ }
64
66
  }
65
67
  _syncCustomFlavor() {
66
68
  this._customFlavorSheet = syncCustomFlavorSheet(this.shadowRoot, this._customFlavorSheet, this.flavor, ({ base }) => inputCustomFlavorCss(base));
67
69
  }
68
- syncErrorFlavor() {
69
- if (!this._connected)
70
- return;
71
- const flavor = this.getProperty('flavor');
72
- const replaced = this.getAttribute(ERROR_FLAVOR);
73
- if (this.getProperty('error')) {
74
- if (replaced === null && flavor === 'neutral') {
75
- this.setAttribute(ERROR_FLAVOR, this.getAttribute('flavor') ?? '');
76
- this.setProperty('flavor', 'danger');
77
- }
78
- }
79
- else if (replaced !== null) {
80
- this.removeAttribute(ERROR_FLAVOR);
81
- if (flavor !== 'danger')
82
- return;
83
- if (!replaced)
84
- this.removeAttribute('flavor');
85
- else
86
- this.setProperty('flavor', replaced);
87
- }
88
- }
89
70
  onDisconnect() {
90
71
  this.removeEventListeners();
91
72
  if (this._localeObserver) {
@@ -105,10 +86,6 @@ onPropertiesChanged(changes) {
105
86
  for (const { name, newValue } of changes) {
106
87
  switch (name) {
107
88
  case 'flavor':
108
- if (newValue !== 'danger') {
109
- this.removeAttribute(ERROR_FLAVOR);
110
- this.syncErrorFlavor();
111
- }
112
89
  this._syncCustomFlavor();
113
90
  break;
114
91
  case 'value':
@@ -118,9 +95,6 @@ case 'type':
118
95
  const currentValue = this.getProperty('value') || '';
119
96
  this._shadowValue = this.parseShadowValue(currentValue);
120
97
  break;
121
- case 'error':
122
- this.syncErrorFlavor();
123
- break;
124
98
  }
125
99
  }
126
100
  }
@@ -226,15 +200,7 @@ set error(v) { this.setProperty('error', v); }
226
200
  get size() { return this.getProperty('size'); }
227
201
  set size(v) { this.setProperty('size', v); }
228
202
  get flavor() { return this.getProperty('flavor'); }
229
- set flavor(v) {
230
- this.removeAttribute(ERROR_FLAVOR);
231
- this.setProperty('flavor', v);
232
- }
233
- attributeChangedCallback(name, oldValue, newValue) {
234
- if (name === 'flavor' && newValue === 'danger' && oldValue === newValue)
235
- this.removeAttribute(ERROR_FLAVOR);
236
- super.attributeChangedCallback(name, oldValue, newValue);
237
- }
203
+ set flavor(v) { this.setProperty('flavor', v); }
238
204
  get currency() { return this.getProperty('currency'); }
239
205
  set currency(v) { this.setProperty('currency', v); }
240
206
  get locale() {
@@ -416,6 +382,9 @@ const existingWrapper = shadow.querySelector('.input-wrapper');
416
382
  const existingLabel = shadow.querySelector('.ty-field-label');
417
383
  const existingError = shadow.querySelector('.error-message');
418
384
  const classes = this.buildClassList();
385
+ const focused = !!existingInput && shadow.activeElement === existingInput;
386
+ if (!focused)
387
+ this._isFocused = false;
419
388
  const inputType = ['password', 'email', 'tel', 'url'].includes(this.type)
420
389
  ? this.type
421
390
  : 'text';
@@ -426,7 +395,9 @@ const inputMode = shouldFormatType(this.type) ? 'decimal'
426
395
  : undefined;
427
396
  const displayValue = this.getDisplayValue();
428
397
  if (existingInput && existingWrapper) {
429
- existingInput.type = inputType;
398
+ const revealed = inputType === 'password' &&
399
+ shadow.querySelector('.password-toggle')?.getAttribute('aria-pressed') === 'true';
400
+ existingInput.type = revealed ? 'text' : inputType;
430
401
  if (existingInput.value !== displayValue)
431
402
  existingInput.value = displayValue;
432
403
  existingInput.placeholder = this.placeholder;
@@ -436,6 +407,7 @@ existingInput.inputMode = inputMode;
436
407
  else
437
408
  existingInput.removeAttribute('inputmode');
438
409
  existingWrapper.className = `input-wrapper ${classes}`;
410
+ existingWrapper.classList.toggle('focused', focused);
439
411
  existingInput.disabled = this.disabled;
440
412
  if (this.disabled) {
441
413
  existingInput.setAttribute('disabled', '');
@@ -541,7 +513,12 @@ return;
541
513
  toggle.addEventListener('mousedown', (e) => e.preventDefault());
542
514
  toggle.addEventListener('click', () => {
543
515
  const reveal = inputEl.type === 'password';
516
+ const { selectionStart, selectionEnd, selectionDirection } = inputEl;
544
517
  inputEl.type = reveal ? 'text' : 'password';
518
+ if (selectionStart !== null && shadow.activeElement === inputEl) {
519
+ inputEl.getBoundingClientRect();
520
+ inputEl.setSelectionRange(selectionStart, selectionEnd, selectionDirection ?? undefined);
521
+ }
545
522
  toggle.innerHTML = reveal ? EYE_OFF_ICON_SVG : EYE_ICON_SVG;
546
523
  toggle.setAttribute('aria-label', reveal ? 'Hide password' : 'Show password');
547
524
  toggle.setAttribute('aria-pressed', String(reveal));
@@ -10,6 +10,13 @@ const hoverEnterHandlers = new WeakMap();
10
10
  const hoverLeaveHandlers = new WeakMap();
11
11
  const modalIds = new WeakMap();
12
12
  const pendingCloseReasons = new WeakMap();
13
+ const detachedOpen = new WeakSet();
14
+ const openStack = [];
15
+ function dropFromStack(el) {
16
+ const i = openStack.indexOf(el);
17
+ if (i >= 0)
18
+ openStack.splice(i, 1);
19
+ }
13
20
  const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
14
21
  function parseBoolAttr(el, name) {
15
22
  if (!el.hasAttribute(name)) {
@@ -108,25 +115,36 @@ return true;
108
115
  }
109
116
  return false;
110
117
  }
111
- function closeNestedDialogs(root, seen = new WeakSet()) {
112
- const close = (node) => {
113
- if (seen.has(node))
114
- return;
115
- seen.add(node);
118
+ function closeNestedDialogs(root) {
119
+ walkNested(root, (node) => {
116
120
  if (node instanceof TyModal && node.open)
117
121
  node.removeAttribute('open');
118
122
  if (node instanceof HTMLDialogElement && node.matches(':modal'))
119
123
  node.close();
120
- if (node.shadowRoot)
121
- closeNestedDialogs(node.shadowRoot, seen);
124
+ return false;
125
+ });
126
+ }
127
+ function hasOpenNestedDialog(root) {
128
+ return walkNested(root, (node) => node instanceof HTMLDialogElement && node.matches(':modal'));
129
+ }
130
+ function walkNested(root, visit, seen = new WeakSet()) {
131
+ const walk = (node) => {
132
+ if (seen.has(node))
133
+ return false;
134
+ seen.add(node);
135
+ if (visit(node))
136
+ return true;
137
+ if (node.shadowRoot && walkNested(node.shadowRoot, visit, seen))
138
+ return true;
122
139
  if (node instanceof HTMLSlotElement) {
123
140
  for (const assigned of node.assignedElements({ flatten: true })) {
124
- close(assigned);
125
- closeNestedDialogs(assigned, seen);
141
+ if (walk(assigned) || walkNested(assigned, visit, seen))
142
+ return true;
126
143
  }
127
144
  }
145
+ return false;
128
146
  };
129
- root.querySelectorAll('*').forEach(close);
147
+ return Array.from(root.querySelectorAll('*')).some(walk);
130
148
  }
131
149
  function handleEscapeKey(el, event) {
132
150
  const dialog = event.currentTarget;
@@ -138,6 +156,22 @@ event.preventDefault();
138
156
  closeModal(el, 'escape');
139
157
  }
140
158
  }
159
+ function handleDocumentEscape(event) {
160
+ if (event.key !== 'Escape' || event.defaultPrevented)
161
+ return;
162
+ const top = openStack[openStack.length - 1];
163
+ if (!top)
164
+ return;
165
+ if (event.composedPath().some((n) => n instanceof HTMLDialogElement && n.open))
166
+ return;
167
+ if (hasOpenNestedDialog(top))
168
+ return;
169
+ event.preventDefault();
170
+ if (getModalAttributes(top).closeOnEscape)
171
+ closeModal(top, 'escape');
172
+ }
173
+ if (typeof document !== 'undefined')
174
+ document.addEventListener('keydown', handleDocumentEscape);
141
175
  function handleCloseButtonClick(el, event) {
142
176
  event.preventDefault();
143
177
  event.stopPropagation();
@@ -273,17 +307,17 @@ setupCloseButtonHover(el, dialog);
273
307
  if (attributes.open) {
274
308
  if (!dialog.open) {
275
309
  lockScroll(modalId);
276
- if (attributes.backdrop) {
277
310
  dialog.showModal();
278
- }
279
- else {
280
- dialog.show();
281
- }
311
+ dropFromStack(el);
312
+ openStack.push(el);
282
313
  syncBackdropZoom();
314
+ if (!detachedOpen.delete(el))
283
315
  dispatchModalEvent(el, 'open', {});
284
316
  }
285
317
  }
286
318
  else {
319
+ detachedOpen.delete(el);
320
+ dropFromStack(el);
287
321
  if (dialog.open) {
288
322
  unlockScroll(modalId);
289
323
  closeNestedDialogs(el);
@@ -296,9 +330,10 @@ dispatchModalEvent(el, 'close', { reason });
296
330
  }
297
331
  syncBackdropZoom();
298
332
  dialog.onclose = (event) => {
299
- if (event.target !== dialog)
333
+ if (event.target !== dialog || dialog.open || !el.isConnected)
300
334
  return;
301
335
  unlockScroll(modalId);
336
+ dropFromStack(el);
302
337
  closeNestedDialogs(el);
303
338
  if (el.hasAttribute('open')) {
304
339
  el.removeAttribute('open');
@@ -311,13 +346,15 @@ syncBackdropZoom();
311
346
  };
312
347
  }
313
348
  function syncBackdropZoom() {
314
- const anyOpen = Array.from(document.querySelectorAll('ty-modal[open], ty-dialog[open]')).some((m) => parseBoolAttr(m, 'open') && !parseBoolAttr(m, 'no-zoom'));
315
- document.documentElement.classList.toggle('ty-modal-zoom', anyOpen);
349
+ const zoom = Array.from(document.querySelectorAll('ty-modal[open], ty-dialog[open]'))
350
+ .some((m) => parseBoolAttr(m, 'open') && !parseBoolAttr(m, 'no-backdrop') && !parseBoolAttr(m, 'no-zoom'));
351
+ document.documentElement.classList.toggle('ty-modal-zoom', zoom);
316
352
  }
317
353
  function cleanup(el) {
318
354
  const modalId = modalIds.get(el);
319
355
  const shadowRoot = el.shadowRoot;
320
356
  const dialog = shadowRoot ? getModalDialog(shadowRoot) : null;
357
+ dropFromStack(el);
321
358
  if (modalId) {
322
359
  unlockScroll(modalId);
323
360
  modalIds.delete(el);
@@ -325,6 +362,10 @@ modalIds.delete(el);
325
362
  syncBackdropZoom();
326
363
  if (!dialog)
327
364
  return;
365
+ if (dialog.open) {
366
+ dialog.close();
367
+ detachedOpen.add(el);
368
+ }
328
369
  const backdropHandler = backdropClickHandlers.get(el);
329
370
  if (backdropHandler) {
330
371
  dialog.removeEventListener('click', backdropHandler);
@@ -9,6 +9,8 @@ const closeTimers = new WeakMap();
9
9
  const escapeHandlers = new WeakMap();
10
10
  const closeRequestHandlers = new WeakMap();
11
11
  const popupIds = new WeakMap();
12
+ const anchors = new WeakMap();
13
+ const escapeGuards = new WeakMap();
12
14
  const pointerPresses = new WeakSet();
13
15
  function parseBoolAttr(el, name) {
14
16
  return el.hasAttribute(name);
@@ -88,7 +90,7 @@ dialog.close();
88
90
  function openPopup(el) {
89
91
  const shadowRoot = el.shadowRoot;
90
92
  const dialog = shadowRoot ? getPopupDialog(shadowRoot) : null;
91
- if (!dialog)
93
+ if (!dialog || !el.isConnected)
92
94
  return;
93
95
  const anchor = getAnchorElement(el);
94
96
  if (dialog.open) {
@@ -105,14 +107,28 @@ return;
105
107
  const popupId = getPopupId(el);
106
108
  lockScroll(popupId);
107
109
  dialog.showModal();
110
+ guardEscape(el);
108
111
  if (anchor?.hasAttribute('aria-haspopup'))
109
112
  anchor.setAttribute('aria-expanded', 'true');
110
113
  requestAnimationFrame(() => {
111
114
  updatePosition(el);
112
115
  dialog.classList.add('preparing-animation');
113
- void dialog.offsetWidth;
116
+ dialog.getBoundingClientRect();
114
117
  requestAnimationFrame(() => {
115
118
  dialog.classList.add('open');
119
+ if (!el.matches(':focus-within')) {
120
+ const target = el.querySelector('[autofocus]');
121
+ target?.focus({ preventScroll: true });
122
+ if (!el.matches(':focus-within')) {
123
+ dialog.focus({ preventScroll: true });
124
+ if (target) {
125
+ requestAnimationFrame(() => requestAnimationFrame(() => {
126
+ if (dialog.open && shadowRoot.activeElement === dialog)
127
+ target.focus({ preventScroll: true });
128
+ }));
129
+ }
130
+ }
131
+ }
116
132
  el.dispatchEvent(new CustomEvent('open'));
117
133
  });
118
134
  });
@@ -153,6 +169,23 @@ return true;
153
169
  }
154
170
  return false;
155
171
  }
172
+ function guardEscape(el) {
173
+ if (escapeGuards.has(el))
174
+ return;
175
+ const guard = (e) => {
176
+ if (e.key === 'Escape' && getPopupAttributes(el).disableClose && !fromNestedDialog(el, e))
177
+ e.preventDefault();
178
+ };
179
+ escapeGuards.set(el, guard);
180
+ document.addEventListener('keydown', guard, true);
181
+ }
182
+ function unguardEscape(el) {
183
+ const guard = escapeGuards.get(el);
184
+ if (!guard)
185
+ return;
186
+ document.removeEventListener('keydown', guard, true);
187
+ escapeGuards.delete(el);
188
+ }
156
189
  function handleEscape(el, event) {
157
190
  if (fromNestedDialog(el, event))
158
191
  return;
@@ -179,14 +212,16 @@ return;
179
212
  anchor.setAttribute('aria-haspopup', 'dialog');
180
213
  if (!anchor.hasAttribute('aria-expanded'))
181
214
  anchor.setAttribute('aria-expanded', 'false');
215
+ const previous = anchors.get(el) ?? anchor;
182
216
  const existingHandler = anchorClickHandlers.get(el);
183
217
  if (existingHandler) {
184
- anchor.removeEventListener('pointerdown', existingHandler);
218
+ previous.removeEventListener('pointerdown', existingHandler);
185
219
  }
186
220
  const existingKeyHandler = anchorKeyClickHandlers.get(el);
187
221
  if (existingKeyHandler) {
188
- anchor.removeEventListener('click', existingKeyHandler);
222
+ previous.removeEventListener('click', existingKeyHandler);
189
223
  }
224
+ anchors.set(el, anchor);
190
225
  const handler = (e) => {
191
226
  pointerPresses.add(el);
192
227
  handleAnchorClick(el, e);
@@ -208,7 +243,8 @@ anchorKeyClickHandlers.set(el, keyHandler);
208
243
  anchor.addEventListener('click', keyHandler);
209
244
  }
210
245
  function cleanupAnchorEvents(el) {
211
- const anchor = getAnchorElement(el);
246
+ const anchor = anchors.get(el);
247
+ anchors.delete(el);
212
248
  const handler = anchorClickHandlers.get(el);
213
249
  if (anchor && handler) {
214
250
  anchor.removeEventListener('pointerdown', handler);
@@ -233,6 +269,7 @@ const dialog = document.createElement('dialog');
233
269
  const container = document.createElement('div');
234
270
  const content = document.createElement('slot');
235
271
  dialog.className = 'popup-dialog';
272
+ dialog.tabIndex = -1;
236
273
  container.className = 'popup-container';
237
274
  content.id = 'popup-content';
238
275
  shadowRoot.appendChild(dialog);
@@ -246,12 +283,23 @@ dialog.addEventListener('pointerdown', outsideHandler);
246
283
  const escapeHandler = (e) => handleEscape(el, e);
247
284
  escapeHandlers.set(el, escapeHandler);
248
285
  el.addEventListener('keydown', escapeHandler);
286
+ const inertKeys = (e) => {
287
+ if ((e.key === 'Enter' || e.key === ' ') && e.composedPath()[0] === dialog)
288
+ e.preventDefault();
289
+ };
290
+ for (const type of ['keydown', 'keypress', 'keyup'])
291
+ dialog.addEventListener(type, inertKeys);
292
+ dialog.addEventListener('cancel', (e) => {
293
+ if (getPopupAttributes(el).disableClose)
294
+ e.preventDefault();
295
+ });
249
296
  new MutationObserver(() => {
250
297
  if (dialog.open)
251
298
  return;
252
299
  clearTimeout(closeTimers.get(el));
253
300
  closeTimers.delete(el);
254
301
  pointerPresses.delete(el);
302
+ unguardEscape(el);
255
303
  unlockScroll(getPopupId(el));
256
304
  dialog.classList.remove('open');
257
305
  dialog.classList.remove('preparing-animation');
@@ -289,12 +337,15 @@ if (outsideHandler) {
289
337
  dialog.removeEventListener('pointerdown', outsideHandler);
290
338
  outsideClickHandlers.delete(el);
291
339
  }
340
+ if (dialog.open)
341
+ dialog.close();
292
342
  }
293
343
  const escapeHandler = escapeHandlers.get(el);
294
344
  if (escapeHandler) {
295
345
  el.removeEventListener('keydown', escapeHandler);
296
346
  escapeHandlers.delete(el);
297
347
  }
348
+ unguardEscape(el);
298
349
  const closeRequestHandler = closeRequestHandlers.get(el);
299
350
  if (closeRequestHandler) {
300
351
  el.removeEventListener('ty:close-popup', closeRequestHandler);