tyrell-components 1.0.0-TC47 → 1.0.0-TC48

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 (41) hide show
  1. package/css/tyrell-theme.css +88 -92
  2. package/css/tyrell.css +19 -0
  3. package/dist/tyrell-theme.css +88 -92
  4. package/dist/tyrell.css +19 -0
  5. package/dist/tyrell.js +1 -1
  6. package/lib/base/ty-component.d.ts +8 -3
  7. package/lib/base/ty-component.js +13 -5
  8. package/lib/components/date-picker.js +2 -8
  9. package/lib/components/file-upload.d.ts +6 -0
  10. package/lib/components/file-upload.js +19 -4
  11. package/lib/components/input.js +1 -0
  12. package/lib/components/modal.d.ts +8 -0
  13. package/lib/components/modal.js +29 -6
  14. package/lib/components/popup.js +4 -4
  15. package/lib/components/radio.js +10 -2
  16. package/lib/components/resize-observer.d.ts +1 -0
  17. package/lib/components/resize-observer.js +12 -1
  18. package/lib/components/select.js +3 -5
  19. package/lib/components/selected-tags.js +1 -1
  20. package/lib/components/tabs.d.ts +2 -0
  21. package/lib/components/tabs.js +15 -0
  22. package/lib/components/tooltip.js +22 -0
  23. package/lib/components/wizard.d.ts +2 -0
  24. package/lib/components/wizard.js +15 -0
  25. package/lib/styles/button.js +4 -1
  26. package/lib/styles/date-picker.js +27 -1
  27. package/lib/styles/modal.d.ts +1 -1
  28. package/lib/styles/modal.js +18 -0
  29. package/lib/styles/option.d.ts +1 -1
  30. package/lib/styles/option.js +15 -0
  31. package/lib/styles/popup.d.ts +1 -1
  32. package/lib/styles/popup.js +22 -5
  33. package/lib/styles/select-base.js +53 -5
  34. package/lib/styles/select.d.ts +1 -1
  35. package/lib/styles/select.js +5 -1
  36. package/lib/utils/number-format.d.ts +6 -1
  37. package/lib/utils/number-format.js +9 -2
  38. package/lib/utils/positioning.js +4 -4
  39. package/lib/version.d.ts +1 -1
  40. package/lib/version.js +1 -1
  41. package/package.json +1 -1
