tyrell-components 1.0.0-TC56 → 1.0.0-TC57

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 (42) hide show
  1. package/css/tyrell.css +3 -3
  2. package/dist/tyrell.js +1 -1
  3. package/lib/base/ty-component.d.ts +3 -0
  4. package/lib/base/ty-component.js +5 -1
  5. package/lib/components/calendar.d.ts +2 -0
  6. package/lib/components/calendar.js +9 -0
  7. package/lib/components/checkbox.d.ts +2 -0
  8. package/lib/components/checkbox.js +7 -0
  9. package/lib/components/date-picker.d.ts +2 -0
  10. package/lib/components/date-picker.js +9 -2
  11. package/lib/components/file-upload.d.ts +2 -0
  12. package/lib/components/file-upload.js +15 -2
  13. package/lib/components/input.d.ts +74 -2
  14. package/lib/components/input.js +166 -6
  15. package/lib/components/modal.d.ts +2 -0
  16. package/lib/components/modal.js +12 -9
  17. package/lib/components/option.js +5 -0
  18. package/lib/components/popup.js +16 -4
  19. package/lib/components/radio.d.ts +2 -0
  20. package/lib/components/radio.js +8 -0
  21. package/lib/components/select.d.ts +2 -0
  22. package/lib/components/select.js +12 -0
  23. package/lib/components/selected-tags.d.ts +7 -1
  24. package/lib/components/selected-tags.js +10 -1
  25. package/lib/components/switch.d.ts +2 -0
  26. package/lib/components/switch.js +7 -0
  27. package/lib/components/textarea.d.ts +4 -0
  28. package/lib/components/textarea.js +7 -0
  29. package/lib/styles/date-picker.js +1 -1
  30. package/lib/styles/file-upload.d.ts +1 -1
  31. package/lib/styles/file-upload.js +3 -3
  32. package/lib/styles/icon.d.ts +1 -1
  33. package/lib/styles/icon.js +2 -2
  34. package/lib/styles/input.js +1 -1
  35. package/lib/styles/radio.js +2 -2
  36. package/lib/utils/property-manager.d.ts +6 -0
  37. package/lib/utils/property-manager.js +15 -5
  38. package/lib/utils/validity.d.ts +12 -0
  39. package/lib/utils/validity.js +65 -2
  40. package/lib/version.d.ts +1 -1
  41. package/lib/version.js +1 -1
  42. package/package.json +2 -2
@@ -119,6 +119,9 @@ export declare abstract class TyComponent<T = any> extends HTMLElement {
119
119
  /** Subclasses report their own validity here, not via _internals directly,
120
120
  * so a user's setCustomValidity() survives re-validation. */
121
121
  protected applyValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement): void;
122
+ /** Show the field's current validation message in its error display, if
123
+ * it's invalid. Called on blur after the user edited the field. */
124
+ protected revealValidity(): void;
122
125
  get validity(): ValidityState | undefined;
123
126
  get validationMessage(): string;
124
127
  get willValidate(): boolean;
@@ -89,7 +89,7 @@ for (const [name, config] of Object.entries(ctor.properties)) {
89
89
  if (!config.formValue)
90
90
  continue;
91
91
  const attr = this.getAttribute(this._toKebabCase(name));
92
- this._resetDefaults.set(name, attr ?? config.default ?? null);
92
+ this._resetDefaults.set(name, attr ?? (typeof config.default === 'function' ? config.default() : config.default) ?? null);
93
93
  }
94
94
  }
95
95
  const preSetProps = this._capturePreSetProperties();
@@ -171,6 +171,10 @@ return this.constructor.formAssociated === true;
171
171
  applyValidity(flags, message, anchor) {
172
172
  this._validity.set(flags, message, anchor);
173
173
  }
174
+ revealValidity() {
175
+ if (this._validates)
176
+ this._validity.reveal();
177
+ }
174
178
  get validity() {
175
179
  return this._validates ? this._internals.validity : undefined;
176
180
  }
@@ -128,5 +128,7 @@ export declare class TyCalendar extends HTMLElement {
128
128
  private pickMonth;
129
129
  private handleKeydown;
130
130
  private focusNavTitle;
131
+ /** Focus the grid's tab stop (the focused day, or month in the month view). */
132
+ focus(options?: FocusOptions): void;
131
133
  private focusDayGrid;
132
134
  }
