tyrell-components 1.0.0-TC53 → 1.0.0-TC55

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.
@@ -387,17 +387,30 @@ const disabledAt = (i) => {
387
387
  const v = days[i].value;
388
388
  return (minTs !== null && v < minTs) || (maxTs !== null && v > maxTs);
389
389
  };
390
- let defaultIndex = days.findIndex(d => d.isSelected);
390
+ const firstEnabled = (pred) => {
391
+ for (let i = 0; i < 42; i++) {
392
+ if (pred(i) && !disabledAt(i))
393
+ return i;
394
+ }
395
+ return -1;
396
+ };
397
+ let defaultIndex = firstEnabled(i => !!days[i].isSelected);
398
+ let provisional = false;
391
399
  if (defaultIndex === -1)
392
- defaultIndex = days.findIndex(d => d.today);
400
+ defaultIndex = firstEnabled(i => !!days[i].today);
401
+ if (defaultIndex === -1) {
402
+ defaultIndex = firstEnabled(() => true);
403
+ provisional = true;
404
+ }
393
405
  if (defaultIndex === -1)
394
406
  defaultIndex = 0;
395
407
  if (this._focusedIndex === null ||
396
408
  this._focusedIndex < 0 ||
397
409
  this._focusedIndex > 41 ||
398
410
  disabledAt(this._focusedIndex)) {
399
- this._focusedIndex = defaultIndex;
411
+ this._focusedIndex = provisional ? null : defaultIndex;
400
412
  }
413
+ const effectiveFocusedIndex = this._focusedIndex ?? defaultIndex;
401
414
  this._cellElements = [];
402
415
  let flatIndex = 0;