@@ -132,9 +132,14 @@ export declare abstract class TyComponent<T = any> extends HTMLElement {
132
132
  * two genuinely separate clicks are always in different tasks.
133
133
  */
134
134
  protected isDuplicateActivationClick(): boolean;
135
- /**
136
- * Form reset callback - called when form.reset() is invoked
137
- * Resets component to its default value
135
+ private _resetDefaults;
136
+ /**
137
+ * Form reset callback - called when form.reset() is invoked.
138
+ * Restores every formValue-bearing property to its attribute-declared
139
+ * default captured at first connect (native defaultValue/defaultChecked
140
+ * semantics). Non-form state (e.g. a checkbox's submission `value`, a
141
+ * ty-radio's option `value`) is deliberately left alone — the old
142
+ * blanket 'value' reset corrupted radio groups by wiping child values.
138
143
  */
139
144
  formResetCallback(): void;
140
145
  /**
@@ -5,6 +5,7 @@ export class TyComponent extends HTMLElement {
5
5
  this._connected = false;
6
6
  this._rendered = false;
7
7
  this._handlingActivationClick = false;
8
+ this._resetDefaults = null;
8
9
  const ctor = this.constructor;
9
10
  this.props = new PropertyManager(ctor.properties);
10
11
  this._internals = this.attachInternals();
@@ -79,6 +80,16 @@ export class TyComponent extends HTMLElement {
79
80
  }
80
81
  connectedCallback() {
81
82
  this._connected = true;
83
+ if (!this._resetDefaults) {
84
+ this._resetDefaults = new Map();
85
+ const ctor = this.constructor;
86
+ for (const [name, config] of Object.entries(ctor.properties)) {
87
+ if (!config.formValue)
88
+ continue;
89
+ const attr = this.getAttribute(this._toKebabCase(name));
90
+ this._resetDefaults.set(name, attr ?? config.default ?? null);
91
+ }
92
+ }
82
93
  const preSetProps = this._capturePreSetProperties();
83
94
  if (preSetProps.length > 0) {
84
95
  this._handlePropertyChanges(preSetProps);
@@ -178,11 +189,8 @@ export class TyComponent extends HTMLElement {
178
189
  return false;
179
190
  }
180
191
  formResetCallback() {
181
- const ctor = this.constructor;
182
- const valueConfig = ctor.properties['value'];
183
- if (valueConfig) {
184
- const defaultValue = valueConfig.default ?? '';
185
- this.setProperty('value', defaultValue);
192
+ for (const [name, def] of this._resetDefaults ?? []) {
193
+ this.setProperty(name, def);
186
194
  }
187
195
  this.onFormReset();
188
196
  }
@@ -980,10 +980,7 @@ export class TyDatePicker extends TyComponent {
980
980
  if (isMobileTouch())
981
981
  return;
982
982
  this.updateState({ open: true });
983
- this.dispatchEvent(new CustomEvent('open', {
984
- bubbles: true,
985
- composed: true
986
- }));
983
+ this.dispatchEvent(new CustomEvent('open'));
987
984
  this.render();
988
985
  requestAnimationFrame(() => {
989
986
  if (!this.shadowRoot)
@@ -1022,10 +1019,7 @@ export class TyDatePicker extends TyComponent {
1022
1019
  if (stubEl)
1023
1020
  stubEl.setAttribute('aria-expanded', 'false');
1024
1021
  }
1025
- this.dispatchEvent(new CustomEvent('close', {
1026
- bubbles: true,
1027
- composed: true
1028
- }));
1022
+ this.dispatchEvent(new CustomEvent('close'));
1029
1023
  this.render();
1030
1024
  }
1031
1025
  cleanup() {
@@ -80,6 +80,12 @@ export declare class TyFileUpload extends TyComponent<FileUploadState> implement
80
80
  protected onPropertiesChanged(_changes: PropertyChange[]): void;
81
81
  protected onFormReset(): void;
82
82
  protected getFormValue(): FormData | string | null;
83
+ /**
84
+ * Match a file against the `accept` attribute the way native inputs do:
85
+ * comma-separated list of `.ext`, `type/subtype` or `type/*` entries.
86
+ * No/blank accept matches everything.
87
+ */
88
+ private matchesAccept;
83
89
  get files(): File[];
84
90
  get multiple(): boolean;
85
91
  set multiple(v: boolean);
@@ -44,15 +44,30 @@ export class TyFileUpload extends TyComponent {
44
44
  fileInput.value = "";
45
45
  }
46
46
  getFormValue() {
47
- if (this._files.length === 0)
47
+ if (this._files.length === 0 || !this.name)
48
48
  return null;
49
49
  const fd = new FormData();
50
- const fieldName = this.name || "files";
51
50
  for (const file of this._files) {
52
- fd.append(fieldName, file);
51
+ fd.append(this.name, file);
53
52
  }
54
53
  return fd;
55
54
  }
55
+ matchesAccept(file) {
56
+ const accept = this.accept?.trim();
57
+ if (!accept)
58
+ return true;
59
+ return accept.split(",").some((raw) => {
60
+ const entry = raw.trim().toLowerCase();
61
+ if (!entry)
62
+ return false;
63
+ if (entry.startsWith("."))
64
+ return file.name.toLowerCase().endsWith(entry);
65
+ const type = file.type.toLowerCase();
66
+ if (entry.endsWith("/*"))
67
+ return type.startsWith(entry.slice(0, -1));
68
+ return type === entry;
69
+ });
70
+ }
56
71
  get files() {
57
72
  return [...this._files];
58
73
  }
@@ -178,7 +193,7 @@ export class TyFileUpload extends TyComponent {
178
193
  e.stopPropagation();
179
194
  this._isDragging = false;
180
195
  dropZone.classList.remove("drag-active");
181
- const files = Array.from(e.dataTransfer?.files ?? []);
196
+ const files = Array.from(e.dataTransfer?.files ?? []).filter((f) => this.matchesAccept(f));
182
197
  if (files.length > 0)
183
198
  this.setFiles(files);
184
199
  };
@@ -81,6 +81,7 @@ export class TyInput extends TyComponent {
81
81
  }
82
82
  onFormReset() {
83
83
  this._shadowValue = null;
84
+ this.initializeShadowValue();
84
85
  this._isFocused = false;
85
86
  if (this._inputDebounceTimer !== null) {
86
87
  clearTimeout(this._inputDebounceTimer);
@@ -48,6 +48,14 @@ export declare class TyModal extends HTMLElement {
48
48
  force?: boolean;
49
49
  }) => void;
50
50
  static get observedAttributes(): string[];
51
+ get open(): boolean;
52
+ set open(v: boolean);
53
+ get backdrop(): boolean;
54
+ set backdrop(v: boolean);
55
+ get closeOnOutsideClick(): boolean;
56
+ set closeOnOutsideClick(v: boolean);
57
+ get closeOnEscape(): boolean;
58
+ set closeOnEscape(v: boolean);
51
59
  constructor();
52
60
  connectedCallback(): void;
53
61
  disconnectedCallback(): void;
@@ -7,6 +7,7 @@ const closeButtonHandlers = new WeakMap();
7
7
  const hoverEnterHandlers = new WeakMap();
8
8
  const hoverLeaveHandlers = new WeakMap();
9
9
  const modalIds = new WeakMap();
10
+ const pendingCloseReasons = new WeakMap();
10
11
  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>`;
11
12
  function parseBoolAttr(el, name) {
12
13
  if (!el.hasAttribute(name)) {
@@ -47,7 +48,6 @@ function isMobile() {
47
48
  function dispatchModalEvent(el, eventType, detail) {
48
49
  const event = new CustomEvent(eventType, {
49
50
  detail,
50
- bubbles: true,
51
51
  cancelable: true,
52
52
  });
53
53
  el.dispatchEvent(event);
@@ -80,14 +80,13 @@ function closeModal(el, reason = 'programmatic', opts) {
80
80
  if (!opts?.force) {
81
81
  const beforeClose = new CustomEvent('beforeclose', {
82
82
  detail: { reason },
83
- bubbles: true,
84
- composed: true,
85
83
  cancelable: true,
86
84
  });
87
85
  el.dispatchEvent(beforeClose);
88
86
  if (beforeClose.defaultPrevented)
89
87
  return;
90
88
  }
89
+ pendingCloseReasons.set(el, reason);
91
90
  el.removeAttribute('open');
92
91
  }
93
92
  function openModal(el) {
@@ -226,6 +225,11 @@ function render(el) {
226
225
  }
227
226
  setupBackdropClick(el, dialog, attributes.closeOnOutsideClick);
228
227
  setupEscapeKey(el, dialog, attributes.closeOnEscape);
228
+ dialog.oncancel = (event) => {
229
+ event.preventDefault();
230
+ if (getModalAttributes(el).closeOnEscape)
231
+ closeModal(el, 'escape');
232
+ };
229
233
  setupCloseButton(el, dialog, attributes.closeOnOutsideClick);
230
234
  setupCloseButtonHover(el, dialog);
231
235
  if (attributes.open) {
@@ -237,6 +241,7 @@ function render(el) {
237
241
  else {
238
242
  dialog.show();
239
243
  }
244
+ syncBackdropZoom();
240
245
  dispatchModalEvent(el, 'open', {});
241
246
  }
242
247
  }
@@ -244,9 +249,10 @@ function render(el) {
244
249
  if (dialog.open) {
245
250
  unlockScroll(modalId);
246
251
  dialog.close();
247
- dispatchModalEvent(el, 'close', {
248
- reason: 'programmatic'
249
- });
252
+ syncBackdropZoom();
253
+ const reason = pendingCloseReasons.get(el) ?? 'programmatic';
254
+ pendingCloseReasons.delete(el);
255
+ dispatchModalEvent(el, 'close', { reason });
250
256
  }
251
257
  }
252
258
  dialog.onclose = (event) => {
@@ -260,8 +266,16 @@ function render(el) {
260
266
  returnValue: dialog.returnValue || undefined
261
267
  });
262
268
  }
269
+ syncBackdropZoom();
263
270
  };
264
271
  }
272
+ function syncBackdropZoom() {
273
+ const anyOpen = Array.from(document.querySelectorAll('ty-modal[open][backdrop-zoom], ty-dialog[open][backdrop-zoom]')).some((m) => {
274
+ const v = m.getAttribute('backdrop-zoom').toLowerCase().trim();
275
+ return v !== 'false' && v !== '0';
276
+ });
277
+ document.documentElement.classList.toggle('ty-modal-zoom', anyOpen);
278
+ }
265
279
  function cleanup(el) {
266
280
  const modalId = modalIds.get(el);
267
281
  const shadowRoot = el.shadowRoot;
@@ -270,6 +284,7 @@ function cleanup(el) {
270
284
  unlockScroll(modalId);
271
285
  modalIds.delete(el);
272
286
  }
287
+ syncBackdropZoom();
273
288
  if (!dialog)
274
289
  return;
275
290
  const backdropHandler = backdropClickHandlers.get(el);
@@ -308,6 +323,14 @@ export class TyModal extends HTMLElement {
308
323
  static get observedAttributes() {
309
324
  return ['open', 'backdrop', 'close-on-outside-click', 'close-on-escape', 'label'];
310
325
  }
326
+ get open() { return parseBoolAttr(this, 'open'); }
327
+ set open(v) { v ? this.setAttribute('open', 'true') : this.removeAttribute('open'); }
328
+ get backdrop() { return getModalAttributes(this).backdrop; }
329
+ set backdrop(v) { this.setAttribute('backdrop', String(!!v)); }
330
+ get closeOnOutsideClick() { return getModalAttributes(this).closeOnOutsideClick; }
331
+ set closeOnOutsideClick(v) { this.setAttribute('close-on-outside-click', String(!!v)); }
332
+ get closeOnEscape() { return getModalAttributes(this).closeOnEscape; }
333
+ set closeOnEscape(v) { this.setAttribute('close-on-escape', String(!!v)); }
311
334
  constructor() {
312
335
  super();
313
336
  this.attachShadow({ mode: 'open' });
@@ -58,6 +58,7 @@ function updatePosition(el) {
58
58
  });
59
59
  el.style.setProperty('--popup-x', `${positionData.x}px`);
60
60
  el.style.setProperty('--popup-y', `${positionData.y}px`);
61
+ dialog.dataset.side = positionData.placement.split('-')[0];
61
62
  dialog.classList.add('position-calculated');
62
63
  }
63
64
  function closePopup(el, force = false) {
@@ -76,8 +77,7 @@ function closePopup(el, force = false) {
76
77
  setTimeout(() => {
77
78
  dialog.classList.remove('position-calculated');
78
79
  dialog.close();
79
- }, 150);
80
- el.dispatchEvent(new CustomEvent('close', { bubbles: true }));
80
+ }, 200);
81
81
  }
82
82
  function openPopup(el) {
83
83
  const popupId = getPopupId(el);
@@ -95,7 +95,7 @@ function openPopup(el) {
95
95
  dialog.classList.add('preparing-animation');
96
96
  requestAnimationFrame(() => {
97
97
  dialog.classList.add('open');
98
- el.dispatchEvent(new CustomEvent('open', { bubbles: true }));
98
+ el.dispatchEvent(new CustomEvent('open'));
99
99
  });
100
100
  });
101
101
  }
@@ -211,7 +211,7 @@ function render(el) {
211
211
  unlockScroll(popupId);
212
212
  dialog.classList.remove('open');
213
213
  dialog.classList.remove('preparing-animation');
214
- el.dispatchEvent(new CustomEvent('close', { bubbles: true }));
214
+ el.dispatchEvent(new CustomEvent('close'));
215
215
  });
216
216
  }
217
217
  el.closePopup = () => closePopup(el, true);
@@ -176,8 +176,16 @@ export class TyRadioGroup extends TyComponent {
176
176
  const radios = this.getRadios();
177
177
  for (const radio of radios) {
178
178
  radio.checked = radio.value === this.value;
179
- if (this.disabled)
180
- radio.disabled = true;
179
+ if (this.disabled) {
180
+ if (!radio.disabled) {
181
+ radio.disabled = true;
182
+ radio.dataset.tyGroupDisabled = "";
183
+ }
184
+ }
185
+ else if ("tyGroupDisabled" in radio.dataset) {
186
+ radio.disabled = false;
187
+ delete radio.dataset.tyGroupDisabled;
188
+ }
181
189
  radio.size = this.size;
182
190
  radio.flavor = this.flavor;
183
191
  }
@@ -13,6 +13,7 @@ export declare class TyResizeObserver extends HTMLElement {
13
13
  set debounce(value: number);
14
14
  constructor();
15
15
  connectedCallback(): void;
16
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
16
17
  disconnectedCallback(): void;
17
18
  private setupObserver;
18
19
  private handleResize;
@@ -22,6 +22,15 @@ export class TyResizeObserver extends HTMLElement {
22
22
  connectedCallback() {
23
23
  this.setupObserver();
24
24
  }
25
+ attributeChangedCallback(name, oldValue, newValue) {
26
+ if (name === 'id' && oldValue && oldValue !== newValue) {
27
+ removeSize(oldValue);
28
+ if (newValue && this.isConnected) {
29
+ const { width, height } = this.getBoundingClientRect();
30
+ updateSize(newValue, width, height);
31
+ }
32
+ }
33
+ }
25
34
  disconnectedCallback() {
26
35
  this.cleanup();
27
36
  }
@@ -60,4 +69,6 @@ export class TyResizeObserver extends HTMLElement {
60
69
  }
61
70
  }
62
71
  }
63
- customElements.define('ty-resize-observer', TyResizeObserver);
72
+ if (!customElements.get('ty-resize-observer')) {
73
+ customElements.define('ty-resize-observer', TyResizeObserver);
74
+ }
@@ -379,6 +379,7 @@ export class TySelect extends TyComponent {
379
379
  popupHeight: estimatedHeight,
380
380
  align: anchored.align,
381
381
  side: anchored.side,
382
+ gap: 8,
382
383
  });
383
384
  const positionBelow = pos.below;
384
385
  const x = pos.x - wrapPadding;
@@ -436,8 +437,8 @@ export class TySelect extends TyComponent {
436
437
  this._scrollLockId = lockId;
437
438
  lockScroll(lockId);
438
439
  dialog.showModal();
439
- dialog.classList.add("open");
440
440
  this.calculatePosition();
441
+ dialog.classList.add("open");
441
442
  this._state.open = true;
442
443
  const chevron = shadow.querySelector(".dropdown-chevron");
443
444
  if (chevron)
@@ -516,6 +517,7 @@ export class TySelect extends TyComponent {
516
517
  this._scrollLockId = lockId;
517
518
  lockScroll(lockId);
518
519
  dialog.showModal();
520
+ dialog.getBoundingClientRect();
519
521
  dialog.classList.add("open");
520
522
  this._state.open = true;
521
523
  const tags = this.getTagElements().map((el) => this.getTagData(el));
@@ -845,8 +847,6 @@ export class TySelect extends TyComponent {
845
847
  fireOpenEvent() {
846
848
  this.dispatchEvent(new CustomEvent("open", {
847
849
  detail: { mode: this._state.mode, element: this },
848
- bubbles: true,
849
- composed: true,
850
850
  }));
851
851
  if (this._externalSearch) {
852
852
  this.fireSearchEvent("");
@@ -855,8 +855,6 @@ export class TySelect extends TyComponent {
855
855
  fireCloseEvent() {
856
856
  this.dispatchEvent(new CustomEvent("close", {
857
857
  detail: { mode: this._state.mode, element: this },
858
- bubbles: true,
859
- composed: true,
860
858
  }));
861
859
  }
862
860
  dispatchChangeEvent(detail) {
@@ -105,7 +105,7 @@ export class TySelectedOptions extends HTMLElement {
105
105
  return ctx;
106
106
  }
107
107
  templateSource(template) {
108
- if (template.content.firstElementChild)
108
+ if (template.content.hasChildNodes())
109
109
  return template.content;
110
110
  const frag = document.createDocumentFragment();
111
111
  template.childNodes.forEach(n => frag.appendChild(n.cloneNode(true)));
@@ -27,6 +27,8 @@ export interface TabChangeDetail {
27
27
  export declare class TyTabs extends HTMLElement {
28
28
  static get observedAttributes(): string[];
29
29
  constructor();
30
+ private _childObserver;
31
+ private _childRenderQueued;
30
32
  connectedCallback(): void;
31
33
  disconnectedCallback(): void;
32
34
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
@@ -515,12 +515,27 @@ export class TyTabs extends HTMLElement {
515
515
  }
516
516
  constructor() {
517
517
  super();
518
+ this._childObserver = null;
519
+ this._childRenderQueued = false;
518
520
  this.attachShadow({ mode: 'open' });
519
521
  }
520
522
  connectedCallback() {
521
523
  render(this);
524
+ this._childObserver = new MutationObserver(() => {
525
+ if (this._childRenderQueued)
526
+ return;
527
+ this._childRenderQueued = true;
528
+ queueMicrotask(() => {
529
+ this._childRenderQueued = false;
530
+ if (this.isConnected)
531
+ render(this);
532
+ });
533
+ });
534
+ this._childObserver.observe(this, { childList: true });
522
535
  }
523
536
  disconnectedCallback() {
537
+ this._childObserver?.disconnect();
538
+ this._childObserver = null;
524
539
  cleanup(this);
525
540
  }
526
541
  attributeChangedCallback(name, oldValue, newValue) {
@@ -57,7 +57,28 @@ function removeDescribedBy(anchor, id) {
57
57
  anchor.removeAttribute('aria-describedby');
58
58
  }
59
59
  }
60
+ function ensureTooltipMotionStyles() {
61
+ if (document.getElementById('ty-tooltip-motion'))
62
+ return;
63
+ const style = document.createElement('style');
64
+ style.id = 'ty-tooltip-motion';
65
+ style.textContent = `
66
+ .ty-tooltip-popover:popover-open {
67
+ transition:
68
+ opacity var(--ty-tooltip-duration, 120ms) ease-out,
69
+ translate var(--ty-tooltip-duration, 120ms) ease-out;
70
+ }
71
+ @starting-style {
72
+ .ty-tooltip-popover:popover-open { opacity: 0; translate: 0 4px; }
73
+ .ty-tooltip-popover:popover-open[data-side="bottom"] { translate: 0 -4px; }
74
+ .ty-tooltip-popover:popover-open[data-side="left"] { translate: 4px 0; }
75
+ .ty-tooltip-popover:popover-open[data-side="right"] { translate: -4px 0; }
76
+ }
77
+ `;
78
+ document.head.appendChild(style);
79
+ }
60
80
  function getOrCreatePopover(el) {
81
+ ensureTooltipMotionStyles();
61
82
  let popover = popoverElements.get(el);
62
83
  if (!popover) {
63
84
  popover = document.createElement('div');
@@ -143,6 +164,7 @@ function updatePosition(el) {
143
164
  });
144
165
  popover.style.left = `${position.x}px`;
145
166
  popover.style.top = `${position.y}px`;
167
+ popover.dataset.side = position.placement.split('-')[0];
146
168
  }
147
169
  function cleanupAutoUpdate(el) {
148
170
  const cleanup = autoUpdateCleanup.get(el);
@@ -30,6 +30,8 @@ export interface WizardStepChangeDetail {
30
30
  export declare class TyWizard extends HTMLElement {
31
31
  static get observedAttributes(): string[];
32
32
  constructor();
33
+ private _childObserver;
34
+ private _childRenderQueued;
33
35
  connectedCallback(): void;
34
36
  disconnectedCallback(): void;
35
37
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
@@ -399,12 +399,27 @@ export class TyWizard extends HTMLElement {
399
399
  }
400
400
  constructor() {
401
401
  super();
402
+ this._childObserver = null;
403
+ this._childRenderQueued = false;
402
404
  this.attachShadow({ mode: 'open' });
403
405
  }
404
406
  connectedCallback() {
405
407
  render(this);
408
+ this._childObserver = new MutationObserver(() => {
409
+ if (this._childRenderQueued)
410
+ return;
411
+ this._childRenderQueued = true;
412
+ queueMicrotask(() => {
413
+ this._childRenderQueued = false;
414
+ if (this.isConnected)
415
+ render(this);
416
+ });
417
+ });
418
+ this._childObserver.observe(this, { childList: true });
406
419
  }
407
420
  disconnectedCallback() {
421
+ this._childObserver?.disconnect();
422
+ this._childObserver = null;
408
423
  cleanup(this);
409
424
  }
410
425
  attributeChangedCallback(name, oldValue, newValue) {
@@ -8,7 +8,7 @@ const solidFlavor = (f, fb) => {
8
8
  const solid = (suffix) => tokenRef('--ty-solid', f, suffix, fb);
9
9
  const borderL = f === 'neutral'
10
10
  ? ' --_border-l: var(--ty-button-border-l-neutral, var(--ty-button-border-l, -0.15));'
11
- : ' --_border-l: var(--ty-button-border-l, -0.15);';
11
+ : ' --_border-l: var(--_muted-border-l, var(--ty-button-border-l, -0.15));';
12
12
  const plusNudge = f === 'neutral' ? nudgedMid : nudged;
13
13
  return `
14
14
  button.solid.${f} { --_ring: ${tokenRef('--ty-color', f, '', fb)}; --_solid-bg: var(--ty-button-bg, var(--_muted-solid-bg, ${solid('')})); --_solid-rest: var(--ty-button-bg, var(--_muted-solid-bg, ${solid('')}));${borderL} color: var(--ty-button-color, var(--_muted-solid-fg, ${solid('-fg')})); }
@@ -288,6 +288,7 @@ button.muted {
288
288
  --_muted-solid-fg: var(--ty-solid-neutral-fg);
289
289
  --_muted-outlined-line: var(--ty-color-neutral);
290
290
  --_muted-ghost-fg: var(--ty-color-neutral);
291
+ --_muted-border-l: var(--ty-button-border-l-neutral, var(--ty-button-border-l, -0.15));
291
292
  }
292
293
  button.muted.tone-plus {
293
294
  --_muted-solid-bg: var(--ty-solid-neutral-strong);
@@ -310,6 +311,7 @@ button.muted.tone-minus {
310
311
  --_muted-solid-fg: unset;
311
312
  --_muted-outlined-line: unset;
312
313
  --_muted-ghost-fg: unset;
314
+ --_muted-border-l: unset;
313
315
  }
314
316
  }
315
317
  button.muted:active:not(:disabled),
@@ -318,6 +320,7 @@ button.muted:focus-visible:not(:disabled) {
318
320
  --_muted-solid-fg: unset;
319
321
  --_muted-outlined-line: unset;
320
322
  --_muted-ghost-fg: unset;
323
+ --_muted-border-l: unset;
321
324
  }
322
325
 
323
326
  button.pill {
@@ -252,7 +252,15 @@ ${FLAVORS.filter((f) => f !== "neutral").map((f) => datePickerFlavor(f)).join(""
252
252
  padding: var(--calendar-padding, 8px);
253
253
 
254
254
  opacity: 0;
255
- transition: opacity 200ms ease;
255
+ /* Entrance motion uses the individual scale/translate properties so it
256
+ composes with the collision-shift transform below (they apply first). */
257
+ scale: 0.96;
258
+ translate: 0 -6px;
259
+ transform-origin: top center;
260
+ transition:
261
+ opacity var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
262
+ scale var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1)),
263
+ translate var(--ty-popup-duration, 180ms) var(--ty-popup-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
256
264
 
257
265
  transform: translate(var(--calendar-offset-x, 0px), var(--calendar-offset-y, 0px));
258
266
  top: -1000px;
@@ -269,10 +277,28 @@ ${FLAVORS.filter((f) => f !== "neutral").map((f) => datePickerFlavor(f)).join(""
269
277
  bottom: var(--calendar-y);
270
278
  top: auto;
271
279
  flex-direction: column-reverse;
280
+ translate: 0 6px;
281
+ transform-origin: bottom center;
272
282
  }
273
283
 
274
284
  .calendar-dialog.open {
275
285
  opacity: 1;
286
+ scale: 1;
287
+ translate: 0 0;
288
+ }
289
+
290
+ /* First-frame "from" values — see select-base.ts: transitions can't start on
291
+ an element that was display:none last frame; @starting-style fixes that. */
292
+ @starting-style {
293
+ .calendar-dialog.open {
294
+ opacity: 0;
295
+ scale: 0.96;
296
+ translate: 0 -6px;
297
+ }
298
+
299
+ .calendar-dialog.open.position-above {
300
+ translate: 0 6px;
301
+ }
276
302
  }
277
303
 
278
304
  .calendar-dialog::backdrop {
@@ -4,4 +4,4 @@
4
4
  * CSS for the modal component wrapper.
5
5
  * The modal is a pure wrapper - all content styling is user-defined.
6
6
  */
7
- export declare const modalStyles = "\n:host {\n display: contents;\n /* Don't interfere with layout */\n}\n\n/* Part of the modal wrapper functionality, not user content styling */\n.close-button {\n position: absolute;\n top: -8px;\n right: -8px;\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 4px;\n border-radius: 4px;\n transition: var(--ty-transition-colors);\n z-index: 1;\n color: #d8d8d8;\n\n /* Remove all default browser styling that could cause outlines/borders */\n outline: none;\n box-shadow: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n\n /* Remove mobile tap highlights and focus rings */\n -webkit-tap-highlight-color: transparent;\n -webkit-touch-callout: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.close-button:hover {\n color: white;\n}\n\n/* Explicitly remove focus and active states for all interaction modes */\n.close-button:focus,\n.close-button:focus-visible,\n.close-button:focus-within,\n.close-button:active {\n outline: none !important;\n box-shadow: none !important;\n border: none !important;\n background: transparent !important;\n}\n\n/* Override hover color even in focus/active states */\n.close-button:hover,\n.close-button:focus:hover,\n.close-button:active:hover {\n color: white;\n}\n\n.close-button.hide {\n visibility: hidden;\n}\n\n/* Pure wrapper: only essential modal functionality, no styling of user content */\n.ty-modal-dialog {\n /* Reset default dialog styles */\n padding: 18px;\n border: none;\n margin: 0;\n background: transparent;\n\n /* Centered */\n position: fixed;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n z-index: 1000;\n overflow: hidden;\n\n /* No sizing - let user content determine size */\n max-width: 85vw;\n max-height: 90vh;\n\n outline: none;\n}\n\n/* Backdrop is part of modal functionality, not user content */\n.ty-modal-dialog::backdrop {\n background: var(--ty-modal-backdrop, rgba(0, 0, 0, 0.5));\n backdrop-filter: var(--ty-modal-backdrop-blur, blur(2px));\n animation: ty-modal-backdrop-enter var(--ty-modal-duration, 200ms) ease-out;\n}\n\n/* When backdrop is disabled */\n.ty-modal-dialog:not([data-backdrop])::backdrop {\n background: transparent;\n backdrop-filter: none;\n}\n\n/* Minimal container - no styling imposed on user content */\n.modal-content {\n color: var(--ty-text);\n}\n\n@keyframes ty-modal-backdrop-enter {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n";
7
+ export declare const modalStyles = "\n:host {\n display: contents;\n /* Don't interfere with layout */\n}\n\n/* Part of the modal wrapper functionality, not user content styling */\n.close-button {\n position: absolute;\n top: -8px;\n right: -8px;\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 4px;\n border-radius: 4px;\n transition: var(--ty-transition-colors);\n z-index: 1;\n color: #d8d8d8;\n\n /* Remove all default browser styling that could cause outlines/borders */\n outline: none;\n box-shadow: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n\n /* Remove mobile tap highlights and focus rings */\n -webkit-tap-highlight-color: transparent;\n -webkit-touch-callout: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.close-button:hover {\n color: white;\n}\n\n/* Explicitly remove focus and active states for all interaction modes */\n.close-button:focus,\n.close-button:focus-visible,\n.close-button:focus-within,\n.close-button:active {\n outline: none !important;\n box-shadow: none !important;\n border: none !important;\n background: transparent !important;\n}\n\n/* Override hover color even in focus/active states */\n.close-button:hover,\n.close-button:focus:hover,\n.close-button:active:hover {\n color: white;\n}\n\n.close-button.hide {\n visibility: hidden;\n}\n\n/* Pure wrapper: only essential modal functionality, no styling of user content */\n.ty-modal-dialog {\n /* Reset default dialog styles */\n padding: 18px;\n border: none;\n margin: 0;\n background: transparent;\n\n /* Centered */\n position: fixed;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n z-index: 1000;\n overflow: hidden;\n\n /* No sizing - let user content determine size */\n max-width: 85vw;\n max-height: 90vh;\n\n outline: none;\n}\n\n/* Panel zoom-in synced with the backdrop fade (shadcn-style zoom-in-95).\n Enter-only: animating exit on a native <dialog> means delaying close(). */\n.ty-modal-dialog[open] {\n animation: ty-modal-enter var(--ty-modal-duration, 200ms) ease-out;\n}\n\n@keyframes ty-modal-enter {\n from {\n opacity: 0;\n transform: translate(-50%, -50%) scale(0.93);\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, -50%) scale(1);\n }\n}\n\n/* Backdrop is part of modal functionality, not user content */\n.ty-modal-dialog::backdrop {\n background: var(--ty-modal-backdrop, rgba(0, 0, 0, 0.5));\n backdrop-filter: var(--ty-modal-backdrop-blur, blur(2px));\n animation: ty-modal-backdrop-enter var(--ty-modal-duration, 200ms) ease-out;\n}\n\n/* When backdrop is disabled */\n.ty-modal-dialog:not([data-backdrop])::backdrop {\n background: transparent;\n backdrop-filter: none;\n}\n\n/* Minimal container - no styling imposed on user content */\n.modal-content {\n color: var(--ty-text);\n}\n\n@keyframes ty-modal-backdrop-enter {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n";
@@ -84,6 +84,24 @@ export const modalStyles = `
84
84
  outline: none;
85
85
  }
86
86
 
87
+ /* Panel zoom-in synced with the backdrop fade (shadcn-style zoom-in-95).
88
+ Enter-only: animating exit on a native <dialog> means delaying close(). */
89
+ .ty-modal-dialog[open] {
90
+ animation: ty-modal-enter var(--ty-modal-duration, 200ms) ease-out;
91
+ }
92
+
93
+ @keyframes ty-modal-enter {
94
+ from {
95
+ opacity: 0;
96
+ transform: translate(-50%, -50%) scale(0.93);
97
+ }
98
+
99
+ to {
100
+ opacity: 1;
101
+ transform: translate(-50%, -50%) scale(1);
102
+ }
103
+ }
104
+
87
105
  /* Backdrop is part of modal functionality, not user content */
88
106
  .ty-modal-dialog::backdrop {
89
107
  background: var(--ty-modal-backdrop, rgba(0, 0, 0, 0.5));
@@ -1,2 +1,2 @@
1
1
  /** Option Component Styles */
2
- export declare const optionStyles = "\n.option-content {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--ty-spacing-2);\n box-sizing: border-box;\n width: 100%;\n\n cursor: pointer;\n user-select: none;\n\n transition: var(--ty-transition-all);\n\n /* Linear-paired typography */\n color: var(--ty-text);\n font-family: inherit;\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n padding: var(--ty-spacing-2) var(--ty-spacing-3);\n border-radius: var(--ty-radius-md);\n}\n\n:host([cloned]) .option-content {\n padding: 0px;\n background: transparent;\n}\n\n.option-content:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[highlighted] {\n background: var(--ty-bg-neutral-soft);\n color: var(--ty-text-strong);\n font-weight: var(--ty-font-semibold);\n}\n\n/* Selected: quiet \u2014 bolder text color only, no background slab, no weight change */\n.option-content[selected] {\n background: transparent;\n color: var(--ty-text-bold);\n}\n\n/* Lucide check \u2014 right-aligned, visible only on selected options */\n.option-check {\n display: none;\n flex-shrink: 0;\n width: 14px;\n height: 14px;\n margin-left: auto;\n color: var(--ty-color-primary);\n}\n\n.option-content[selected] .option-check {\n display: block;\n}\n\n/* Never show the check on the copy cloned into the select's trigger field */\n:host([cloned]) .option-check {\n display: none;\n}\n\n.option-content[selected]:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.option-content[hidden] {}\n\n.option-content * {\n pointer-events: none;\n}\n";
2
+ export declare const optionStyles = "\n.option-content {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--ty-spacing-2);\n box-sizing: border-box;\n width: 100%;\n\n cursor: pointer;\n user-select: none;\n\n transition: var(--ty-transition-all);\n\n /* Linear-paired typography */\n color: var(--ty-text);\n font-family: inherit;\n font-size: var(--ty-font-sm);\n line-height: var(--ty-leading-sm);\n letter-spacing: var(--ty-tracking-sm);\n padding: var(--ty-spacing-2) var(--ty-spacing-3);\n border-radius: var(--ty-radius-md);\n}\n\n/* Cloned copy (projected into the select's trigger field): host defaults to\n inline like any unstyled custom element, which ignores width/overflow \u2014\n force a block box so the parent's flex-basis:0 truncation (select.ts\n .select-stub.has-clone slot[name=\"selected\"]) actually clips it, and\n collapse the content to a single ellipsized line instead of wrapping. */\n:host([cloned]) {\n display: block;\n min-width: 0;\n overflow: hidden;\n}\n:host([cloned]) .option-content {\n display: block;\n padding: 0px;\n background: transparent;\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n.option-content:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[highlighted] {\n background: var(--ty-bg-neutral-soft);\n color: var(--ty-text-strong);\n font-weight: var(--ty-font-semibold);\n}\n\n/* Selected: quiet \u2014 bolder text color only, no background slab, no weight change */\n.option-content[selected] {\n background: transparent;\n color: var(--ty-text-bold);\n}\n\n/* Lucide check \u2014 right-aligned, visible only on selected options */\n.option-check {\n display: none;\n flex-shrink: 0;\n width: 14px;\n height: 14px;\n margin-left: auto;\n color: var(--ty-color-primary);\n}\n\n.option-content[selected] .option-check {\n display: block;\n}\n\n/* Never show the check on the copy cloned into the select's trigger field */\n:host([cloned]) .option-check {\n display: none;\n}\n\n.option-content[selected]:hover {\n background: var(--ty-bg-neutral-soft);\n}\n\n.option-content[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.option-content[hidden] {}\n\n.option-content * {\n pointer-events: none;\n}\n";