@@ -792,6 +792,15 @@ requestAnimationFrame(() => {
792
792
  this._navigation?.shadowRoot?.querySelector('.nav-title')?.focus();
793
793
  });
794
794
  }
795
+ focus(options) {
796
+ const cell = this._view === 'months'
797
+ ? this._monthGrid?.querySelector('.month-cell[tabindex="0"]')
798
+ : this._monthDisplay?.shadowRoot?.querySelector('.calendar-day-cell[tabindex="0"]');
799
+ if (cell)
800
+ cell.focus(options);
801
+ else
802
+ super.focus(options);
803
+ }
795
804
  focusDayGrid() {
796
805
  requestAnimationFrame(() => {
797
806
  const cell = this._monthDisplay?.shadowRoot?.querySelector('.calendar-day-cell[tabindex="0"]');
@@ -110,6 +110,8 @@ export declare class TyCheckbox extends TyComponent<CheckboxState> implements Ty
110
110
  * Label, required asterisk, and error UI are the consumer's responsibility.
111
111
  */
112
112
  protected render(): void;
113
+ /** Focus the checkbox itself (the host is not focusable; its role element is). */
114
+ focus(options?: FocusOptions): void;
113
115
  /** required + unchecked => valueMissing; otherwise valid. */
114
116
  private updateValidity;
115
117
  get checked(): boolean;
@@ -165,6 +165,13 @@ this.setupEventListeners();
165
165
  this.applyLabelName(checkboxEl);
166
166
  this.updateValidity();
167
167
  }
168
+ focus(options) {
169
+ const el = this.shadowRoot?.querySelector(".checkbox-container");
170
+ if (el)
171
+ el.focus(options);
172
+ else
173
+ super.focus(options);
174
+ }
168
175
  updateValidity() {
169
176
  const anchor = this.shadowRoot?.querySelector(".checkbox-container");
170
177
  if (this.disabled) {
@@ -147,6 +147,8 @@ export declare class TyDatePicker extends TyComponent<DatePickerState> {
147
147
  */
148
148
  protected getFormValue(): FormDataEntryValue | null;
149
149
  protected updateFormValue(): void;
150
+ /** Focus the field: the host itself isn't focusable. */
151
+ focus(options?: FocusOptions): void;
150
152
  /**
151
153
  * `required` used to be cosmetic: nothing reported valueMissing, so an empty
152
154
  * required picker never blocked its form. Disabled pickers don't validate.
@@ -220,7 +220,7 @@ ensureStyles(shadow, { css: datePickerStyles, id: 'ty-date-picker' });
220
220
  onConnect() {
221
221
  this.initializeState();
222
222
  this.render();
223
- this.updateValidity();
223
+ this.updateFormValue();
224
224
  this._localeObserver = observeLocaleChanges(this, () => {
225
225
  this.render();
226
226
  });
@@ -311,9 +311,16 @@ getFormValue() {
311
311
  return componentsToOutputValue(this.getComponents());
312
312
  }
313
313
  updateFormValue() {
314
- super.updateFormValue();
314
+ this._internals.setFormValue(this.getFormValue() ?? '');
315
315
  this.updateValidity();
316
316
  }
317
+ focus(options) {
318
+ const stub = this.shadowRoot?.querySelector('.date-picker-stub');
319
+ if (stub)
320
+ stub.focus(options);
321
+ else
322
+ super.focus(options);
323
+ }
317
324
  updateValidity() {
318
325
  if (this.getProperty('disabled') || !this.getProperty('required') || this.getFormValue()) {
319
326
  this.applyValidity({});
@@ -84,6 +84,8 @@ export declare class TyFileUpload extends TyComponent<FileUploadState> implement
84
84
  do — without this the asterisk showed but submission went through. */
85
85
  private updateValidity;
86
86
  protected getFormValue(): FormData | string | null;
87
+ /** Focus the drop zone: the host itself isn't focusable. */
88
+ focus(options?: FocusOptions): void;
87
89
  /**
88
90
  * Match a file against the `accept` attribute the way native inputs do:
89
91
  * comma-separated list of `.ext`, `type/subtype` or `type/*` entries.
@@ -29,7 +29,9 @@ this._dropHandler = null;
29
29
  this._inputChangeHandler = null;
30
30
  ensureStyles(this.shadowRoot, { css: fileUploadStyles, id: "ty-file-upload" });
31
31
  }
32
- onConnect() { }
32
+ onConnect() {
33
+ this.updateFormValue();
34
+ }
33
35
  onDisconnect() {
34
36
  this.removeEventListeners();
35
37
  }
@@ -57,14 +59,24 @@ this.applyValidity({});
57
59
  }
58
60
  }
59
61
  getFormValue() {
60
- if (this._files.length === 0 || !this.name)
62
+ if (!this.name)
61
63
  return null;
62
64
  const fd = new FormData();
65
+ if (this._files.length === 0) {
66
+ fd.append(this.name, new File([], '', { type: 'application/octet-stream' }));
67
+ }
63
68
  for (const file of this._files) {
64
69
  fd.append(this.name, file);
65
70
  }
66
71
  return fd;
67
72
  }
73
+ focus(options) {
74
+ const zone = this.shadowRoot?.querySelector('.drop-zone');
75
+ if (zone)
76
+ zone.focus(options);
77
+ else
78
+ super.focus(options);
79
+ }
68
80
  matchesAccept(file) {
69
81
  const accept = this.accept?.trim();
70
82
  if (!accept)
@@ -146,6 +158,7 @@ names: this._files.map((f) => f.name),
146
158
  };
147
159
  setTimeout(() => {
148
160
  this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true, composed: true }));
161
+ this.revealValidity();
149
162
  }, 0);
150
163
  }
151
164
  removeFile(index) {
@@ -59,6 +59,41 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
59
59
  visual: boolean;
60
60
  default: boolean;
61
61
  };
62
+ pattern: {
63
+ type: "string";
64
+ visual: boolean;
65
+ default: string;
66
+ };
67
+ minlength: {
68
+ type: "string";
69
+ visual: boolean;
70
+ default: string;
71
+ };
72
+ maxlength: {
73
+ type: "string";
74
+ visual: boolean;
75
+ default: string;
76
+ };
77
+ min: {
78
+ type: "string";
79
+ visual: boolean;
80
+ default: string;
81
+ };
82
+ max: {
83
+ type: "string";
84
+ visual: boolean;
85
+ default: string;
86
+ };
87
+ tooShortMessage: {
88
+ type: "string";
89
+ visual: boolean;
90
+ default: string;
91
+ };
92
+ tooLongMessage: {
93
+ type: "string";
94
+ visual: boolean;
95
+ default: string;
96
+ };
62
97
  error: {
63
98
  type: "string";
64
99
  visual: boolean;
@@ -102,6 +137,8 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
102
137
  };
103
138
  private _shadowValue;
104
139
  private _isFocused;
140
+ /** The user typed since the field was created: blur then shows its validation message. */
141
+ private _userEdited;
105
142
  private _listenersSetup;
106
143
  private _errorAutoDanger;
107
144
  private _customFlavorSheet;
@@ -143,8 +180,24 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
143
180
  set label(v: string);
144
181
  get disabled(): boolean;
145
182
  set disabled(v: boolean);
183
+ /** Focus the inner <input>: the host itself isn't focusable. */
184
+ focus(options?: FocusOptions): void;
146
185
  get required(): boolean;
147
186
  set required(v: boolean);
187
+ get pattern(): string;
188
+ set pattern(v: string);
189
+ get minLength(): number;
190
+ set minLength(v: number);
191
+ get maxLength(): number;
192
+ set maxLength(v: number);
193
+ get min(): string;
194
+ set min(v: string);
195
+ get max(): string;
196
+ set max(v: string);
197
+ get tooShortMessage(): string;
198
+ set tooShortMessage(v: string);
199
+ get tooLongMessage(): string;
200
+ set tooLongMessage(v: string);
148
201
  get error(): string;
149
202
  set error(v: string);
150
203
  get size(): Size;
@@ -165,6 +218,13 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
165
218
  private dispatchChangeEvent;
166
219
  private removeEventListeners;
167
220
  /** IMPORTANT: Only called ONCE, not on every render. */
221
+ /**
222
+ * Enter submits the owning form, like a native input (HTML implicit
223
+ * submission): through the form's default button when it has one (nothing
224
+ * if that button is disabled), otherwise only when this is the form's only
225
+ * text field.
226
+ */
227
+ private _implicitSubmit;
168
228
  private setupEventListeners;
169
229
  /** Render the input component with wrapper and slots. */
170
230
  render(): void;
@@ -174,10 +234,22 @@ export declare class TyInput extends TyComponent<InputState> implements TyInputE
174
234
  * `type` property stays "password" (form value and API unaffected).
175
235
  */
176
236
  private setupPasswordToggle;
237
+ /**
238
+ * pattern and maxlength go on the inner <input>: pattern for the browser's
239
+ * own patternMismatch check, maxlength so typing stops at the limit like a
240
+ * native field. Formatted (numeric) types show a display string, so they
241
+ * get neither.
242
+ */
243
+ private syncConstraintAttributes;
177
244
  /**
178
245
  * Reflect constraint validation to the owning <form> via ElementInternals.
179
- * Without this `required` is only cosmetic — the inner shadow <input> can't
180
- * block the outer form. Empty + required => valueMissing; otherwise valid.
246
+ * Without this the inner shadow <input> can't block the outer form.
247
+ * Rules, first failing one supplies the message:
248
+ * - required: empty => valueMissing
249
+ * - email/url: the inner input's own typeMismatch
250
+ * - text types: pattern, minlength, maxlength
251
+ * - number/currency/percent/compact: min, max against the numeric value
252
+ * Empty values only fail `required`, like native inputs.
181
253
  * Disabled fields are barred from constraint validation.
182
254
  */
183
255
  private updateValidity;
@@ -13,6 +13,7 @@ constructor() {
13
13
  super();
14
14
  this._shadowValue = null;
15
15
  this._isFocused = false;
16
+ this._userEdited = false;
16
17
  this._listenersSetup = false;
17
18
  this._errorAutoDanger = false;
18
19
  this._customFlavorSheet = null;
@@ -22,6 +23,34 @@ this._focusHandler = null;
22
23
  this._blurHandler = null;
23
24
  this._inputDebounceTimer = null;
24
25
  this._changeDebounceTimer = null;
26
+ this._implicitSubmit = (e) => {
27
+ if (e.key !== 'Enter' || e.isComposing || e.defaultPrevented)
28
+ return;
29
+ const form = this._internals.form;
30
+ if (!form)
31
+ return;
32
+ const controls = Array.from(form.elements);
33
+ const isSubmitter = (el) => (el instanceof HTMLButtonElement && el.type === 'submit') ||
34
+ (el instanceof HTMLInputElement && (el.type === 'submit' || el.type === 'image')) ||
35
+ (el.tagName === 'TY-BUTTON' && !['button', 'reset'].includes(el.getAttribute('type') ?? ''));
36
+ const submitter = controls.find(isSubmitter);
37
+ if (submitter) {
38
+ if (submitter.disabled || submitter.hasAttribute('disabled'))
39
+ return;
40
+ e.preventDefault();
41
+ const target = submitter.tagName === 'TY-BUTTON'
42
+ ? submitter.shadowRoot?.querySelector('button') ?? submitter
43
+ : submitter;
44
+ target.click();
45
+ return;
46
+ }
47
+ const textFields = controls.filter((el) => el.tagName === 'TY-INPUT' ||
48
+ (el instanceof HTMLInputElement && !['button', 'checkbox', 'radio', 'file', 'hidden', 'image', 'reset', 'submit', 'range', 'color'].includes(el.type)));
49
+ if (textFields.length === 1) {
50
+ e.preventDefault();
51
+ form.requestSubmit();
52
+ }
53
+ };
25
54
  const shadow = this.shadowRoot;
26
55
  ensureStyles(shadow, { css: inputStyles, id: 'ty-input' });
27
56
  }
@@ -95,14 +124,17 @@ this._changeDebounceTimer = null;
95
124
  this.render();
96
125
  }
97
126
  getFormValue() {
98
- const formValue = this._shadowValue !== null ? String(this._shadowValue) : null;
127
+ const formValue = this._shadowValue !== null ? String(this._shadowValue) : '';
99
128
  return formValue;
100
129
  }
101
130
  initializeShadowValue() {
102
131
  const currentValue = this.getProperty('value');
103
132
  if (currentValue) {
104
133
  this._shadowValue = this.parseShadowValue(currentValue);
105
- this._internals.setFormValue(this._shadowValue !== null ? String(this._shadowValue) : null);
134
+ this._internals.setFormValue(this._shadowValue !== null ? String(this._shadowValue) : '');
135
+ }
136
+ else {
137
+ this._internals.setFormValue('');
106
138
  }
107
139
  }
108
140
  parseShadowValue(value) {
@@ -155,8 +187,25 @@ get label() { return this.getProperty('label'); }
155
187
  set label(v) { this.setProperty('label', v); }
156
188
  get disabled() { return this.getProperty('disabled'); }
157
189
  set disabled(v) { this.setProperty('disabled', v); }
190
+ focus(options) {
191
+ this.shadowRoot?.querySelector('input')?.focus(options);
192
+ }
158
193
  get required() { return this.getProperty('required'); }
159
194
  set required(v) { this.setProperty('required', v); }
195
+ get pattern() { return this.getProperty('pattern'); }
196
+ set pattern(v) { this.setProperty('pattern', v ?? ''); }
197
+ get minLength() { return toLimit(this.getProperty('minlength')) ?? -1; }
198
+ set minLength(v) { this.setProperty('minlength', v == null || v < 0 ? '' : String(v)); }
199
+ get maxLength() { return toLimit(this.getProperty('maxlength')) ?? -1; }
200
+ set maxLength(v) { this.setProperty('maxlength', v == null || v < 0 ? '' : String(v)); }
201
+ get min() { return this.getProperty('min'); }
202
+ set min(v) { this.setProperty('min', v == null ? '' : String(v)); }
203
+ get max() { return this.getProperty('max'); }
204
+ set max(v) { this.setProperty('max', v == null ? '' : String(v)); }
205
+ get tooShortMessage() { return this.getProperty('tooShortMessage'); }
206
+ set tooShortMessage(v) { this.setProperty('tooShortMessage', v ?? ''); }
207
+ get tooLongMessage() { return this.getProperty('tooLongMessage'); }
208
+ set tooLongMessage(v) { this.setProperty('tooLongMessage', v ?? ''); }
160
209
  get error() { return this.getProperty('error'); }
161
210
  set error(v) { this.setProperty('error', v); }
162
211
  get size() { return this.getProperty('size'); }
@@ -235,6 +284,7 @@ if (this._blurHandler) {
235
284
  inputEl.removeEventListener('blur', this._blurHandler);
236
285
  this._blurHandler = null;
237
286
  }
287
+ inputEl.removeEventListener('keydown', this._implicitSubmit);
238
288
  this._listenersSetup = false;
239
289
  }
240
290
  setupEventListeners() {
@@ -249,12 +299,13 @@ console.warn('[TyInput.setupEventListeners] ⚠️ Input element not found!');
249
299
  return;
250
300
  }
251
301
  this._inputHandler = (e) => {
302
+ this._userEdited = true;
252
303
  e.stopPropagation();
253
304
  e.stopImmediatePropagation();
254
305
  const target = e.target;
255
306
  const rawValue = target.value;
256
307
  this._shadowValue = this.parseShadowValue(rawValue);
257
- this._internals.setFormValue(this._shadowValue !== null ? String(this._shadowValue) : null);
308
+ this._internals.setFormValue(this._shadowValue !== null ? String(this._shadowValue) : '');
258
309
  this.updateValidity();
259
310
  if (this._inputDebounceTimer !== null) {
260
311
  clearTimeout(this._inputDebounceTimer);
@@ -275,7 +326,7 @@ e.stopImmediatePropagation();
275
326
  const target = e.target;
276
327
  const rawValue = target.value;
277
328
  this._shadowValue = this.parseShadowValue(rawValue);
278
- this._internals.setFormValue(this._shadowValue !== null ? String(this._shadowValue) : null);
329
+ this._internals.setFormValue(this._shadowValue !== null ? String(this._shadowValue) : '');
279
330
  this.updateValidity();
280
331
  if (this._changeDebounceTimer !== null) {
281
332
  clearTimeout(this._changeDebounceTimer);
@@ -317,6 +368,8 @@ this.dispatchChangeEvent(rawValue, e);
317
368
  }
318
369
  this._isFocused = false;
319
370
  wrapperEl.classList.remove('focused');
371
+ if (this._userEdited)
372
+ this.revealValidity();
320
373
  if (this.shouldFormat()) {
321
374
  inputEl.value = this.getDisplayValue();
322
375
  }
@@ -330,6 +383,7 @@ inputEl.addEventListener('input', this._inputHandler);
330
383
  inputEl.addEventListener('change', this._changeHandler);
331
384
  inputEl.addEventListener('focus', this._focusHandler);
332
385
  inputEl.addEventListener('blur', this._blurHandler);
386
+ inputEl.addEventListener('keydown', this._implicitSubmit);
333
387
  this._listenersSetup = true;
334
388
  }
335
389
  render() {
@@ -372,6 +426,7 @@ existingInput.setAttribute('required', '');
372
426
  else {
373
427
  existingInput.removeAttribute('required');
374
428
  }
429
+ this.syncConstraintAttributes(existingInput);
375
430
  if (this.label)
376
431
  existingInput.setAttribute('aria-label', this.label);
377
432
  else
@@ -445,6 +500,7 @@ ${errorHtml}
445
500
  const inputEl = shadow.querySelector('input');
446
501
  inputEl.disabled = this.disabled;
447
502
  inputEl.required = this.required;
503
+ this.syncConstraintAttributes(inputEl);
448
504
  if (this.label)
449
505
  inputEl.setAttribute('aria-label', this.label);
450
506
  this.setupPasswordToggle();
@@ -467,6 +523,12 @@ toggle.setAttribute('aria-label', reveal ? 'Hide password' : 'Show password');
467
523
  toggle.setAttribute('aria-pressed', String(reveal));
468
524
  });
469
525
  }
526
+ syncConstraintAttributes(inputEl) {
527
+ const text = !shouldFormatType(this.type);
528
+ const set = (name, v) => text && v !== '' ? inputEl.setAttribute(name, v) : inputEl.removeAttribute(name);
529
+ set('pattern', this.pattern);
530
+ set('maxlength', toLimit(this.getProperty('maxlength')) != null ? String(this.maxLength) : '');
531
+ }
470
532
  updateValidity() {
471
533
  const inputEl = this.shadowRoot?.querySelector('input');
472
534
  if (this.disabled) {
@@ -474,12 +536,48 @@ this.applyValidity({});
474
536
  return;
475
537
  }
476
538
  const current = inputEl ? inputEl.value : String(this.getProperty('value') ?? '');
477
- if (this.required && current.trim() === '') {
478
- this.applyValidity({ valueMissing: true }, 'Please fill out this field', inputEl ?? undefined);
539
+ if (current.trim() === '') {
540
+ if (this.required) {
541
+ this.applyValidity({ valueMissing: true }, nativeMessage('text', { required: '' }, ''), inputEl ?? undefined);
479
542
  }
480
543
  else {
481
544
  this.applyValidity({});
482
545
  }
546
+ return;
547
+ }
548
+ const flags = {};
549
+ const messages = [];
550
+ const fail = (flag, message) => {
551
+ flags[flag] = true;
552
+ messages.push(message);
553
+ };
554
+ if (shouldFormatType(this.type)) {
555
+ const n = typeof this._shadowValue === 'number' ? this._shadowValue : null;
556
+ const min = toNumber(this.min);
557
+ const max = toNumber(this.max);
558
+ const range = (flag) => fail(flag, nativeMessage('number', { step: 'any', min: this.min, max: this.max }, String(n)));
559
+ if (n !== null && min !== null && n < min)
560
+ range('rangeUnderflow');
561
+ if (n !== null && max !== null && n > max)
562
+ range('rangeOverflow');
563
+ }
564
+ else {
565
+ if (inputEl?.validity.typeMismatch)
566
+ fail('typeMismatch', inputEl.validationMessage);
567
+ if (inputEl?.validity.patternMismatch)
568
+ fail('patternMismatch', inputEl.validationMessage);
569
+ const length = current.length;
570
+ const minLength = toLimit(this.getProperty('minlength'));
571
+ const maxLength = toLimit(this.getProperty('maxlength'));
572
+ const vars = { minlength: minLength, maxlength: maxLength, length };
573
+ if (minLength !== null && length < minLength) {
574
+ fail('tooShort', fillMessage(this.tooShortMessage || 'Use at least {minlength} characters (now {length})', vars));
575
+ }
576
+ if (maxLength !== null && length > maxLength) {
577
+ fail('tooLong', fillMessage(this.tooLongMessage || 'Use at most {maxlength} characters (now {length})', vars));
578
+ }
579
+ }
580
+ this.applyValidity(flags, messages[0], inputEl ?? undefined);
483
581
  }
484
582
  }
485
583
  TyInput.formAssociated = true;
@@ -538,6 +636,41 @@ type: 'boolean',
538
636
  visual: true,
539
637
  default: false
540
638
  },
639
+ pattern: {
640
+ type: 'string',
641
+ visual: true,
642
+ default: ''
643
+ },
644
+ minlength: {
645
+ type: 'string',
646
+ visual: true,
647
+ default: ''
648
+ },
649
+ maxlength: {
650
+ type: 'string',
651
+ visual: true,
652
+ default: ''
653
+ },
654
+ min: {
655
+ type: 'string',
656
+ visual: true,
657
+ default: ''
658
+ },
659
+ max: {
660
+ type: 'string',
661
+ visual: true,
662
+ default: ''
663
+ },
664
+ tooShortMessage: {
665
+ type: 'string',
666
+ visual: true,
667
+ default: ''
668
+ },
669
+ tooLongMessage: {
670
+ type: 'string',
671
+ visual: true,
672
+ default: ''
673
+ },
541
674
  error: {
542
675
  type: 'string',
543
676
  visual: true,
@@ -604,6 +737,33 @@ return Math.max(0, Math.min(5000, Math.floor(num)));
604
737
  }
605
738
  }
606
739
  };
