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
@@ -5,6 +5,7 @@ import { popupStyles } from '../styles/popup.js';
5
5
  const anchorClickHandlers = new WeakMap();
6
6
  const anchorKeyClickHandlers = new WeakMap();
7
7
  const outsideClickHandlers = new WeakMap();
8
+ const closeTimers = new WeakMap();
8
9
  const escapeHandlers = new WeakMap();
9
10
  const closeRequestHandlers = new WeakMap();
10
11
  const popupIds = new WeakMap();
@@ -76,18 +77,29 @@ if (anchor?.hasAttribute('aria-haspopup'))
76
77
  anchor.setAttribute('aria-expanded', 'false');
77
78
  const exitMs = Math.max(0, ...getComputedStyle(dialog).transitionDuration
78
79
  .split(',').map((d) => parseFloat(d) * (d.trim().endsWith('ms') ? 1 : 1000)));
79
- setTimeout(() => {
80
+ clearTimeout(closeTimers.get(el));
81
+ closeTimers.set(el, setTimeout(() => {
82
+ closeTimers.delete(el);
80
83
  dialog.classList.remove('position-calculated');
81
84
  dialog.close();
82
- }, exitMs + 20);
85
+ }, exitMs + 20));
83
86
  }
84
87
  function openPopup(el) {
85
- const popupId = getPopupId(el);
86
- lockScroll(popupId);
87
88
  const shadowRoot = el.shadowRoot;
88
89
  const dialog = shadowRoot ? getPopupDialog(shadowRoot) : null;
89
90
  if (!dialog)
90
91
  return;
92
+ if (dialog.open) {
93
+ const pending = closeTimers.get(el);
94
+ if (pending !== undefined) {
95
+ clearTimeout(pending);
96
+ closeTimers.delete(el);
97
+ dialog.classList.add('open');
98
+ }
99
+ return;
100
+ }
101
+ const popupId = getPopupId(el);
102
+ lockScroll(popupId);
91
103
  dialog.showModal();
92
104
  const anchor = getAnchorElement(el);
93
105
  if (anchor?.hasAttribute('aria-haspopup'))
@@ -160,6 +160,8 @@ export declare class TyRadioGroup extends TyComponent<RadioGroupState> implement
160
160
  * the accessors after upgrade. Each radio fires ty-radio-connected once
161
161
  * upgraded, which re-syncs the group.
162
162
  */
163
+ /** Focus the group's tab stop: the checked radio, else the first enabled one. */
164
+ focus(options?: FocusOptions): void;
163
165
  private getRadios;
164
166
  private syncChildren;
165
167
  /** Report `required` to the owning form — without this the asterisk
@@ -184,6 +184,14 @@ this.syncChildren();
184
184
  getFormValue() {
185
185
  return this.value || null;
186
186
  }
187
+ focus(options) {
188
+ const enabled = this.getRadios().filter((r) => !r.disabled);
189
+ const stop = enabled.find((r) => r.checked) ?? enabled[0];
190
+ if (stop)
191
+ stop.focus(options);
192
+ else
193
+ super.focus(options);
194
+ }
187
195
  getRadios() {
188
196
  return Array.from(this.querySelectorAll("ty-radio"))
189
197
  .filter((r) => typeof r.setTabStop === "function");
@@ -207,6 +207,8 @@ export declare class TySelect extends TyComponent<SelectState> {
207
207
  private applyLoadingState;
208
208
  /** Form value: a plain entry for single, FormData with repeated entries for multiple (HTMX standard). */
209
209
  protected getFormValue(): FormDataEntryValue | FormData | null;
210
+ /** Focus the trigger: a slotted one, else the field. The host itself isn't focusable. */
211
+ focus(options?: FocusOptions): void;
210
212
  private parseValue;
211
213
  /** Initialize component state from the value property / pre-selected children. */
212
214
  private initializeState;
@@ -96,6 +96,7 @@ dialog.close();
96
96
  });
97
97
  this.render();
98
98
  this.initializeState();
99
+ this.updateFormValue();
99
100
  this._childObserver = new MutationObserver(() => {
100
101
  this.ensureOptionRoles();
101
102
  if (this._state.selectedValues.length > 0) {
@@ -250,8 +251,19 @@ formData.append(this._name, value);
250
251
  });
251
252
  return formData;
252
253
  }
254
+ if (this._name && !this._multiple)
255
+ return "";
253
256
  return null;
254
257
  }