403
416
  dayWeeks.forEach(week => {
@@ -407,7 +420,7 @@ dayRow.setAttribute('role', 'row');
407
420
  week.forEach(dayContext => {
408
421
  const disabled = (minTs !== null && dayContext.value < minTs) ||
409
422
  (maxTs !== null && dayContext.value > maxTs);
410
- const dayCell = this.renderDayCell(dayContext, disabled, flatIndex === this._focusedIndex);
423
+ const dayCell = this.renderDayCell(dayContext, disabled, flatIndex === effectiveFocusedIndex);
411
424
  dayCell.className = `${dayCell.className} calendar-cell calendar-day-cell`;
412
425
  dayRow.appendChild(dayCell);
413
426
  this._cellElements[flatIndex] = dayCell;
@@ -32,7 +32,7 @@ export interface CalendarNavigateDetail {
32
32
  */
33
33
  export declare class TyCalendar extends HTMLElement {
34
34
  private _state;
35
- private _showNavigation;
35
+ private _hideNavigation;
36
36
  private _stateless;
37
37
  private _size;
38
38
  private _width?;
@@ -44,6 +44,7 @@ export declare class TyCalendar extends HTMLElement {
44
44
  private _navigation?;
45
45
  private _monthDisplay?;
46
46
  private _internals?;
47
+ private _initialValue;
47
48
  private _localeObserver?;
48
49
  static formAssociated: boolean;
49
50
  static get observedAttributes(): string[];
@@ -65,8 +66,8 @@ export declare class TyCalendar extends HTMLElement {
65
66
  set day(value: number | undefined);
66
67
  get locale(): string;
67
68
  set locale(value: string);
68
- get showNavigation(): boolean;
69
- set showNavigation(value: boolean);
69
+ get hideNavigation(): boolean;
70
+ set hideNavigation(value: boolean);
70
71
  get stateless(): boolean;
71
72
  set stateless(value: boolean);
72
73
  get size(): CalendarSize;
@@ -83,6 +84,7 @@ export declare class TyCalendar extends HTMLElement {
83
84
  * Initialize state from HTML attributes on first load
84
85
  */
85
86
  private initializeFromAttributes;
87
+ formResetCallback(): void;
86
88
  private syncStateFromAttributes;
87
89
  private syncChildComponents;
88
90
  private updateFormValue;
@@ -37,14 +37,15 @@ return `${year}-${monthStr}-${dayStr}`;
37
37
  }
38
38
  export class TyCalendar extends HTMLElement {
39
39
  static get observedAttributes() {
40
- return ['value', 'year', 'month', 'day', 'show-navigation', 'stateless', 'locale', 'name', 'size', 'width', 'min', 'max', 'flavor'];
40
+ return ['value', 'year', 'month', 'day', 'hide-navigation', 'stateless', 'locale', 'name', 'size', 'width', 'min', 'max', 'flavor'];
41
41
  }
42
42
  constructor() {
43
43
  super();
44
- this._showNavigation = true;
44
+ this._hideNavigation = false;
45
45
  this._stateless = false;
46
46
  this._size = 'md';
47
47
  this._flavor = 'primary';
48
+ this._initialValue = '';
48
49
  const current = getCurrentDate();
49
50
  this._state = {
50
51
  displayYear: current.year,
@@ -115,8 +116,8 @@ this.syncStateFromAttributes();
115
116
  this.syncChildComponents();
116
117
  this.updateFormValue();
117
118
  break;
118
- case 'show-navigation':
119
- this._showNavigation = newValue !== 'false';
119
+ case 'hide-navigation':
120
+ this._hideNavigation = newValue !== null;
120
121
  this.render();
121
122
  break;
122
123
  case 'stateless':
@@ -256,12 +257,15 @@ return getEffectiveLocale(this, this.getAttribute('locale'));
256
257
  set locale(value) {
257
258
  this.setAttribute('locale', value);
258
259
  }
259
- get showNavigation() {
260
- return this._showNavigation;
260
+ get hideNavigation() {
261
+ return this._hideNavigation;
261
262
  }
262
- set showNavigation(value) {
263
- this._showNavigation = value;
264
- this.setAttribute('show-navigation', value.toString());
263
+ set hideNavigation(value) {
264
+ this._hideNavigation = value;
265
+ if (value)
266
+ this.setAttribute('hide-navigation', '');
267
+ else
268
+ this.removeAttribute('hide-navigation');
265
269
  }
266
270
  get stateless() {
267
271
  return this._stateless;
@@ -363,7 +367,7 @@ initializeFromAttributes() {
363
367
  const yearStr = this.getAttribute('year');
364
368
  const monthStr = this.getAttribute('month');
365
369
  const dayStr = this.getAttribute('day');
366
- const showNavStr = this.getAttribute('show-navigation');
370
+ const hideNav = this.hasAttribute('hide-navigation');
367
371
  const statelessStr = this.getAttribute('stateless');
368
372
  const sizeStr = this.getAttribute('size');
369
373
  const widthStr = this.getAttribute('width');
@@ -385,9 +389,7 @@ this._state.selectedMonth = month;
385
389
  this._state.selectedDay = day;
386
390
  }
387
391
  }
388
- if (showNavStr) {
389
- this._showNavigation = showNavStr !== 'false';
390
- }
392
+ this._hideNavigation = hideNav;
391
393
  if (statelessStr !== null) {
392
394
  this._stateless = statelessStr !== 'false';
393
395
  }
@@ -397,6 +399,10 @@ this._size = sizeStr;
397
399
  if (widthStr) {
398
400
  this._width = widthStr;
399
401
  }
402
+ this._initialValue = this.value;
403
+ }
404
+ formResetCallback() {
405
+ this.value = this._initialValue;
400
406
  }
401
407
  syncStateFromAttributes() {
402
408
  const yearStr = this.getAttribute('year');
@@ -611,7 +617,7 @@ ensureStyles(root, { css: calendarStyles, id: 'ty-calendar' });
611
617
  root.innerHTML = '';
612
618
  const container = document.createElement('div');
613
619
  container.className = 'calendar-container';
614
- if (this._showNavigation) {
620
+ if (!this._hideNavigation) {
615
621
  const nav = this.createNavigation();
616
622
  container.appendChild(nav);
617
623
  }
@@ -45,10 +45,11 @@ return classes.join(' ');
45
45
  async copyToClipboard() {
46
46
  if (this.disabled || !this.value)
47
47
  return;
48
+ const value = this.value;
48
49
  try {
49
- await navigator.clipboard.writeText(this.value);
50
+ await navigator.clipboard.writeText(value);
50
51
  this.dispatchEvent(new CustomEvent('copy-success', {
51
- detail: { value: this.value },
52
+ detail: { value },
52
53
  bubbles: true,
53
54
  composed: true
54
55
  }));
@@ -56,7 +57,7 @@ composed: true
56
57
  catch (err) {
57
58
  try {
58
59
  const textarea = document.createElement('textarea');
59
- textarea.value = this.value;
60
+ textarea.value = value;
60
61
  textarea.style.position = 'fixed';
61
62
  textarea.style.opacity = '0';
62
63
  document.body.appendChild(textarea);
@@ -64,7 +65,7 @@ textarea.select();
64
65
  document.execCommand('copy');
65
66
  document.body.removeChild(textarea);
66
67
  this.dispatchEvent(new CustomEvent('copy-success', {
67
- detail: { value: this.value },
68
+ detail: { value },
68
69
  bubbles: true,
69
70
  composed: true
70
71
  }));
@@ -168,6 +168,13 @@ export declare class TyDatePicker extends TyComponent<DatePickerState> {
168
168
  * Compares before setting to prevent circular triggers
169
169
  */
170
170
  private syncFormValue;
171
+ /**
172
+ * Reflect constraint validation to the owning <form> via ElementInternals.
173
+ * Without this `required` is only cosmetic — unlike ty-input/ty-select/
174
+ * ty-calendar, which all call setValidity, an empty required date-picker
175
+ * never blocks form submission.
176
+ */
177
+ private updateValidity;
171
178
  /**
172
179
  * Handle time input changes from TimeInput class
173
180
  */
@@ -381,6 +381,7 @@ withTime: this._state.withTime,
381
381
  open: this._state.open,
382
382
  };
383
383
  }
384
+ this.syncFormValue();
384
385
  break;
385
386
  }
386
387
  const currentComponents = {
@@ -400,6 +401,7 @@ this._state = {
400
401
  ...this._state,
401
402
  ...newComponents,
402
403
  };
404
+ this.syncFormValue();
403
405
  }
404
406
  break;
405
407
  }
@@ -479,6 +481,18 @@ this.setProperty('value', null);
479
481
  }
480
482
  }
481
483
  }
484
+ updateValidity() {
485
+ if (this.disabled) {
486
+ this._internals.setValidity({});
487
+ return;
488
+ }
489
+ if (this.required && !this.value) {
490
+ this._internals.setValidity({ valueMissing: true }, 'Please fill out this field');
491
+ }
492
+ else {
493
+ this._internals.setValidity({});
494
+ }
495
+ }
482
496
  handleTimeInputChange(hour, minute) {
483
497
  if (!this.hasDate())
484
498
  return;
@@ -797,6 +811,7 @@ return container;
797
811
  render() {
798
812
  if (!this.shadowRoot)
799
813
  return;
814
+ this.updateValidity();
800
815
  ensureStyles(this.shadowRoot, { css: datePickerStyles, id: 'ty-date-picker' });
801
816
  if (isMobileTouch()) {
802
817
  const existingNativeInput = this.shadowRoot.querySelector('.native-date-input');
@@ -864,6 +879,16 @@ if (flavor && flavor !== 'default')
864
879
  calendar.setAttribute('flavor', flavor);
865
880
  else
866
881
  calendar.removeAttribute('flavor');
882
+ const min = this.getProperty('min');
883
+ const max = this.getProperty('max');
884
+ if (min)
885
+ calendar.setAttribute('min', min);
886
+ else
887
+ calendar.removeAttribute('min');
888
+ if (max)
889
+ calendar.setAttribute('max', max);
890
+ else
891
+ calendar.removeAttribute('max');
867
892
  }
868
893
  if (this._timeInput && this._state.hour !== undefined && this._state.minute !== undefined) {
869
894
  this._timeInput.setTime(this._state.hour, this._state.minute);
@@ -23,7 +23,7 @@ return ['value', 'disabled', 'selected', 'highlighted', 'hidden'];
23
23
  }
24
24
  connectedCallback() {
25
25
  const instanceValue = Object.getOwnPropertyDescriptor(this, 'value');
26
- if (instanceValue && instanceValue.value !== undefined) {
26
+ if (instanceValue) {
27
27
  this._value = instanceValue.value;
28
28
  delete this.value;
29
29
  }
@@ -170,18 +170,16 @@ this._contentObserver = new MutationObserver((records) => {
170
170
  const wrap = this._scrollWrapper;
171
171
  if (!wrap)
172
172
  return;
173
- const wrapTop = wrap.getBoundingClientRect().top;
174
- let addedAbove = 0;
175
- for (const r of records) {
176
- r.addedNodes.forEach((n) => {
177
- if (n.nodeType !== Node.ELEMENT_NODE)
173
+ const added = new Set();
174
+ for (const r of records)
175
+ r.addedNodes.forEach((n) => added.add(n));
176
+ if (added.size === 0)
178
177
  return;
179
- const rect = n.getBoundingClientRect();
180
- if (rect.bottom <= wrapTop)
181
- addedAbove += rect.height;
182
- else if (rect.top < wrapTop)
183
- addedAbove += wrapTop - rect.top;
184
- });
178
+ let addedAbove = 0;
179
+ for (const child of Array.from(this.children)) {
180
+ if (!added.has(child))
181
+ break;
182
+ addedAbove += child.offsetHeight;
185
183
  }
186
184
  if (addedAbove > 0) {
187
185
  wrap.scrollTop += addedAbove;
@@ -106,11 +106,10 @@ export declare class TySelect extends TyComponent<SelectState> {
106
106
  };
107
107
  coerce: (v: any) => "none" | "slug";
108
108
  };
109
- searchable: {
110
- type: "string";
109
+ notSearchable: {
110
+ type: "boolean";
111
111
  visual: boolean;
112
- default: string;
113
- coerce: (v: any) => "auto" | "always" | "never";
112
+ default: boolean;
114
113
  };
115
114
  align: {
116
115
  type: "string";
@@ -142,6 +141,11 @@ export declare class TySelect extends TyComponent<SelectState> {
142
141
  visual: boolean;
143
142
  default: string;
144
143
  };
144
+ "selected-label": {
145
+ type: "string";
146
+ visual: boolean;
147
+ default: string;
148
+ };
145
149
  "no-options-message": {
146
150
  type: "string";
147
151
  visual: boolean;
@@ -170,11 +174,12 @@ export declare class TySelect extends TyComponent<SelectState> {
170
174
  private _externalSearch;
171
175
  private _allowCreate;
172
176
  private _createTransform;
173
- private _searchable;
177
+ private _notSearchable;
174
178
  private _loading;
175
179
  private _scrollLockId;
176
180
  private _size;
177
181
  private _availableLabel;
182
+ private _selectedLabel;
178
183
  private _noOptionsMessage;
179
184
  private _state;
180
185
  private _stubClickHandler;
@@ -208,6 +213,29 @@ export declare class TySelect extends TyComponent<SelectState> {
208
213
  private parseValue;
209
214
  /** Initialize component state from the value property / pre-selected children. */
210
215
  private initializeState;
216
+ /**
217
+ * Declarative init: no `value` attribute, but markup pre-marked one or more
218
+ * options `selected` — native <select> semantics. This is the ONE place
219
+ * selection is read FROM the DOM rather than treated as a rendering of
220
+ * state (see getSelectedValues()): it seeds initial state, once. After
221
+ * this, `value` rules — flipping an option's `selected` property later
222
+ * does nothing, same as native.
223
+ *
224
+ * Called from initializeState() at connect AND from the child observer:
225
+ * a real HTML parser upgrades a custom element the moment its start tag is
226
+ * seen, BEFORE its children exist — `<ty-select>` connects with zero
227
+ * `<ty-option>` children when it's part of the page's own markup (as
228
+ * opposed to an already-built fragment attached via innerHTML/appendChild,
229
+ * where children exist at connect). Calling this only at connect would
230
+ * silently miss every option parsed after that instant, which is the
231
+ * common case for markup that is literally in the page's initial HTML.
232
+ *
233
+ * Safe to call repeatedly: no-ops once `value` has ever been explicitly
234
+ * set (attribute or property) or state is already non-empty — including
235
+ * after a real `.clear()`, which removes `selected` from every option via
236
+ * syncSelectedTags, so there is nothing left here to (re-)seed from.
237
+ */
238
+ private resolveDeclarativeSelection;
211
239
  /**
212
240
  * Get all option elements from the component (ty-option preferred, ty-tag accepted)
213
241
  */
@@ -223,7 +251,15 @@ export declare class TySelect extends TyComponent<SelectState> {
223
251
  */
224
252
  private deselectTag;
225
253
  /**
226
- * Get array of currently selected values from tags (ALWAYS reads from DOM)
254
+ * Get the array of currently selected values.
255
+ *
256
+ * Reads STATE (`_state.selectedValues`), not the DOM: `value` is the
257
+ * selection, and an option's `selected` attribute is a rendering of it
258
+ * (written by syncSelectedTags below), never the other way round. This
259
+ * matters whenever options don't fully reflect the selection — external
260
+ * search legitimately has a selected value with no matching option
261
+ * present; a DOM scan would report empty and desync .value, `required`
262
+ * validation and the click-toggle logic from what's actually selected.
227
263
  */
228
264
  private getSelectedValues;
229
265
  private syncSelectedTags;
@@ -299,6 +335,13 @@ export declare class TySelect extends TyComponent<SelectState> {
299
335
  private blockSearchClick;
300
336
  private handleSearchInput;
301
337
  private handleKeyboard;
338
+ /**
339
+ * Next tag index in `direction` (-1/1) that isn't disabled, wrapping
340
+ * around — mirrors handleTagClick's disabled check so keyboard nav can
341
+ * never land on/select what a click already refuses to. Returns -1 if
342
+ * every tag is disabled (or there are none).
343
+ */
344
+ private nextEnabledTagIndex;
302
345
  private filterTags;
303
346
  private updateTagVisibility;
304
347
  private updateOptionsVisibility;
@@ -340,9 +383,25 @@ export declare class TySelect extends TyComponent<SelectState> {
340
383
  private handleMobileTagClick;
341
384
  private updateMobileSelectedState;
342
385
  /**
343
- * Should the popup show a search row? external-search always (the input is
344
- * the mechanism); searchable always/never wins otherwise; 'auto' shows it
345
- * only when the option list is long enough to need filtering.
386
+ * Hand the dialog's spare vertical space to one section or the other. Only
387
+ * one can own it: on a phone there isn't enough height for two scrollable
388
+ * lists to both be usefully tall, so tapping a header trades the space
389
+ * rather than splitting it. Selecting from the list hands it back to
390
+ * "available" — you're still picking, so that's the list you want open.
391
+ */
392
+ private expandMobileSection;
393
+ /**
394
+ * The selected values as dismissible chips, pinned above the available
395
+ * list so a long list can never scroll the current selection out of sight.
396
+ * Rebuilt wholesale: the strip is small, and diffing it would have to
397
+ * track chip identity for the sake of a handful of nodes.
398
+ */
399
+ private renderMobileSelectedChips;
400
+ /**
401
+ * Should the popup show a search row? external-search/allow-create pin it
402
+ * on (the input is the only way to get a query out at all), not-searchable
403
+ * forces it off, and otherwise it follows the list: shown once there are
404
+ * more options than fit without scrolling.
346
405
  */
347
406
  private shouldShowSearch;
348
407
  /**
@@ -400,8 +459,8 @@ export declare class TySelect extends TyComponent<SelectState> {
400
459
  set clearable(value: boolean);
401
460
  get required(): boolean;
402
461
  set required(value: boolean);
403
- get searchable(): "auto" | "always" | "never";
404
- set searchable(value: "auto" | "always" | "never" | boolean);
462
+ get notSearchable(): boolean;
463
+ set notSearchable(value: boolean);
405
464
  get externalSearch(): boolean;
406
465
  set externalSearch(value: boolean);
407
466
  get debounce(): number;