740
+ let probe = null;
741
+ function nativeMessage(type, attrs, value) {
742
+ probe ?? (probe = document.createElement('input'));
743
+ for (const name of Array.from(probe.getAttributeNames()))
744
+ probe.removeAttribute(name);
745
+ probe.type = type;
746
+ for (const [name, v] of Object.entries(attrs))
747
+ if (v !== '' || name === 'required')
748
+ probe.setAttribute(name, v);
749
+ probe.value = value;
750
+ return probe.validationMessage;
751
+ }
752
+ function fillMessage(template, vars) {
753
+ return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] != null ? String(vars[key]) : match);
754
+ }
755
+ function toLimit(v) {
756
+ if (v === null || v === undefined || String(v).trim() === '')
757
+ return null;
758
+ const n = Number(v);
759
+ return Number.isInteger(n) && n >= 0 ? n : null;
760
+ }
761
+ function toNumber(v) {
762
+ if (v === null || v === undefined || String(v).trim() === '')
763
+ return null;
764
+ const n = Number(v);
765
+ return Number.isFinite(n) ? n : null;
766
+ }
607
767
  if (!customElements.get('ty-input')) {
608
768
  customElements.define('ty-input', TyInput);
609
769
  }
@@ -52,6 +52,8 @@ export declare class TyModal extends HTMLElement {
52
52
  set open(v: boolean);
53
53
  get noBackdrop(): boolean;
54
54
  set noBackdrop(v: boolean);
55
+ get noZoom(): boolean;
56
+ set noZoom(v: boolean);
55
57
  get preventOutsideClick(): boolean;
56
58
  set preventOutsideClick(v: boolean);
57
59
  get preventEscape(): boolean;
@@ -1,4 +1,5 @@
1
1
  import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
2
+ import { coerceBoolean } from '../utils/property-manager.js';
2
3
  import { ensureStyles } from '../utils/styles.js';
3
4
  import { modalStyles } from '../styles/modal.js';
4
5
  const backdropClickHandlers = new WeakMap();
@@ -259,6 +260,7 @@ pendingCloseReasons.delete(el);
259
260
  dispatchModalEvent(el, 'close', { reason });
260
261
  }