258
+ focus(options) {
259
+ const trigger = this.querySelector(':scope > [slot="trigger"]');
260
+ const stub = this.shadowRoot?.querySelector(".select-stub");
261
+ const target = trigger ?? stub;
262
+ if (target)
263
+ target.focus(options);
264
+ else
265
+ super.focus(options);
266
+ }
255
267
  parseValue(value) {
256
268
  if (!value || typeof value !== "string" || value.trim() === "")
257
269
  return [];
@@ -28,6 +28,8 @@ export declare class TySelectedOptions extends HTMLElement {
28
28
  * Interpolation context for a value: value, label, flavor plus every
29
29
  * data-* attribute of the picker's matching option.
30
30
  */
31
+ /** The picker's option for a value (never its display clone). */
32
+ private option;
31
33
  private context;
32
34
  /**
33
35
  * The template's blueprint fragment. HTML-parsed templates carry children in
@@ -42,7 +44,11 @@ export declare class TySelectedOptions extends HTMLElement {
42
44
  * no flavor on the option).
43
45
  */
44
46
  private stampTemplate;
45
- /** Default chip when no template is provided. */
47
+ /**
48
+ * Default chip when no template is provided: the option's own content,
49
+ * cloned, so a rich option makes a rich chip. An explicit `label` on the
50
+ * option replaces it with that text; a <template> child replaces it entirely.
51
+ */
46
52
  private defaultChip;
47
53
  private renderChips;
48
54
  /** Remove a value from the picker; picker re-emits change → chips re-render. */
@@ -93,8 +93,11 @@ return parseValues(this._picker?.value ?? '');
93
93
  template() {
94
94
  return this.querySelector(':scope > template');
95
95
  }
96
+ option(value) {
97
+ return this._picker?.querySelector(`[value="${CSS.escape(value)}"]:not([cloned])`);
98
+ }
96
99
  context(value) {
97
- const opt = this._picker?.querySelector(`[value="${CSS.escape(value)}"]`);
100
+ const opt = this.option(value);
98
101
  const ctx = {
99
102
  value,
100
103
  label: opt?.getAttribute('label') || opt?.textContent?.trim() || value,
@@ -150,12 +153,18 @@ return clone;
150
153
  }
151
154
  defaultChip(value) {
152
155
  const ctx = this.context(value);
156
+ const opt = this.option(value);
153
157
  const tag = document.createElement('ty-tag');
154
158
  tag.setAttribute('value', value);
155
159
  tag.setAttribute('dismissible', 'true');
156
160
  if (ctx.flavor)
157
161
  tag.setAttribute('flavor', ctx.flavor);
162
+ if (opt && !opt.hasAttribute('label') && opt.hasChildNodes()) {
163
+ opt.childNodes.forEach(n => tag.appendChild(n.cloneNode(true)));
164
+ }
165
+ else {
158
166
  tag.textContent = ctx.label;
167
+ }
159
168
  tag.addEventListener('dismiss', () => this.deselect(value));
160
169
  return tag;
161
170
  }
@@ -82,6 +82,8 @@ export declare class TySwitch extends TyComponent<SwitchState> implements TySwit
82
82
  private removeEventListeners;
83
83
  private setupEventListeners;
84
84
  protected render(): void;
85
+ /** Focus the switch itself (the host is not focusable; its role element is). */
86
+ focus(options?: FocusOptions): void;
85
87
  /** required + unchecked => valueMissing; otherwise valid. */
86
88
  private updateValidity;
87
89
  get checked(): boolean;
@@ -145,6 +145,13 @@ this.setupEventListeners();
145
145
  this.applyLabelName(switchEl);
146
146
  this.updateValidity();
147
147
  }
148
+ focus(options) {
149
+ const el = this.shadowRoot?.querySelector(".switch-container");
150
+ if (el)
151
+ el.focus(options);
152
+ else
153
+ super.focus(options);
154
+ }
148
155
  updateValidity() {
149
156
  const anchor = this.shadowRoot?.querySelector(".switch-container");
150
157
  if (this.disabled) {
@@ -125,6 +125,8 @@ export declare class TyTextarea extends TyComponent<TextareaState> implements Ty
125
125
  set label(v: string);
126
126
  get disabled(): boolean;
127
127
  set disabled(v: boolean);
128
+ /** Focus the inner <textarea>: the host itself isn't focusable. */
129
+ focus(options?: FocusOptions): void;
128
130
  get required(): boolean;
129
131
  set required(v: boolean);
130
132
  get error(): string;
@@ -151,6 +153,8 @@ export declare class TyTextarea extends TyComponent<TextareaState> implements Ty
151
153
  private _destroyScrollbar;
152
154
  private emitValueEvent;
153
155
  private handleInputEvent;
156
+ /** The user typed since the field was created: blur then shows its validation message. */
157
+ private _userEdited;
154
158
  private _valueAtFocus;
155
159
  private handleFocusEvent;
156
160
  private handleBlurEvent;
@@ -13,6 +13,7 @@ this._dummyEl = null;
13
13
  this._scrollbar = null;
14
14
  this._isFocused = false;
15
15
  this.handleInputEvent = (e) => {
16
+ this._userEdited = true;
16
17
  const target = e.target;
17
18
  const newValue = target.value;
18
19
  this.value = newValue;
@@ -23,6 +24,7 @@ this._internals.setFormValue(this.value || '');
23
24
  this.updateValidity();
24
25
  this.emitValueEvent('input', e);
25
26
  };
27
+ this._userEdited = false;
26
28
  this._valueAtFocus = null;
27
29
  this.handleFocusEvent = () => {
28
30
  this._isFocused = true;
@@ -30,6 +32,8 @@ this._valueAtFocus = this.value;
30
32
  };
31
33
  this.handleBlurEvent = (e) => {
32
34
  this._isFocused = false;
35
+ if (this._userEdited)
36
+ this.revealValidity();
33
37
  if (this._valueAtFocus !== null && this.value !== this._valueAtFocus) {
34
38
  this.emitValueEvent('change', e);
35
39
  }
@@ -67,6 +71,9 @@ get label() { return this.getProperty('label'); }
67
71
  set label(v) { this.setProperty('label', v); }
68
72
  get disabled() { return this.getProperty('disabled'); }
69
73
  set disabled(v) { this.setProperty('disabled', v); }
74
+ focus(options) {
75
+ this.shadowRoot?.querySelector('textarea')?.focus(options);
76
+ }
70
77
  get required() { return this.getProperty('required'); }
71
78
  set required(v) { this.setProperty('required', v); }
72
79
  get error() { return this.getProperty('error'); }
@@ -92,7 +92,7 @@ align-items: center;
92
92
  justify-content: center;
93
93
  flex-shrink: 0;
94
94
  margin-right: 0.5rem;
95
- color: var(--ty-color-text-soft);
95
+ color: var(--ty-text-soft);
96
96
  }
97
97
  .date-picker-stub ::slotted(ty-icon[slot="start"]) {
98
98
  width: 1em;
@@ -1 +1 @@
1
- export declare const fileUploadStyles = "\n:host {\n display: block;\n font-family: inherit;\n width: 100%;\n}\n\n.file-upload-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n box-sizing: border-box;\n}\n\n.upload-label {\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n font-weight: var(--ty-font-medium);\n color: var(--ty-label-color);\n margin-bottom: 6px;\n padding-left: 4px;\n display: flex;\n align-items: center;\n}\n\n.required-icon {\n color: var(--ty-color-danger);\n margin-left: 4px;\n font-size: 0.75rem;\n line-height: 1;\n}\n\n.drop-zone {\n border: 2px dashed var(--ty-border);\n border-radius: var(--ty-radius-base);\n padding: 2rem 1.5rem;\n text-align: center;\n cursor: pointer;\n transition: var(--ty-local-transition, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease);\n outline: none;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 0.5rem;\n min-height: 140px;\n user-select: none;\n}\n\n.drop-zone:hover:not(.disabled) {\n border-color: var(--ty-input-border-hover);\n background: var(--ty-surface-content);\n}\n\n.drop-zone.focused {\n border-color: var(--ty-input-border-focus);\n box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);\n}\n\n.drop-zone.drag-active {\n border-color: var(--ty-color-primary);\n border-style: solid;\n background: var(--ty-bg-primary-);\n}\n\n.drop-zone.drag-active .upload-icon {\n color: var(--ty-color-primary);\n transform: translateY(-2px);\n}\n\n.drop-zone.has-files {\n min-height: 80px;\n padding: 1.25rem 1.5rem;\n}\n\n.drop-zone.disabled {\n opacity: 0.5;\n cursor: not-allowed;\n pointer-events: none;\n}\n\n.drop-zone.error {\n border-color: var(--ty-color-danger);\n}\n\n.drop-zone.error:not(.disabled) {\n background: var(--ty-bg-danger-);\n}\n\n.upload-icon {\n color: var(--ty-text-faint);\n width: 2.5rem;\n height: 2.5rem;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: var(--ty-local-transition, color 0.15s ease, transform 0.15s ease);\n pointer-events: none;\n}\n\n.upload-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.upload-hint {\n color: var(--ty-text-soft);\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n pointer-events: none;\n}\n\n.browse-link {\n color: var(--ty-color-primary);\n font-weight: var(--ty-font-medium);\n}\n\n.upload-sub-hint {\n color: var(--ty-text-faint);\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n pointer-events: none;\n}\n\n.file-list {\n display: flex;\n flex-direction: column;\n gap: 0.375rem;\n margin-top: 0.5rem;\n}\n\n.file-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-radius: var(--ty-radius-base);\n background: var(--ty-surface-content);\n border: 1px solid var(--ty-border-soft);\n}\n\n.file-icon {\n color: var(--ty-text-faint);\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n display: flex;\n align-items: center;\n}\n\n.file-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.file-name {\n flex: 1;\n font-size: var(--ty-font-sm);\n color: var(--ty-text);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n min-width: 0;\n}\n\n.file-size {\n font-size: var(--ty-font-xs);\n color: var(--ty-text-faint);\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n}\n\n.file-remove {\n flex-shrink: 0;\n cursor: pointer;\n color: var(--ty-text-faint);\n width: 1.25rem;\n height: 1.25rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n transition: var(--ty-local-transition, color 0.15s ease, background-color 0.15s ease);\n border: none;\n background: none;\n padding: 0;\n outline: none;\n}\n\n.file-remove:hover {\n color: var(--ty-color-danger);\n background: var(--ty-bg-danger-);\n}\n\n.file-remove:focus-visible {\n box-shadow: 0 0 0 2px var(--ty-color-danger);\n}\n\n.file-remove svg {\n width: 12px;\n height: 12px;\n}\n\n.error-message {\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n color: var(--ty-color-danger);\n margin-top: 4px;\n padding-left: 4px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .drop-zone,\n .upload-icon,\n .file-remove {\n transition: none;\n }\n}\n\n@media (prefers-contrast: high) {\n .drop-zone {\n border-width: 3px;\n }\n}\n";
1
+ export declare const fileUploadStyles = "\n:host {\n display: block;\n font-family: inherit;\n width: 100%;\n}\n\n.file-upload-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n box-sizing: border-box;\n}\n\n.upload-label {\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n font-weight: var(--ty-font-medium);\n color: var(--ty-label-color);\n margin-bottom: 6px;\n padding-left: 4px;\n display: flex;\n align-items: center;\n}\n\n.required-icon {\n color: var(--ty-color-danger);\n margin-left: 4px;\n font-size: 0.75rem;\n line-height: 1;\n}\n\n.drop-zone {\n border: 2px dashed var(--ty-border);\n border-radius: var(--ty-radius-base);\n padding: 2rem 1.5rem;\n text-align: center;\n cursor: pointer;\n transition: var(--ty-local-transition, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease);\n outline: none;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 0.5rem;\n min-height: 140px;\n user-select: none;\n}\n\n.drop-zone:hover:not(.disabled) {\n border-color: var(--ty-input-border-hover);\n background: var(--ty-surface-content);\n}\n\n.drop-zone.focused {\n border-color: var(--ty-input-border-focus);\n box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);\n}\n\n.drop-zone.drag-active {\n border-color: var(--ty-color-primary);\n border-style: solid;\n background: var(--ty-bg-primary-soft);\n}\n\n.drop-zone.drag-active .upload-icon {\n color: var(--ty-color-primary);\n transform: translateY(-2px);\n}\n\n.drop-zone.has-files {\n min-height: 80px;\n padding: 1.25rem 1.5rem;\n}\n\n.drop-zone.disabled {\n opacity: 0.5;\n cursor: not-allowed;\n pointer-events: none;\n}\n\n.drop-zone.error {\n border-color: var(--ty-color-danger);\n}\n\n.drop-zone.error:not(.disabled) {\n background: var(--ty-bg-danger-soft);\n}\n\n.upload-icon {\n color: var(--ty-text-faint);\n width: 2.5rem;\n height: 2.5rem;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: var(--ty-local-transition, color 0.15s ease, transform 0.15s ease);\n pointer-events: none;\n}\n\n.upload-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.upload-hint {\n color: var(--ty-text-soft);\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n pointer-events: none;\n}\n\n.browse-link {\n color: var(--ty-color-primary);\n font-weight: var(--ty-font-medium);\n}\n\n.upload-sub-hint {\n color: var(--ty-text-faint);\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n pointer-events: none;\n}\n\n.file-list {\n display: flex;\n flex-direction: column;\n gap: 0.375rem;\n margin-top: 0.5rem;\n}\n\n.file-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-radius: var(--ty-radius-base);\n background: var(--ty-surface-content);\n border: 1px solid var(--ty-border-soft);\n}\n\n.file-icon {\n color: var(--ty-text-faint);\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n display: flex;\n align-items: center;\n}\n\n.file-icon svg {\n width: 100%;\n height: 100%;\n}\n\n.file-name {\n flex: 1;\n font-size: var(--ty-font-sm);\n color: var(--ty-text);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n min-width: 0;\n}\n\n.file-size {\n font-size: var(--ty-font-xs);\n color: var(--ty-text-faint);\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n}\n\n.file-remove {\n flex-shrink: 0;\n cursor: pointer;\n color: var(--ty-text-faint);\n width: 1.25rem;\n height: 1.25rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n transition: var(--ty-local-transition, color 0.15s ease, background-color 0.15s ease);\n border: none;\n background: none;\n padding: 0;\n outline: none;\n}\n\n.file-remove:hover {\n color: var(--ty-color-danger);\n background: var(--ty-bg-danger-soft);\n}\n\n.file-remove:focus-visible {\n box-shadow: 0 0 0 2px var(--ty-color-danger);\n}\n\n.file-remove svg {\n width: 12px;\n height: 12px;\n}\n\n.error-message {\n font-size: var(--ty-font-xs);\n line-height: var(--ty-leading-xs);\n color: var(--ty-color-danger);\n margin-top: 4px;\n padding-left: 4px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .drop-zone,\n .upload-icon,\n .file-remove {\n transition: none;\n }\n}\n\n@media (prefers-contrast: high) {\n .drop-zone {\n border-width: 3px;\n }\n}\n";
@@ -54,7 +54,7 @@ box-shadow: 0 0 0 3px var(--ty-input-shadow-focus);
54
54
  .drop-zone.drag-active {
55
55
  border-color: var(--ty-color-primary);
56
56
  border-style: solid;
57
- background: var(--ty-bg-primary-);
57
+ background: var(--ty-bg-primary-soft);
58
58
  }
59
59
  .drop-zone.drag-active .upload-icon {
60
60
  color: var(--ty-color-primary);
@@ -73,7 +73,7 @@ pointer-events: none;
73
73
  border-color: var(--ty-color-danger);
74
74
  }
75
75
  .drop-zone.error:not(.disabled) {
76
- background: var(--ty-bg-danger-);
76
+ background: var(--ty-bg-danger-soft);
77
77
  }
78
78
  .upload-icon {
79
79
  color: var(--ty-text-faint);
@@ -165,7 +165,7 @@ outline: none;
165
165
  }
166
166
  .file-remove:hover {
167
167
  color: var(--ty-color-danger);
168
- background: var(--ty-bg-danger-);
168
+ background: var(--ty-bg-danger-soft);
169
169
  }
170
170
  .file-remove:focus-visible {
171
171
  box-shadow: 0 0 0 2px var(--ty-color-danger);
@@ -1,2 +1,2 @@
1
1
  /** Icon Component Styles */
2
- export declare const iconStyles = "\n:host {\n /* Display & Layout - CRITICAL: No containment to prevent paint deferral */\n /* Block-level flex, NOT inline-flex: as an inline box the icon generates a\n line box in its parent, so a shrink-wrapping wrapper (badge, .rounded-full\n chip) inherits the parent's line-height strut and renders taller than wide\n \u2014 a 12px icon in a 24px-line-height badge becomes a 22x34 oval. To place an\n icon inline with text, make the *text wrapper* inline-flex instead. */\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 0;\n \n width: var(--ty-icon-size, 1em);\n height: var(--ty-icon-size, 1em);\n \n aspect-ratio: 1 / 1;\n \n \n color: inherit;\n transition: color var(--ty-transition-fast);\n \n flex-shrink: 0;\n \n /* Visibility - Force immediate rendering */\n visibility: visible !important;\n opacity: 1;\n \n /* ANTI-FLICKER: Force browser to paint immediately */\n will-change: contents;\n \n /* Prevent any sub-pixel rendering issues */\n transform: translateZ(0);\n -webkit-font-smoothing: antialiased;\n}\n\n/* The SVG element inside shadow DOM (registry / fallback path) */\n:host svg {\n width: 100%;\n height: 100%;\n display: block;\n\n /* Force immediate SVG rendering */\n will-change: auto;\n transform: translateZ(0);\n}\n\n/* Slotted SVG (server-side path: <ty-icon><svg>...</svg></ty-icon>).\n ::slotted() targets light-DOM children so they fill the host the same way\n shadow-DOM SVG does. Color is inherited from :host via currentColor. */\n::slotted(svg),\n::slotted(img) {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n/* In a named slot the icon sits alongside text (button start/end), so inline\n layout is what the host expects there. */\n:host([slot]) {\n display: inline-flex;\n}\n\n:host(.icon-xs) {\n width: 0.75em;\n height: 0.75em;\n}\n\n:host(.icon-sm) {\n width: 0.875em;\n height: 0.875em;\n}\n\n:host(.icon-md) {\n width: 1em;\n height: 1em;\n}\n\n:host(.icon-lg) {\n width: 1.25em;\n height: 1.25em;\n}\n\n:host(.icon-xl) {\n width: 1.5em;\n height: 1.5em;\n}\n\n:host(.icon-2xl) {\n width: 2em;\n height: 2em;\n}\n\n/* Fixed pixel sizes */\n:host(.icon-12) {\n width: 12px;\n height: 12px;\n}\n\n:host(.icon-14) {\n width: 14px;\n height: 14px;\n}\n\n:host(.icon-16) {\n width: 16px;\n height: 16px;\n}\n\n:host(.icon-18) {\n width: 18px;\n height: 18px;\n}\n\n:host(.icon-20) {\n width: 20px;\n height: 20px;\n}\n\n:host(.icon-24) {\n width: 24px;\n height: 24px;\n}\n\n:host(.icon-32) {\n width: 32px;\n height: 32px;\n}\n\n:host(.icon-48) {\n width: 48px;\n height: 48px;\n}\n\n:host(.icon-64) {\n width: 64px;\n height: 64px;\n}\n\n:host(.icon-80) {\n width: 80px;\n height: 80px;\n}\n\n:host(.icon-96) {\n width: 96px;\n height: 96px;\n}\n\n:host(.icon-spin) {\n animation: icon-spin 1s linear infinite;\n}\n\n:host(.icon-spin.icon-tempo-slow) {\n animation: icon-spin 2s linear infinite;\n}\n\n:host(.icon-spin.icon-tempo-fast) {\n animation: icon-spin 0.5s linear infinite;\n}\n\n@keyframes icon-spin {\n from {\n transform: rotate(0deg);\n }\n\n to {\n transform: rotate(360deg);\n }\n}\n\n:host(.icon-pulse) {\n animation: icon-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n:host(.icon-pulse.icon-tempo-slow) {\n animation: icon-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n:host(.icon-pulse.icon-tempo-fast) {\n animation: icon-pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n@keyframes icon-pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n\n 50% {\n opacity: 0.5;\n }\n}\n\n/* Accessibility: ensure icons are not announced by screen readers by default */\n:host {\n -webkit-user-select: none;\n user-select: none;\n}\n\n:host(:not(.icon-clickable)) {\n pointer-events: none;\n}\n\n:host(.icon-clickable) {\n cursor: pointer;\n pointer-events: auto;\n transition: color var(--ty-transition-fast), transform var(--ty-transition-fast);\n}\n\n:host(.icon-clickable:hover) {\n transform: scale(1.1);\n}\n\n:host(.icon-clickable:active) {\n transform: scale(0.95);\n}\n\n:host(.icon-disabled) {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n:host(.icon-block) {\n display: block;\n}\n";
2
+ export declare const iconStyles = "\n:host {\n /* Display & Layout - CRITICAL: No containment to prevent paint deferral */\n /* Block-level flex, NOT inline-flex: as an inline box the icon generates a\n line box in its parent, so a shrink-wrapping wrapper (badge, .rounded-full\n chip) inherits the parent's line-height strut and renders taller than wide\n \u2014 a 12px icon in a 24px-line-height badge becomes a 22x34 oval. To place an\n icon inline with text, make the *text wrapper* inline-flex instead. */\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 0;\n \n width: var(--ty-icon-size, 1em);\n height: var(--ty-icon-size, 1em);\n \n aspect-ratio: 1 / 1;\n \n \n color: inherit;\n transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);\n \n flex-shrink: 0;\n \n /* Visibility - Force immediate rendering */\n visibility: visible !important;\n opacity: 1;\n \n /* ANTI-FLICKER: Force browser to paint immediately */\n will-change: contents;\n \n /* Prevent any sub-pixel rendering issues */\n transform: translateZ(0);\n -webkit-font-smoothing: antialiased;\n}\n\n/* The SVG element inside shadow DOM (registry / fallback path) */\n:host svg {\n width: 100%;\n height: 100%;\n display: block;\n\n /* Force immediate SVG rendering */\n will-change: auto;\n transform: translateZ(0);\n}\n\n/* Slotted SVG (server-side path: <ty-icon><svg>...</svg></ty-icon>).\n ::slotted() targets light-DOM children so they fill the host the same way\n shadow-DOM SVG does. Color is inherited from :host via currentColor. */\n::slotted(svg),\n::slotted(img) {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n/* In a named slot the icon sits alongside text (button start/end), so inline\n layout is what the host expects there. */\n:host([slot]) {\n display: inline-flex;\n}\n\n:host(.icon-xs) {\n width: 0.75em;\n height: 0.75em;\n}\n\n:host(.icon-sm) {\n width: 0.875em;\n height: 0.875em;\n}\n\n:host(.icon-md) {\n width: 1em;\n height: 1em;\n}\n\n:host(.icon-lg) {\n width: 1.25em;\n height: 1.25em;\n}\n\n:host(.icon-xl) {\n width: 1.5em;\n height: 1.5em;\n}\n\n:host(.icon-2xl) {\n width: 2em;\n height: 2em;\n}\n\n/* Fixed pixel sizes */\n:host(.icon-12) {\n width: 12px;\n height: 12px;\n}\n\n:host(.icon-14) {\n width: 14px;\n height: 14px;\n}\n\n:host(.icon-16) {\n width: 16px;\n height: 16px;\n}\n\n:host(.icon-18) {\n width: 18px;\n height: 18px;\n}\n\n:host(.icon-20) {\n width: 20px;\n height: 20px;\n}\n\n:host(.icon-24) {\n width: 24px;\n height: 24px;\n}\n\n:host(.icon-32) {\n width: 32px;\n height: 32px;\n}\n\n:host(.icon-48) {\n width: 48px;\n height: 48px;\n}\n\n:host(.icon-64) {\n width: 64px;\n height: 64px;\n}\n\n:host(.icon-80) {\n width: 80px;\n height: 80px;\n}\n\n:host(.icon-96) {\n width: 96px;\n height: 96px;\n}\n\n:host(.icon-spin) {\n animation: icon-spin 1s linear infinite;\n}\n\n:host(.icon-spin.icon-tempo-slow) {\n animation: icon-spin 2s linear infinite;\n}\n\n:host(.icon-spin.icon-tempo-fast) {\n animation: icon-spin 0.5s linear infinite;\n}\n\n@keyframes icon-spin {\n from {\n transform: rotate(0deg);\n }\n\n to {\n transform: rotate(360deg);\n }\n}\n\n:host(.icon-pulse) {\n animation: icon-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n:host(.icon-pulse.icon-tempo-slow) {\n animation: icon-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n:host(.icon-pulse.icon-tempo-fast) {\n animation: icon-pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n@keyframes icon-pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n\n 50% {\n opacity: 0.5;\n }\n}\n\n/* Accessibility: ensure icons are not announced by screen readers by default */\n:host {\n -webkit-user-select: none;\n user-select: none;\n}\n\n:host(:not(.icon-clickable)) {\n pointer-events: none;\n}\n\n:host(.icon-clickable) {\n cursor: pointer;\n pointer-events: auto;\n transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n:host(.icon-clickable:hover) {\n transform: scale(1.1);\n}\n\n:host(.icon-clickable:active) {\n transform: scale(0.95);\n}\n\n:host(.icon-disabled) {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n:host(.icon-block) {\n display: block;\n}\n";
@@ -8,7 +8,7 @@ width: var(--ty-icon-size, 1em);
8
8
  height: var(--ty-icon-size, 1em);
9
9
  aspect-ratio: 1 / 1;
10
10
  color: inherit;
11
- transition: color var(--ty-transition-fast);
11
+ transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
12
12
  flex-shrink: 0;
13
13
  visibility: visible !important;
14
14
  opacity: 1;
@@ -145,7 +145,7 @@ pointer-events: none;
145
145
  :host(.icon-clickable) {
146
146
  cursor: pointer;
147
147
  pointer-events: auto;
148
- transition: color var(--ty-transition-fast), transform var(--ty-transition-fast);
148
+ transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
149
149
  }
150
150
  :host(.icon-clickable:hover) {
151
151
  transform: scale(1.1);
@@ -96,7 +96,7 @@ display: flex;
96
96
  align-items: center;
97
97
  justify-content: center;
98
98
  flex-shrink: 0;
99
- color: var(--ty-color-text-soft);
99
+ color: var(--ty-text-soft);
100
100
  }
101
101
  .password-toggle {
102
102
  display: flex;
@@ -27,13 +27,13 @@ display: flex;
27
27
  flex-direction: column;
28
28
  gap: 0.4rem;
29
29
  font-family: inherit;
30
- color: var(--ty-color-text);
30
+ color: var(--ty-text);
31
31
  }
32
32
  .radio-group-label {
33
33
  font-size: var(--ty-font-sm);
34
34
  line-height: var(--ty-leading-sm);
35
35
  font-weight: var(--ty-font-medium);
36
- color: var(--ty-color-text);
36
+ color: var(--ty-text);
37
37
  display: flex;
38
38
  align-items: center;
39
39
  gap: 0.25rem;
@@ -29,6 +29,12 @@ export interface PropertyChange {
29
29
  /**
30
30
  * Manages component properties with unified lifecycle
31
31
  */
32
+ /**
33
+ * Boolean coercion for a property set from JS, HTML-style: '' (what React 19
34
+ * and Vue hand a property for a bare attribute) is true; false, null,
35
+ * undefined, 0, 'false' and '0' are false.
36
+ */
37
+ export declare function coerceBoolean(value: unknown): boolean;
32
38
  export declare class PropertyManager<T = any> {
33
39
  private _props;
34
40
  private _config;
@@ -1,3 +1,13 @@
1
+ export function coerceBoolean(value) {
2
+ if (typeof value === 'string') {
3
+ const normalized = value.toLowerCase().trim();
4
+ return normalized !== 'false' && normalized !== '0';
5
+ }
6
+ return Boolean(value);
7
+ }
8
+ function defaultOf(config) {
9
+ return typeof config.default === 'function' ? config.default() : config.default;
10
+ }
1
11
  export class PropertyManager {
2
12
  constructor(config) {
3
13
  this._props = new Map();
@@ -7,7 +17,7 @@ this._initializeDefaults();
7
17
  _initializeDefaults() {
8
18
  for (const [name, config] of Object.entries(this._config)) {
9
19
  if (config.default !== undefined) {
10
- this._props.set(name, config.default);
20
+ this._props.set(name, defaultOf(config));
11
21
  }
12
22
  }
13
23
  }
@@ -43,7 +53,7 @@ if (config.coerce) {
43
53
  return config.coerce(value);
44
54
  }
45
55
  if (value === null || value === undefined) {
46
- return config.default ?? null;
56
+ return defaultOf(config) ?? null;
47
57
  }
48
58
  switch (config.type) {
49
59
  case 'boolean':
@@ -58,7 +68,7 @@ return true;
58
68
  return Boolean(value);
59
69
  case 'number':
60
70
  const num = Number(value);
61
- return isNaN(num) ? (config.default ?? 0) : num;
71
+ return isNaN(num) ? (defaultOf(config) ?? 0) : num;
62
72
  case 'string':
63
73
  return String(value);
64
74
  case 'object':
@@ -69,7 +79,7 @@ return JSON.parse(value);
69
79
  }
70
80
  catch {
71
81
  console.warn(`[PropertyManager] Failed to parse ${name}:`, value);
72
- return config.default ?? (config.type === 'array' ? [] : {});
82
+ return defaultOf(config) ?? (config.type === 'array' ? [] : {});
73
83
  }
74
84
  }
75
85
  return value;
@@ -119,7 +129,7 @@ handleAlias(aliasName, value) {
119
129
  for (const [propName, config] of Object.entries(this._config)) {
120
130
  if (config.aliases && aliasName in config.aliases) {
121
131
  if (value === null)
122
- return this.updateProperty(propName, config.default, 'attribute');
132
+ return this.updateProperty(propName, defaultOf(config), 'attribute');
123
133
  const aliasedValue = config.aliases[aliasName];
124
134
  return this.updateProperty(propName, aliasedValue, 'attribute');
125
135
  }
@@ -9,6 +9,12 @@
9
9
  * reportValidity() uses the component's own `error` display when it has one
10
10
  * (ty-input, ty-textarea, ty-radio-group, ty-file-upload) instead of the
11
11
  * browser's bubble, and clears that message again once the field is valid.
12
+ * Whenever the browser fires `invalid` on such a field — a submit attempt,
13
+ * form.reportValidity(), or checkValidity() on the field or its form — the
14
+ * message goes into that error line (no bubble), so a plain <form> needs no
15
+ * JS. For a check that shows nothing, read `field.validity.valid` or
16
+ * `form.matches(':invalid')`. Only a real submit (a submitter click, or
17
+ * Enter) also moves focus to the first failing field.
12
18
  */
13
19
  export declare class ValidityController {
14
20
  private host;
@@ -18,6 +24,12 @@ export declare class ValidityController {
18
24
  /** `error` was written by reportValidity(), so it's ours to clear. */
19
25
  private errorFromReport;
20
26
  constructor(host: HTMLElement, internals: ElementInternals);
27
+ /**
28
+ * Show the current message, if any, in the error display (on blur after an
29
+ * edit). While a pointer is pressed it waits until after pointerup.
30
+ */
31
+ reveal(): void;
32
+ private showError;
21
33
  /** The component's own validity (replaces the previous one). */
22
34
  set(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement): void;
23
35
  setCustom(message: string): void;
@@ -1,3 +1,43 @@
1
+ let submitAttempt = false;
2
+ let pointerPressed = false;
3
+ const pendingReveals = new Set();
4
+ let documentWatched = false;
5
+ function watchDocument() {
6
+ if (documentWatched)
7
+ return;
8
+ documentWatched = true;
9
+ const mark = () => {
10
+ submitAttempt = true;
11
+ setTimeout(() => { submitAttempt = false; });
12
+ };
13
+ document.addEventListener('click', (e) => {
14
+ const submitter = e.target?.closest?.('button:not([type=button]):not([type=reset]), input[type=submit], input[type=image], ' +
15
+ 'ty-button:not([type=button]):not([type=reset])');
16
+ if (submitter)
17
+ mark();
18
+ }, true);
19
+ document.addEventListener('keydown', (e) => {
20
+ if (e.key === 'Enter')
21
+ mark();
22
+ }, true);
23
+ document.addEventListener('pointerdown', () => { pointerPressed = true; }, true);
24
+ const release = () => {
25
+ pointerPressed = false;
26
+ if (pendingReveals.size === 0)
27
+ return;
28
+ const waiting = [...pendingReveals];
29
+ pendingReveals.clear();
30
+ setTimeout(() => waiting.forEach((c) => c.reveal()));
31
+ };
32
+ document.addEventListener('pointerup', release, true);
33
+ document.addEventListener('pointercancel', release, true);
34
+ }
35
+ function firstInvalid(form) {
36
+ return Array.from(form.elements).find((el) => {
37
+ const v = el.validity;
38
+ return v && !v.valid && !el.disabled;
39
+ });
40
+ }
1
41
  export class ValidityController {
2
42
  constructor(host, internals) {
3
43
  this.host = host;
@@ -5,6 +45,30 @@ this.internals = internals;
5
45
  this.own = { flags: {} };
6
46
  this.custom = '';
7
47
  this.errorFromReport = false;
48
+ watchDocument();
49
+ host.addEventListener('invalid', (e) => {
50
+ if (!('error' in host))
51
+ return;
52
+ e.preventDefault();
53
+ this.showError();
54
+ if (!submitAttempt)
55
+ return;
56
+ const form = this.internals.form;
57
+ if (!form || firstInvalid(form) === host)
58
+ host.focus();
59
+ });
60
+ }
61
+ reveal() {
62
+ if (pointerPressed) {
63
+ pendingReveals.add(this);
64
+ return;
65
+ }
66
+ if ('error' in this.host && !this.internals.validity.valid)
67
+ this.showError();
68
+ }
69
+ showError() {
70
+ this.host.setAttribute('error', this.internals.validationMessage);
71
+ this.errorFromReport = true;
8
72
  }
9
73
  set(flags, message, anchor) {
10
74
  this.own = { flags, message, anchor };
@@ -19,8 +83,7 @@ const valid = this.internals.checkValidity();
19
83
  if (valid)
20
84
  return true;
21
85
  if ('error' in this.host) {
22
- this.host.setAttribute('error', this.internals.validationMessage);
23
- this.errorFromReport = true;
86
+ this.showError();
24
87
  this.host.focus();
25
88
  }
26
89
  else {
package/lib/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * This is automatically synced with package.json during build.
5
5
  * Use `npm version` to bump versions.
6
6
  */
7
- export declare const VERSION = "1.0.0-TC56";
7
+ export declare const VERSION = "1.0.0-TC57";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.0.0-TC56';
1
+ export const VERSION = '1.0.0-TC57';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tyrell-components",
3
- "version": "1.0.0-TC56",
3
+ "version": "1.0.0-TC57",
4
4
  "description": "Tyrell Components - Framework-agnostic web components with semantic design system",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -183,7 +183,7 @@
183
183
  },
184
184
  "repository": {
185
185
  "type": "git",
186
- "url": "https://github.com/gersak/tyrell.git",
186
+ "url": "git+https://github.com/gersak/tyrell.git",
187
187
  "directory": "packages/core"
188
188
  },
189
189
  "homepage": "https://gersak.github.io/tyrell/",