tyrell-components 1.0.0-TC49 → 1.0.0-TC51

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.
@@ -52,8 +52,10 @@ export declare class TyModal extends HTMLElement {
52
52
  set open(v: boolean);
53
53
  get backdrop(): boolean;
54
54
  set backdrop(v: boolean);
55
+ /** @deprecated Use preventOutsideClick — prevent-* wins when both are set. */
55
56
  get closeOnOutsideClick(): boolean;
56
57
  set closeOnOutsideClick(v: boolean);
58
+ /** @deprecated Use preventEscape — prevent-* wins when both are set. */
57
59
  get closeOnEscape(): boolean;
58
60
  set closeOnEscape(v: boolean);
59
61
  get preventOutsideClick(): boolean;
@@ -895,7 +895,11 @@ export class TySelect extends TyComponent {
895
895
  const slot = shadow.querySelector('slot[name="trigger"]');
896
896
  if (!stub || !slot)
897
897
  return;
898
- const sync = () => stub.classList.toggle("custom-trigger", slot.assignedElements().length > 0);
898
+ const sync = () => {
899
+ const hasTrigger = slot.assignedElements().length > 0;
900
+ stub.classList.toggle("custom-trigger", hasTrigger);
901
+ this.toggleAttribute("has-trigger", hasTrigger);
902
+ };
899
903
  slot.addEventListener("slotchange", sync);
900
904
  sync();
901
905
  }
@@ -11,7 +11,7 @@ export declare class TySelectedOptions extends HTMLElement {
11
11
  private _childObserver;
12
12
  private _pickerObserver;
13
13
  private _lastTemplate;
14
- private _restoreDisplay;
14
+ private _pickerOpen;
15
15
  private _onPickerOpen;
16
16
  private _onPickerClose;
17
17
  connectedCallback(): void;
@@ -12,13 +12,13 @@ export class TySelectedOptions extends HTMLElement {
12
12
  this._childObserver = null;
13
13
  this._pickerObserver = null;
14
14
  this._lastTemplate = null;
15
- this._restoreDisplay = 'contents';
15
+ this._pickerOpen = false;
16
16
  this._onPickerOpen = () => {
17
- this._restoreDisplay = this.style.display || 'contents';
18
- this.style.display = 'none';
17
+ this._pickerOpen = true;
19
18
  };
20
19
  this._onPickerClose = () => {
21
- this.style.display = this._restoreDisplay;
20
+ this._pickerOpen = false;
21
+ this.renderChips();
22
22
  };
23
23
  }
24
24
  static get observedAttributes() {
@@ -81,7 +81,7 @@ export class TySelectedOptions extends HTMLElement {
81
81
  this._pickerObserver?.disconnect();
82
82
  this._pickerObserver = null;
83
83
  this._picker = null;
84
- this.style.display = this._restoreDisplay;
84
+ this._pickerOpen = false;
85
85
  }
86
86
  values() {
87
87
  return parseValues(this._picker?.value ?? '');
@@ -156,6 +156,8 @@ export class TySelectedOptions extends HTMLElement {
156
156
  return tag;
157
157
  }
158
158
  renderChips() {
159
+ if (this._pickerOpen)
160
+ return;
159
161
  const template = this.template();
160
162
  this._lastTemplate = template;
161
163
  if (!this._picker) {
@@ -16,8 +16,11 @@ function getChildTabs(el) {
16
16
  function getTabId(tab) {
17
17
  return tab.getAttribute('id');
18
18
  }
19
+ function getSlotLabel(tabsEl, tabId) {
20
+ return tabsEl.querySelector(`:scope > [slot='label-${tabId}']`);
21
+ }
19
22
  function hasSlotLabel(tabsEl, tabId) {
20
- return tabsEl.querySelector(`[slot='label-${tabId}']`) !== null;
23
+ return getSlotLabel(tabsEl, tabId) !== null;
21
24
  }
22
25
  function getTabLabelType(tabsEl, tab) {
23
26
  const tabId = getTabId(tab);
@@ -137,7 +140,7 @@ function updateAriaAttributes(el, shadowRoot, activeId) {
137
140
  button.setAttribute('aria-selected', String(isActive));
138
141
  button.setAttribute('tabindex', isActive ? '0' : '-1');
139
142
  button.setAttribute('data-active', String(isActive));
140
- const slottedLabel = el.querySelector(`[slot='label-${tabId}']`);
143
+ const slottedLabel = getSlotLabel(el, tabId);
141
144
  if (slottedLabel) {
142
145
  slottedLabel.setAttribute('data-active', String(isActive));
143
146
  }
@@ -236,10 +239,11 @@ function setupResizeObserver(el) {
236
239
  if (activeIndex !== undefined) {
237
240
  updateTransform(el, activeIndex);
238
241
  }
242
+ updateOverflow(el);
239
243
  if (activeId) {
240
244
  updateMarker(el, activeId);
245
+ scrollActiveIntoView(el, activeId, false);
241
246
  }
242
- updateOverflow(el);
243
247
  });
244
248
  observer.observe(el);
245
249
  resizeObservers.set(el, observer);
@@ -275,11 +279,13 @@ function renderTabButtons(tabsEl, tabs, activeId) {
275
279
  }).join('');
276
280
  return `
277
281
  <div class="tab-buttons" role="tablist" part="buttons-container">
278
- <div class="marker-wrapper" part="marker-wrapper">
279
- ${!hasCustomMarker(tabsEl) ? '<div class="default-marker"></div>' : ''}
280
- <slot name="marker"></slot>
282
+ <div class="tab-strip" part="strip">
283
+ <div class="marker-wrapper" part="marker-wrapper">
284
+ ${!hasCustomMarker(tabsEl) ? '<div class="default-marker"></div>' : ''}
285
+ <slot name="marker"></slot>
286
+ </div>
287
+ ${buttons}
281
288
  </div>
282
- ${buttons}
283
289
  </div>
284
290
  `;
285
291
  }
@@ -306,11 +312,10 @@ function renderButtonsOnly(tabsEl, tabs, activeId) {
306
312
  </button>`;
307
313
  }).join('');
308
314
  }
309
- const OVERFLOW_TRIGGER_WIDTH = 40;
310
315
  function getTabMenuLabel(tabsEl, tab) {
311
316
  const tabId = getTabId(tab);
312
317
  if (tabId && getTabLabelType(tabsEl, tab) === 'slot') {
313
- const slotted = tabsEl.querySelector(`[slot='label-${tabId}']`);
318
+ const slotted = getSlotLabel(tabsEl, tabId);
314
319
  return slotted?.textContent?.trim() || tabId;
315
320
  }
316
321
  return tab.getAttribute('label') || 'Tab';
@@ -318,29 +323,27 @@ function getTabMenuLabel(tabsEl, tab) {
318
323
  function removeOverflowMenu(shadowRoot) {
319
324
  shadowRoot.querySelector('.tab-overflow-trigger')?.remove();
320
325
  }
321
- function renderOverflowMenu(el, container, hiddenButtons, allTabs) {
322
- if (hiddenButtons.length === 0)
323
- return;
324
- const tabsById = new Map(allTabs.map((tab) => [getTabId(tab), tab]));
326
+ function renderOverflowMenu(el, container, allTabs) {
327
+ const activeId = getActiveTabId(el, allTabs);
325
328
  const trigger = document.createElement('button');
326
329
  trigger.className = 'tab-overflow-trigger';
327
330
  trigger.type = 'button';
328
- trigger.setAttribute('aria-label', `${hiddenButtons.length} more tabs`);
331
+ trigger.setAttribute('aria-label', `${allTabs.length} tabs`);
329
332
  trigger.textContent = '⋯';
330
333
  const popup = document.createElement('ty-popup');
331
334
  popup.setAttribute('placement', 'bottom');
332
335
  const menu = document.createElement('div');
333
336
  menu.className = 'tab-overflow-menu';
334
337
  menu.setAttribute('role', 'menu');
335
- hiddenButtons.forEach((btn) => {
336
- const tabId = btn.dataset.tabId;
337
- const tab = tabId ? tabsById.get(tabId) : undefined;
338
- if (!tabId || !tab)
338
+ allTabs.forEach((tab) => {
339
+ const tabId = getTabId(tab);
340
+ if (!tabId)
339
341
  return;
340
342
  const item = document.createElement('button');
341
343
  item.type = 'button';
342
344
  item.className = 'tab-overflow-item';
343
345
  item.setAttribute('role', 'menuitem');
346
+ item.setAttribute('data-active', String(tabId === activeId));
344
347
  item.textContent = getTabMenuLabel(el, tab);
345
348
  if (isTabDisabled(tab)) {
346
349
  item.disabled = true;
@@ -362,48 +365,41 @@ function updateOverflow(el) {
362
365
  if (!shadowRoot)
363
366
  return;
364
367
  const container = shadowRoot.querySelector('.tab-buttons');
365
- if (!container)
368
+ const strip = shadowRoot.querySelector('.tab-strip');
369
+ if (!container || !strip)
366
370
  return;
367
371
  removeOverflowMenu(shadowRoot);
368
- const buttons = Array.from(container.querySelectorAll('.tab-button'));
369
- buttons.forEach((b) => b.classList.remove('overflow-hidden'));
370
- if (buttons.length === 0)
372
+ if (strip.clientWidth <= 0)
371
373
  return;
372
- const available = container.clientWidth;
373
- if (available <= 0)
374
+ updateEdgeFades(strip);
375
+ const buttons = strip.querySelectorAll('.tab-button');
376
+ const last = buttons[buttons.length - 1];
377
+ const contentWidth = last ? last.offsetLeft + last.offsetWidth : 0;
378
+ const overflowing = contentWidth > strip.clientWidth + 1;
379
+ strip.classList.toggle('unclipped', !overflowing);
380
+ if (!overflowing)
374
381
  return;
375
- let total = 0;
376
- let cutoff = buttons.length;
377
- for (let i = 0; i < buttons.length; i++) {
378
- total += buttons[i].offsetWidth;
379
- if (total > available) {
380
- cutoff = i;
381
- break;
382
- }
383
- }
384
- if (cutoff >= buttons.length)
382
+ renderOverflowMenu(el, container, getChildTabs(el));
383
+ }
384
+ function updateEdgeFades(strip) {
385
+ const maxScroll = strip.scrollWidth - strip.clientWidth;
386
+ strip.style.setProperty('--fade-left', strip.scrollLeft > 1 ? '28px' : '0px');
387
+ strip.style.setProperty('--fade-right', strip.scrollLeft < maxScroll - 1 ? '28px' : '0px');
388
+ }
389
+ function scrollActiveIntoView(el, activeId, smooth) {
390
+ const shadowRoot = el.shadowRoot;
391
+ if (!shadowRoot)
385
392
  return;
386
- total = 0;
387
- cutoff = buttons.length;
388
- for (let i = 0; i < buttons.length; i++) {
389
- total += buttons[i].offsetWidth;
390
- if (total > available - OVERFLOW_TRIGGER_WIDTH) {
391
- cutoff = i;
392
- break;
393
- }
394
- }
395
- cutoff = Math.max(cutoff, 1);
396
- const tabs = getChildTabs(el);
397
- const activeId = getActiveTabId(el, tabs);
398
- const hidden = new Set(buttons.slice(cutoff));
399
- const activeButton = activeId ? buttons.find((b) => b.dataset.tabId === activeId) : undefined;
400
- if (activeButton && hidden.has(activeButton)) {
401
- const lastVisible = buttons[cutoff - 1];
402
- hidden.delete(activeButton);
403
- hidden.add(lastVisible);
393
+ const strip = shadowRoot.querySelector('.tab-strip');
394
+ const button = shadowRoot.querySelector(`[data-tab-id='${activeId}']`);
395
+ if (!strip || !button || strip.clientWidth <= 0)
396
+ return;
397
+ const centered = button.offsetLeft + button.offsetWidth / 2 - strip.clientWidth / 2;
398
+ const target = Math.max(0, Math.min(centered, strip.scrollWidth - strip.clientWidth));
399
+ if (Math.round(target) !== Math.round(strip.scrollLeft)) {
400
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
401
+ strip.scrollTo({ left: target, behavior: smooth && !reduce ? 'smooth' : 'auto' });
404
402
  }
405
- buttons.forEach((b) => b.classList.toggle('overflow-hidden', hidden.has(b)));
406
- renderOverflowMenu(el, container, buttons.filter((b) => hidden.has(b)), tabs);
407
403
  }
408
404
  function render(el) {
409
405
  const shadowRoot = el.shadowRoot;
@@ -423,27 +419,25 @@ function render(el) {
423
419
  });
424
420
  const existingContainer = shadowRoot.querySelector('.tabs-container');
425
421
  const existingButtons = shadowRoot.querySelector('.tab-buttons');
422
+ const existingStrip = shadowRoot.querySelector('.tab-strip');
426
423
  const existingViewport = shadowRoot.querySelector('.panels-viewport');
427
424
  ensureStyles(shadowRoot, { css: tabsStyles, id: 'ty-tabs' });
428
- if (tabs.length > 7) {
429
- console.warn(`[ty-tabs] More than 7 tabs detected (${tabs.length} tabs). ` +
430
- 'This may cause overflow and poor UX. ' +
431
- 'Consider using sidebar navigation, accordion menu, or other patterns.');
432
- }
433
425
  el.style.setProperty('--tabs-width', width.includes('%') ? '100%' : width);
434
426
  el.style.setProperty('--tabs-height', height);
435
427
  el.style.setProperty('--active-index', String(activeIndex));
436
- if (existingContainer && existingButtons && existingViewport) {
428
+ if (existingContainer && existingButtons && existingStrip && existingViewport) {
437
429
  existingContainer.setAttribute('data-placement', placement);
438
430
  existingButtons.querySelector('.tab-overflow-trigger')?.remove();
439
- const allButtons = existingButtons.querySelectorAll('.tab-button');
431
+ const savedScrollLeft = existingStrip.scrollLeft;
432
+ const allButtons = existingStrip.querySelectorAll('.tab-button');
440
433
  allButtons.forEach(button => button.remove());
441
434
  const buttonsHtml = renderButtonsOnly(el, tabs, activeId);
442
435
  const tempDiv = document.createElement('div');
443
436
  tempDiv.innerHTML = buttonsHtml;
444
437
  Array.from(tempDiv.children).forEach(button => {
445
- existingButtons.appendChild(button);
438
+ existingStrip.appendChild(button);
446
439
  });
440
+ existingStrip.scrollLeft = savedScrollLeft;
447
441
  setupEventListeners(el, shadowRoot, tabs);
448
442
  updateAriaAttributes(el, shadowRoot, activeId || '');
449
443
  requestAnimationFrame(() => {
@@ -453,10 +447,11 @@ function render(el) {
453
447
  el.style.setProperty('--buttons-height', `${buttonsHeight}px`);
454
448
  }
455
449
  updateTransform(el, activeIndex);
450
+ updateOverflow(el);
456
451
  if (activeId) {
457
452
  updateMarker(el, activeId);
453
+ scrollActiveIntoView(el, activeId, true);
458
454
  }
459
- updateOverflow(el);
460
455
  });
461
456
  if (activeId) {
462
457
  updatePanelInteraction(el, activeId);
@@ -480,14 +475,17 @@ function render(el) {
480
475
  el.style.setProperty('--buttons-height', `${buttonsHeight}px`);
481
476
  }
482
477
  updateTransform(el, activeIndex);
478
+ updateOverflow(el);
483
479
  if (activeId) {
484
480
  updateMarker(el, activeId);
481
+ scrollActiveIntoView(el, activeId, false);
485
482
  }
486
- updateOverflow(el);
487
483
  });
488
484
  setupEventListeners(el, shadowRoot, tabs);
489
485
  updateAriaAttributes(el, shadowRoot, activeId || '');
490
486
  setupResizeObserver(el);
487
+ const strip = shadowRoot.querySelector('.tab-strip');
488
+ strip?.addEventListener('scroll', () => updateEdgeFades(strip), { passive: true });
491
489
  if (activeId) {
492
490
  updatePanelInteraction(el, activeId);
493
491
  }
@@ -44,6 +44,15 @@ export const selectBaseStyles = `
44
44
  width: auto;
45
45
  }
46
46
 
47
+ /* A custom slot="trigger" is the same situation as compact: the trigger is
48
+ the consumer's own button, not a form field — hug it so chips/toolbar
49
+ items can sit right next to it. [has-trigger] is reflected by
50
+ setupTriggerSlot() because Blink does not parse :has() inside :host(). */
51
+ :host([has-trigger]) {
52
+ display: inline-block;
53
+ width: auto;
54
+ }
55
+
47
56
  .dropdown-dialog {
48
57
  position: fixed;
49
58
  width: var(--dropdown-width, 200px);
@@ -2,4 +2,4 @@
2
2
  * Tabs Component Styles — ty-tabs container: top/bottom placement, animated
3
3
  * active marker, carousel viewport. Customizable via CSS Parts (::part).
4
4
  */
5
- export declare const tabsStyles = "\n:host {\n display: block;\n width: var(--tabs-width, 100%);\n height: var(--tabs-height, 400px);\n box-sizing: border-box;\n}\n\n.tabs-container {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n position: relative;\n box-sizing: border-box;\n}\n\n.tabs-container[data-placement=\"bottom\"] {\n flex-direction: column-reverse;\n}\n\n/* Tab buttons container \u2014 exposed as a CSS Part for full styling control */\n.tab-buttons {\n display: flex;\n gap: 0;\n flex-shrink: 0;\n position: relative;\n /* For absolute positioned marker */\n \n /* Default minimal styling - customize via ::part(buttons-container) */\n border-bottom: 1px solid var(--ty-border);\n background: transparent;\n}\n\n.tabs-container[data-placement=\"bottom\"] .tab-buttons {\n border-bottom: none;\n border-top: 1px solid var(--ty-border);\n}\n\n/* Marker wrapper \u2014 exposed as a CSS Part for custom marker styling */\n.marker-wrapper {\n position: absolute;\n z-index: 0;\n /* Behind buttons */\n pointer-events: none;\n /* Don't block clicks */\n transition: left var(--transition-duration, 300ms) var(--transition-easing, ease-in-out),\n width var(--transition-duration, 300ms) var(--transition-easing, ease-in-out),\n height var(--transition-duration, 300ms) var(--transition-easing, ease-in-out),\n top var(--transition-duration, 300ms) var(--transition-easing, ease-in-out);\n}\n\n.default-marker {\n position: absolute;\n bottom: 0;\n left: 0;\n height: 2px;\n width: 100%;\n background: var(--ty-color-primary);\n pointer-events: none;\n}\n\n/* Hide default marker when custom marker is slotted */\n.marker-wrapper:has(::slotted([slot=\"marker\"])) .default-marker {\n display: none;\n}\n\n::slotted([slot=\"marker\"]) {\n display: block;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n}\n\n.tab-button {\n min-width: 120px;\n padding: 6px 12px;\n border: none;\n background: transparent;\n cursor: pointer;\n font: inherit;\n color: var(--ty-text-soft);\n transition: var(--ty-local-transition, color 200ms, background-color 200ms);\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n font-weight: var(--ty-font-bold);\n font-size: var(--ty-font-sm);\n position: relative;\n /* Establish stacking context */\n z-index: 10;\n /* Above marker */\n}\n\n.tab-button[aria-selected=true] {\n color: var(--ty-text-strong);\n}\n\n.tab-button[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n pointer-events: none;\n}\n\n.tab-button:focus-visible {\n outline: 2px solid var(--ty-color-primary);\n outline-offset: -2px;\n}\n\n.tab-button.overflow-hidden {\n display: none;\n}\n\n/* Overflow \"more\" trigger + menu */\n.tab-overflow-trigger {\n flex-shrink: 0;\n width: 40px;\n min-width: 40px;\n padding: 6px;\n border: none;\n background: transparent;\n cursor: pointer;\n color: var(--ty-text-soft);\n font-size: 18px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n position: relative;\n z-index: 10;\n}\n\n.tab-overflow-trigger:hover {\n color: var(--ty-text-strong);\n}\n\n.tab-overflow-trigger:focus-visible {\n outline: 2px solid var(--ty-color-primary);\n outline-offset: -2px;\n}\n\n.tab-overflow-menu {\n display: flex;\n flex-direction: column;\n min-width: 160px;\n max-height: 300px;\n overflow-y: auto;\n padding: var(--ty-spacing-2, 4px);\n background: var(--ty-surface-floating);\n border-radius: var(--ty-radius-md);\n box-shadow: var(--ty-shadow-md, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1));\n}\n\n.tab-overflow-item {\n padding: var(--ty-spacing-2, 6px) var(--ty-spacing-3, 12px);\n border: none;\n background: transparent;\n text-align: left;\n cursor: pointer;\n border-radius: var(--ty-radius-md);\n color: var(--ty-text);\n font: inherit;\n font-size: var(--ty-font-sm);\n white-space: nowrap;\n}\n\n.tab-overflow-item:hover {\n background: var(--ty-bg-neutral-soft);\n color: var(--ty-text-strong);\n}\n\n.tab-overflow-item[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n/* Panels viewport \u2014 exposed as a CSS Part */\n.panels-viewport {\n position: relative;\n flex: 1;\n overflow: hidden;\n /* Critical: hides off-screen panels */\n min-height: 0;\n /* Allows flex child to shrink */\n}\n\n/* Panels wrapper slides horizontally */\n.panels-wrapper {\n display: flex;\n height: 100%;\n transition: transform var(--transition-duration, 300ms) var(--transition-easing, ease-in-out);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .panels-wrapper {\n transition-duration: 0ms !important;\n }\n\n .marker-wrapper {\n transition-duration: 0ms !important;\n }\n}\n\n::slotted(ty-tab) {\n /* Carousel page width. --tabs-page-width is the measured pixel width, set by\n the ResizeObserver for percentage-width tabs so every panel is exactly one\n page wide. It is deliberately NOT --tabs-width: that one sizes :host, and\n feeding a measured pixel value back into the host's own width is a\n self-referential loop (see setupResizeObserver). */\n width: var(--tabs-page-width, var(--tabs-width, 100%));\n height: 100%;\n flex-shrink: 0;\n}\n";
5
+ export declare const tabsStyles = "\n:host {\n display: block;\n width: var(--tabs-width, 100%);\n height: var(--tabs-height, 400px);\n box-sizing: border-box;\n}\n\n.tabs-container {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n position: relative;\n box-sizing: border-box;\n}\n\n.tabs-container[data-placement=\"bottom\"] {\n flex-direction: column-reverse;\n}\n\n/* Tab buttons container \u2014 exposed as a CSS Part for full styling control */\n.tab-buttons {\n display: flex;\n gap: 0;\n flex-shrink: 0;\n position: relative;\n\n /* Default minimal styling - customize via ::part(buttons-container) */\n background: transparent;\n}\n\n/* Separator drawn as a pseudo-element, not a border: a border sits OUTSIDE the\n strip's box, so the marker (absolutely positioned inside the strip) could only\n ever stop just above it, reading as two stacked lines. As an overlaid line it\n lands inside the marker's range, and .tab-strip's z-index lets the marker\n cover it. */\n.tab-buttons::after {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n height: 1px;\n /* --tabs-separator: transparent removes the line (e.g. with a pill marker) */\n background: var(--tabs-separator, var(--ty-border));\n pointer-events: none;\n}\n\n.tabs-container[data-placement=\"bottom\"] .tab-buttons::after {\n bottom: auto;\n top: 0;\n}\n\n/* Scrollable strip holding the buttons + marker. Tabs never collapse \u2014 the\n strip scrolls (scrollbar hidden; touch/wheel still work) and activation\n glides the active tab into view. The \"\u2026\" trigger sits OUTSIDE this strip,\n pinned at the edge. position:relative makes it the offsetParent for the\n marker, so the marker scrolls with the buttons and can't desync. */\n.tab-strip {\n display: flex;\n flex: 1 1 auto;\n min-width: 0;\n position: relative;\n /* Paints the marker + buttons over .tab-buttons::after (the separator line) */\n z-index: 1;\n overflow-x: auto;\n scrollbar-width: none;\n /* Edge fades \u2014 a mask, not overlays, so it works over any background.\n --fade-left/right are set from the scroll listener: 0px at a hard edge,\n 28px when more tabs continue past that side. */\n --fade-left: 0px;\n --fade-right: 0px;\n -webkit-mask-image: linear-gradient(to right,\n transparent, black var(--fade-left),\n black calc(100% - var(--fade-right)), transparent);\n mask-image: linear-gradient(to right,\n transparent, black var(--fade-left),\n black calc(100% - var(--fade-right)), transparent);\n}\n\n.tab-strip::-webkit-scrollbar {\n display: none;\n}\n\n/* When every tab fits there is nothing to scroll, so drop the clipping \u2014\n both overflow and the mask cut a custom marker's box-shadow off at the\n strip edge. Set by updateOverflow(); clipping stays the pre-measurement\n default so overflowing tabs can't spill for a frame on first render. */\n.tab-strip.unclipped {\n overflow: visible;\n -webkit-mask-image: none;\n mask-image: none;\n}\n\n/* Marker wrapper \u2014 exposed as a CSS Part for custom marker styling */\n.marker-wrapper {\n position: absolute;\n z-index: 0;\n /* Behind buttons */\n pointer-events: none;\n /* Don't block clicks */\n transition: left var(--transition-duration, 300ms) var(--transition-easing, ease-in-out),\n width var(--transition-duration, 300ms) var(--transition-easing, ease-in-out),\n height var(--transition-duration, 300ms) var(--transition-easing, ease-in-out),\n top var(--transition-duration, 300ms) var(--transition-easing, ease-in-out);\n}\n\n.default-marker {\n position: absolute;\n bottom: 0;\n left: 0;\n height: 3px;\n width: 100%;\n background: var(--ty-color-primary);\n pointer-events: none;\n}\n\n/* Bottom placement: marker rides the top edge, next to the content it marks */\n.tabs-container[data-placement=\"bottom\"] .default-marker {\n bottom: auto;\n top: 0;\n}\n\n/* Hide default marker when custom marker is slotted */\n.marker-wrapper:has(::slotted([slot=\"marker\"])) .default-marker {\n display: none;\n}\n\n::slotted([slot=\"marker\"]) {\n display: block;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n}\n\n.tab-button {\n min-width: 120px;\n padding: 6px 12px;\n border: none;\n background: transparent;\n cursor: pointer;\n font: inherit;\n color: var(--ty-text-soft);\n transition: var(--ty-local-transition, color 200ms, background-color 200ms);\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n font-weight: var(--ty-font-bold);\n font-size: var(--ty-font-sm);\n position: relative;\n /* Establish stacking context */\n z-index: 10;\n /* Above marker */\n}\n\n.tab-button[aria-selected=true] {\n color: var(--ty-text-strong);\n}\n\n.tab-button[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n pointer-events: none;\n}\n\n.tab-button:focus-visible {\n outline: 2px solid var(--ty-color-primary);\n outline-offset: -2px;\n}\n\n/* Overflow \"more\" trigger (pill) + menu */\n.tab-overflow-trigger {\n flex-shrink: 0;\n align-self: center;\n min-width: 36px;\n height: 24px;\n margin: 0 8px;\n padding: 0 10px;\n border: 1px solid var(--ty-border);\n border-radius: 9999px;\n background: transparent;\n cursor: pointer;\n color: var(--ty-text-soft);\n font-size: 16px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n position: relative;\n z-index: 10;\n transition: color 200ms, border-color 200ms;\n}\n\n.tab-overflow-trigger:hover {\n color: var(--ty-text-strong);\n border-color: var(--ty-text-soft);\n}\n\n.tab-overflow-trigger:focus-visible {\n outline: 2px solid var(--ty-color-primary);\n outline-offset: -2px;\n}\n\n.tab-overflow-menu {\n display: flex;\n flex-direction: column;\n min-width: 160px;\n max-height: 300px;\n overflow-y: auto;\n padding: var(--ty-spacing-2, 4px);\n background: var(--ty-surface-floating);\n border-radius: var(--ty-radius-md);\n box-shadow: var(--ty-shadow-md, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1));\n}\n\n.tab-overflow-item {\n padding: var(--ty-spacing-2, 6px) var(--ty-spacing-3, 12px);\n border: none;\n background: transparent;\n text-align: left;\n cursor: pointer;\n border-radius: var(--ty-radius-md);\n color: var(--ty-text);\n font: inherit;\n font-size: var(--ty-font-sm);\n white-space: nowrap;\n}\n\n.tab-overflow-item:hover {\n background: var(--ty-bg-neutral-soft);\n color: var(--ty-text-strong);\n}\n\n.tab-overflow-item[data-active=\"true\"] {\n color: var(--ty-color-primary);\n font-weight: var(--ty-font-bold);\n}\n\n.tab-overflow-item[disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n/* Panels viewport \u2014 exposed as a CSS Part */\n.panels-viewport {\n position: relative;\n flex: 1;\n overflow: hidden;\n /* Critical: hides off-screen panels */\n min-height: 0;\n /* Allows flex child to shrink */\n}\n\n/* Panels wrapper slides horizontally */\n.panels-wrapper {\n display: flex;\n height: 100%;\n transition: transform var(--transition-duration, 300ms) var(--transition-easing, ease-in-out);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .panels-wrapper {\n transition-duration: 0ms !important;\n }\n\n .marker-wrapper {\n transition-duration: 0ms !important;\n }\n}\n\n::slotted(ty-tab) {\n /* Carousel page width. --tabs-page-width is the measured pixel width, set by\n the ResizeObserver for percentage-width tabs so every panel is exactly one\n page wide. It is deliberately NOT --tabs-width: that one sizes :host, and\n feeding a measured pixel value back into the host's own width is a\n self-referential loop (see setupResizeObserver). */\n width: var(--tabs-page-width, var(--tabs-width, 100%));\n height: 100%;\n flex-shrink: 0;\n}\n";
@@ -25,16 +25,72 @@ export const tabsStyles = `
25
25
  gap: 0;
26
26
  flex-shrink: 0;
27
27
  position: relative;
28
- /* For absolute positioned marker */
29
-
28
+
30
29
  /* Default minimal styling - customize via ::part(buttons-container) */
31
- border-bottom: 1px solid var(--ty-border);
32
30
  background: transparent;
33
31
  }
34
32
 
35
- .tabs-container[data-placement="bottom"] .tab-buttons {
36
- border-bottom: none;
37
- border-top: 1px solid var(--ty-border);
33
+ /* Separator drawn as a pseudo-element, not a border: a border sits OUTSIDE the
34
+ strip's box, so the marker (absolutely positioned inside the strip) could only
35
+ ever stop just above it, reading as two stacked lines. As an overlaid line it
36
+ lands inside the marker's range, and .tab-strip's z-index lets the marker
37
+ cover it. */
38
+ .tab-buttons::after {
39
+ content: "";
40
+ position: absolute;
41
+ left: 0;
42
+ right: 0;
43
+ bottom: 0;
44
+ height: 1px;
45
+ /* --tabs-separator: transparent removes the line (e.g. with a pill marker) */
46
+ background: var(--tabs-separator, var(--ty-border));
47
+ pointer-events: none;
48
+ }
49
+
50
+ .tabs-container[data-placement="bottom"] .tab-buttons::after {
51
+ bottom: auto;
52
+ top: 0;
53
+ }
54
+
55
+ /* Scrollable strip holding the buttons + marker. Tabs never collapse — the
56
+ strip scrolls (scrollbar hidden; touch/wheel still work) and activation
57
+ glides the active tab into view. The "…" trigger sits OUTSIDE this strip,
58
+ pinned at the edge. position:relative makes it the offsetParent for the
59
+ marker, so the marker scrolls with the buttons and can't desync. */
60
+ .tab-strip {
61
+ display: flex;
62
+ flex: 1 1 auto;
63
+ min-width: 0;
64
+ position: relative;
65
+ /* Paints the marker + buttons over .tab-buttons::after (the separator line) */
66
+ z-index: 1;
67
+ overflow-x: auto;
68
+ scrollbar-width: none;
69
+ /* Edge fades — a mask, not overlays, so it works over any background.
70
+ --fade-left/right are set from the scroll listener: 0px at a hard edge,
71
+ 28px when more tabs continue past that side. */
72
+ --fade-left: 0px;
73
+ --fade-right: 0px;
74
+ -webkit-mask-image: linear-gradient(to right,
75
+ transparent, black var(--fade-left),
76
+ black calc(100% - var(--fade-right)), transparent);
77
+ mask-image: linear-gradient(to right,
78
+ transparent, black var(--fade-left),
79
+ black calc(100% - var(--fade-right)), transparent);
80
+ }
81
+
82
+ .tab-strip::-webkit-scrollbar {
83
+ display: none;
84
+ }
85
+
86
+ /* When every tab fits there is nothing to scroll, so drop the clipping —
87
+ both overflow and the mask cut a custom marker's box-shadow off at the
88
+ strip edge. Set by updateOverflow(); clipping stays the pre-measurement
89
+ default so overflowing tabs can't spill for a frame on first render. */
90
+ .tab-strip.unclipped {
91
+ overflow: visible;
92
+ -webkit-mask-image: none;
93
+ mask-image: none;
38
94
  }
39
95
 
40
96
  /* Marker wrapper — exposed as a CSS Part for custom marker styling */
@@ -54,12 +110,18 @@ export const tabsStyles = `
54
110
  position: absolute;
55
111
  bottom: 0;
56
112
  left: 0;
57
- height: 2px;
113
+ height: 3px;
58
114
  width: 100%;
59
115
  background: var(--ty-color-primary);
60
116
  pointer-events: none;
61
117
  }
62
118
 
119
+ /* Bottom placement: marker rides the top edge, next to the content it marks */
120
+ .tabs-container[data-placement="bottom"] .default-marker {
121
+ bottom: auto;
122
+ top: 0;
123
+ }
124
+
63
125
  /* Hide default marker when custom marker is slotted */
64
126
  .marker-wrapper:has(::slotted([slot="marker"])) .default-marker {
65
127
  display: none;
@@ -109,31 +171,32 @@ export const tabsStyles = `
109
171
  outline-offset: -2px;
110
172
  }
111
173
 
112
- .tab-button.overflow-hidden {
113
- display: none;
114
- }
115
-
116
- /* Overflow "more" trigger + menu */
174
+ /* Overflow "more" trigger (pill) + menu */
117
175
  .tab-overflow-trigger {
118
176
  flex-shrink: 0;
119
- width: 40px;
120
- min-width: 40px;
121
- padding: 6px;
122
- border: none;
177
+ align-self: center;
178
+ min-width: 36px;
179
+ height: 24px;
180
+ margin: 0 8px;
181
+ padding: 0 10px;
182
+ border: 1px solid var(--ty-border);
183
+ border-radius: 9999px;
123
184
  background: transparent;
124
185
  cursor: pointer;
125
186
  color: var(--ty-text-soft);
126
- font-size: 18px;
187
+ font-size: 16px;
127
188
  line-height: 1;
128
189
  display: inline-flex;
129
190
  align-items: center;
130
191
  justify-content: center;
131
192
  position: relative;
132
193
  z-index: 10;
194
+ transition: color 200ms, border-color 200ms;
133
195
  }
134
196
 
135
197
  .tab-overflow-trigger:hover {
136
198
  color: var(--ty-text-strong);
199
+ border-color: var(--ty-text-soft);
137
200
  }
138
201
 
139
202
  .tab-overflow-trigger:focus-visible {
@@ -171,6 +234,11 @@ export const tabsStyles = `
171
234
  color: var(--ty-text-strong);
172
235
  }
173
236
 
237
+ .tab-overflow-item[data-active="true"] {
238
+ color: var(--ty-color-primary);
239
+ font-weight: var(--ty-font-bold);
240
+ }
241
+
174
242
  .tab-overflow-item[disabled] {
175
243
  opacity: 0.5;
176
244
  cursor: not-allowed;
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-TC49";
7
+ export declare const VERSION = "1.0.0-TC51";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.0.0-TC49';
1
+ export const VERSION = '1.0.0-TC51';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tyrell-components",
3
- "version": "1.0.0-TC49",
3
+ "version": "1.0.0-TC51",
4
4
  "description": "Tyrell Components - Framework-agnostic web components with semantic design system",
5
5
  "type": "module",
6
6
  "sideEffects": [