261
262
  }
263
+ syncBackdropZoom();
262
264
  dialog.onclose = (event) => {
263
265
  if (event.target !== dialog)
264
266
  return;
@@ -274,10 +276,7 @@ syncBackdropZoom();
274
276
  };
275
277
  }
276
278
  function syncBackdropZoom() {
277
- const anyOpen = Array.from(document.querySelectorAll('ty-modal[open][backdrop-zoom], ty-dialog[open][backdrop-zoom]')).some((m) => {
278
- const v = m.getAttribute('backdrop-zoom').toLowerCase().trim();
279
- return v !== 'false' && v !== '0';
280
- });
279
+ const anyOpen = Array.from(document.querySelectorAll('ty-modal[open], ty-dialog[open]')).some((m) => parseBoolAttr(m, 'open') && !parseBoolAttr(m, 'no-zoom'));
281
280
  document.documentElement.classList.toggle('ty-modal-zoom', anyOpen);
282
281
  }
283
282
  function cleanup(el) {
@@ -325,21 +324,25 @@ hoverLeaveHandlers.delete(el);
325
324
  }
326
325
  export class TyModal extends HTMLElement {
327
326
  static get observedAttributes() {
328
- return ['open', 'no-backdrop', 'prevent-outside-click', 'prevent-escape', 'label'];
327
+ return ['open', 'no-backdrop', 'no-zoom', 'prevent-outside-click', 'prevent-escape', 'label'];
329
328
  }
330
329
  get open() { return parseBoolAttr(this, 'open'); }
331
- set open(v) { v ? this.setAttribute('open', 'true') : this.removeAttribute('open'); }
330
+ set open(v) { coerceBoolean(v) ? this.setAttribute('open', 'true') : this.removeAttribute('open'); }
332
331
  get noBackdrop() { return parseBoolAttr(this, 'no-backdrop'); }
333
332
  set noBackdrop(v) {
334
- v ? this.setAttribute('no-backdrop', '') : this.removeAttribute('no-backdrop');
333
+ coerceBoolean(v) ? this.setAttribute('no-backdrop', '') : this.removeAttribute('no-backdrop');
334
+ }
335
+ get noZoom() { return parseBoolAttr(this, 'no-zoom'); }
336
+ set noZoom(v) {
337
+ coerceBoolean(v) ? this.setAttribute('no-zoom', '') : this.removeAttribute('no-zoom');
335
338
  }
336
339
  get preventOutsideClick() { return parseBoolAttr(this, 'prevent-outside-click'); }
337
340
  set preventOutsideClick(v) {
338
- v ? this.setAttribute('prevent-outside-click', '') : this.removeAttribute('prevent-outside-click');
341
+ coerceBoolean(v) ? this.setAttribute('prevent-outside-click', '') : this.removeAttribute('prevent-outside-click');
339
342
  }
340
343
  get preventEscape() { return parseBoolAttr(this, 'prevent-escape'); }
341
344
  set preventEscape(v) {
342
- v ? this.setAttribute('prevent-escape', '') : this.removeAttribute('prevent-escape');
345
+ coerceBoolean(v) ? this.setAttribute('prevent-escape', '') : this.removeAttribute('prevent-escape');
343
346
  }
344
347
  constructor() {
345
348
  super();
@@ -1,4 +1,5 @@
1
1
  import { ensureStyles } from '../utils/styles.js';
2
+ import { coerceBoolean } from '../utils/property-manager.js';
2
3
  import { optionStyles } from '../styles/option.js';
3
4
  const CHECK_SVG = `
4
5
  <svg class="option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -80,6 +81,7 @@ get selected() {
80
81
  return this._selected;
81
82
  }
82
83
  set selected(value) {
84
+ value = coerceBoolean(value);
83
85
  if (this._selected !== value) {
84
86
  this._selected = value;
85
87
  if (value) {
@@ -95,6 +97,7 @@ get disabled() {
95
97
  return this._disabled;
96
98
  }
97
99
  set disabled(value) {
100
+ value = coerceBoolean(value);
98
101
  if (this._disabled !== value) {
99
102
  this._disabled = value;
100
103
  if (value) {
@@ -110,6 +113,7 @@ get highlighted() {
110
113
  return this._highlighted;
111
114
  }
112
115
  set highlighted(value) {
116
+ value = coerceBoolean(value);
113
117
  if (this._highlighted !== value) {
114
118
  this._highlighted = value;
115
119
  if (value) {
@@ -125,6 +129,7 @@ get hidden() {
125
129
  return this._hidden;
126
130
  }
127
131
  set hidden(value) {
132
+ value = coerceBoolean(value);
128
133
  if (this._hidden !== value) {
129
134
  this._hidden = value;
130
135
  if (value) {