codexly-ui 0.8.2 → 0.9.0

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, computed, ChangeDetectionStrategy, ViewEncapsulation, Component, InjectionToken, inject, PLATFORM_ID, signal, effect, Injectable, ElementRef, HostAttributeToken, output, Directive, forwardRef, contentChild, Renderer2, ApplicationRef, EnvironmentInjector, DestroyRef, createComponent, untracked, HostListener, viewChild, ViewChild, Injector, ChangeDetectorRef, NgZone, model, contentChildren, ContentChildren, ViewChildren, numberAttribute, booleanAttribute, Input } from '@angular/core';
2
+ import { input, computed, ChangeDetectionStrategy, ViewEncapsulation, Component, InjectionToken, inject, PLATFORM_ID, signal, effect, Injectable, ElementRef, HostAttributeToken, output, Directive, forwardRef, contentChild, ApplicationRef, EnvironmentInjector, RendererFactory2, createComponent, DestroyRef, untracked, HostListener, viewChild, ViewChild, Injector, ChangeDetectorRef, NgZone, model, contentChildren, ContentChildren, ViewChildren, numberAttribute, booleanAttribute, Input } from '@angular/core';
3
3
  import { isPlatformBrowser, NgTemplateOutlet, DOCUMENT, NgStyle, CurrencyPipe, DecimalPipe } from '@angular/common';
4
4
  import * as i1$1 from '@angular/forms';
5
5
  import { NG_VALUE_ACCESSOR, FormControl, NgControl, ReactiveFormsModule, FormGroup, Validators, FormsModule } from '@angular/forms';
@@ -2352,10 +2352,16 @@ class ClxAnimateService {
2352
2352
  }
2353
2353
  // ─── Interno (usado por ClxAnimateDirective) ──────────────────────────────
2354
2354
  runAnimation(element, config) {
2355
+ // Solo hay clases previas que limpiar si el elemento ya tenía un cleanup
2356
+ // registrado — en ese caso (restart de una animación en curso) sí hace
2357
+ // falta forzar el reflow para que el browser registre la eliminación de
2358
+ // clases antes de volver a agregarlas. En el caso común (primera
2359
+ // animación del elemento, ej. un tooltip recién creado) stop() es un
2360
+ // no-op y el reflow forzado es puro costo evitable.
2361
+ const hadPreviousAnimation = this._cleanups.has(element);
2355
2362
  this.stop(element);
2356
- // Fuerza reflow para que el browser registre la eliminación de clases
2357
- // antes de volver a agregarlas — necesario para reiniciar la animación.
2358
- void element.offsetHeight;
2363
+ if (hadPreviousAnimation)
2364
+ void element.offsetHeight;
2359
2365
  const animClass = `${PREFIX}${config.animation}`;
2360
2366
  const isInfinite = config.repeat === 'infinite';
2361
2367
  element.style.setProperty('--clx-anim-duration', `${config.duration}ms`);
@@ -2429,83 +2435,92 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
2429
2435
 
2430
2436
  // ── Gap between trigger and bubble (px) ───────────────────────────────────
2431
2437
  const GAP = 8;
2432
- class ClxTooltipDirective {
2433
- // ── Inputs ───────────────────────────────────────────────────────────────
2434
- clxTooltip = input.required(...(ngDevMode ? [{ debugName: "clxTooltip" }] : /* istanbul ignore next */ []));
2435
- clxTooltipPosition = input('top', ...(ngDevMode ? [{ debugName: "clxTooltipPosition" }] : /* istanbul ignore next */ []));
2436
- clxTooltipColor = input('slate', ...(ngDevMode ? [{ debugName: "clxTooltipColor" }] : /* istanbul ignore next */ []));
2437
- clxTooltipSize = input('md', ...(ngDevMode ? [{ debugName: "clxTooltipSize" }] : /* istanbul ignore next */ []));
2438
- clxTooltipDelay = input(0, ...(ngDevMode ? [{ debugName: "clxTooltipDelay" }] : /* istanbul ignore next */ []));
2439
- // ── DI ───────────────────────────────────────────────────────────────────
2440
- _el = inject(ElementRef);
2441
- _renderer = inject(Renderer2);
2438
+ /**
2439
+ * Shared, app-wide tooltip bubble — a single ClxTooltipComponent instance reused by every
2440
+ * [clxTooltip] directive instead of each one creating/destroying its own on every hover.
2441
+ * A table with 75 tooltipped cells used to mean up to 75 independent createComponent/destroy
2442
+ * cycles as the mouse scanned across it; now there's exactly one bubble, repositioned and
2443
+ * relabeled per show(). Ownership is tracked by trigger element so a directive can only hide
2444
+ * the bubble it itself showed (prevents a stale hide() from a slower hover-out racing a newer
2445
+ * hover-in on a different element).
2446
+ */
2447
+ class ClxTooltipService {
2442
2448
  _appRef = inject(ApplicationRef);
2443
2449
  _injector = inject(EnvironmentInjector);
2444
2450
  _animate = inject(ClxAnimateService);
2445
2451
  _platformId = inject(PLATFORM_ID);
2446
- _destroyRef = inject(DestroyRef);
2447
- // ── State ────────────────────────────────────────────────────────────────
2452
+ _renderer;
2453
+ _isBrowser;
2448
2454
  _ref = null;
2449
- _pendingRef = null;
2450
- _showTimer = null;
2451
- _hideTimer = null;
2452
- _tooltipId = `clx-tooltip-${Math.random().toString(36).slice(2, 8)}`;
2453
- _abort = new AbortController();
2454
- _destroyed = false;
2455
+ _el = null;
2456
+ _owner = null;
2457
+ _hiding = false;
2455
2458
  constructor() {
2456
- if (!isPlatformBrowser(this._platformId))
2459
+ this._isBrowser = isPlatformBrowser(this._platformId);
2460
+ this._renderer = inject(RendererFactory2).createRenderer(null, null);
2461
+ }
2462
+ /** Shows (or relabels/repositions, if already visible) the shared bubble for `trigger`. */
2463
+ show(trigger, config) {
2464
+ if (!this._isBrowser || !config.text)
2457
2465
  return;
2458
- const host = this._el.nativeElement;
2459
- const { signal } = this._abort;
2460
- host.addEventListener('mouseenter', () => this._scheduleShow(), { signal });
2461
- host.addEventListener('mouseleave', () => this._scheduleHide(), { signal });
2462
- host.addEventListener('focus', () => this._scheduleShow(), { signal });
2463
- host.addEventListener('blur', () => this._scheduleHide(), { signal });
2464
- // Touch devices fire 'mouseenter'/'focus' on tap but never a real
2465
- // 'mouseleave' dismiss immediately (no fade) so the bubble can't
2466
- // outlive the tap and float over whatever it triggered (e.g. a modal
2467
- // opening on the same gesture). Capture-phase 'click' fires before the
2468
- // host's own (click) handler — the one that actually opens the modal —
2469
- // so the bubble is gone from the DOM before the modal is created.
2470
- host.addEventListener('touchend', () => this._hideImmediate(), { signal });
2471
- host.addEventListener('touchcancel', () => this._hideImmediate(), { signal });
2472
- host.addEventListener('click', () => this._hideImmediate(), { signal, capture: true });
2473
- this._destroyRef.onDestroy(() => {
2474
- this._destroyed = true;
2475
- this._abort.abort();
2476
- this._clearTimers();
2477
- this._forceDestroyBubble();
2466
+ this._owner = trigger;
2467
+ this._hiding = false;
2468
+ const ref = this._ref ?? this._create();
2469
+ ref.setInput('text', config.text);
2470
+ ref.setInput('color', config.color);
2471
+ ref.setInput('size', config.size);
2472
+ ref.setInput('position', config.position);
2473
+ const el = ref.location.nativeElement;
2474
+ this._animate.stop(el);
2475
+ el.style.visibility = 'hidden';
2476
+ el.style.opacity = '';
2477
+ ref.changeDetectorRef.detectChanges();
2478
+ // Single measurement pass — see clx-tooltip.directive.ts's original comment: read both
2479
+ // rects once, resolve the flipped position and its coordinates from that same read, then
2480
+ // apply everything in one write batch instead of interleaving reads/writes.
2481
+ const triggerRect = trigger.getBoundingClientRect();
2482
+ const bubbleRect = el.getBoundingClientRect();
2483
+ const finalPos = this._resolvePosition(config.position, triggerRect, bubbleRect);
2484
+ if (finalPos !== config.position) {
2485
+ ref.setInput('position', finalPos);
2486
+ ref.changeDetectorRef.detectChanges();
2487
+ }
2488
+ this._placeAt(el, triggerRect, bubbleRect, finalPos);
2489
+ el.style.visibility = '';
2490
+ trigger.setAttribute('aria-describedby', this._tooltipId);
2491
+ this._animate.animate(el, { animation: 'fadeIn', duration: 160, trigger: 'manual', fillMode: 'both' });
2492
+ }
2493
+ /** Hides the bubble, but only if `trigger` is the element that currently owns it — a hover-out
2494
+ * from an element that already lost ownership (a newer hover-in interrupted it) is a no-op. */
2495
+ hide(trigger) {
2496
+ if (!this._isBrowser || this._owner !== trigger || !this._ref)
2497
+ return;
2498
+ this._hiding = true;
2499
+ trigger.removeAttribute('aria-describedby');
2500
+ const el = this._ref.location.nativeElement;
2501
+ this._animate.animate(el, { animation: 'fadeOut', duration: 120, trigger: 'manual', fillMode: 'forwards' })
2502
+ .then(() => {
2503
+ if (this._owner === trigger && this._hiding)
2504
+ this._owner = null;
2478
2505
  });
2479
2506
  }
2480
- // ── Show / Hide ──────────────────────────────────────────────────────────
2481
- _scheduleShow() {
2482
- this._clearHide();
2483
- const delay = this.clxTooltipDelay();
2484
- if (delay > 0) {
2485
- this._showTimer = setTimeout(() => this._show(), delay);
2486
- }
2487
- else {
2488
- this._show();
2489
- }
2507
+ /** Synchronous, no fade — used on touchend/click so the bubble can't linger over UI the
2508
+ * gesture opens (e.g. a modal). Only acts if `trigger` still owns the bubble. */
2509
+ hideImmediate(trigger) {
2510
+ if (!this._isBrowser || this._owner !== trigger || !this._ref)
2511
+ return;
2512
+ trigger.removeAttribute('aria-describedby');
2513
+ const el = this._ref.location.nativeElement;
2514
+ this._animate.stop(el);
2515
+ el.style.visibility = 'hidden';
2516
+ this._owner = null;
2517
+ this._hiding = false;
2490
2518
  }
2491
- _scheduleHide() {
2492
- this._clearShow();
2493
- this._hideTimer = setTimeout(() => this._hide(), 80);
2519
+ get _tooltipId() {
2520
+ return 'clx-tooltip-shared';
2494
2521
  }
2495
- _show() {
2496
- if (this._ref)
2497
- return;
2498
- if (!this.clxTooltip())
2499
- return;
2500
- // Create component
2501
- const ref = createComponent(ClxTooltipComponent, {
2502
- environmentInjector: this._injector,
2503
- });
2504
- ref.setInput('text', this.clxTooltip());
2505
- ref.setInput('color', this.clxTooltipColor());
2506
- ref.setInput('size', this.clxTooltipSize());
2507
- // We'll set position after measurement
2508
- ref.setInput('position', this.clxTooltipPosition());
2522
+ _create() {
2523
+ const ref = createComponent(ClxTooltipComponent, { environmentInjector: this._injector });
2509
2524
  this._appRef.attachView(ref.hostView);
2510
2525
  const el = ref.location.nativeElement;
2511
2526
  el.style.position = 'fixed';
@@ -2513,63 +2528,13 @@ class ClxTooltipDirective {
2513
2528
  el.style.zIndex = '9999';
2514
2529
  el.id = this._tooltipId;
2515
2530
  this._renderer.appendChild(document.body, el);
2516
- ref.changeDetectorRef.detectChanges();
2517
- // Resolve final position (with viewport flip)
2518
- const finalPos = this._resolvePosition(el);
2519
- ref.setInput('position', finalPos);
2520
- ref.changeDetectorRef.detectChanges();
2521
- // Place at resolved coordinates
2522
- this._placeAt(el, finalPos);
2523
- el.style.visibility = '';
2524
2531
  this._ref = ref;
2525
- // Aria
2526
- this._el.nativeElement.setAttribute('aria-describedby', this._tooltipId);
2527
- // Animate in
2528
- this._animate.animate(el, {
2529
- animation: 'fadeIn',
2530
- duration: 160,
2531
- trigger: 'manual',
2532
- fillMode: 'both',
2533
- });
2534
- }
2535
- _hide() {
2536
- const ref = this._ref;
2537
- if (!ref)
2538
- return;
2539
- this._ref = null;
2540
- this._pendingRef = ref;
2541
- this._el.nativeElement.removeAttribute('aria-describedby');
2542
- const el = ref.location.nativeElement;
2543
- this._animate.animate(el, {
2544
- animation: 'fadeOut',
2545
- duration: 120,
2546
- trigger: 'manual',
2547
- fillMode: 'forwards',
2548
- }).then(() => {
2549
- this._pendingRef = null;
2550
- this._appRef.detachView(ref.hostView);
2551
- ref.destroy();
2552
- });
2553
- }
2554
- /** Synchronous teardown, no fade — used on touchend so the bubble can't linger over UI the tap opens. */
2555
- _hideImmediate() {
2556
- this._clearTimers();
2557
- this._destroyBubble();
2558
- if (this._pendingRef) {
2559
- const el = this._pendingRef.location.nativeElement;
2560
- this._animate.stop(el);
2561
- this._appRef.detachView(this._pendingRef.hostView);
2562
- this._pendingRef.destroy();
2563
- this._pendingRef = null;
2564
- }
2532
+ return ref;
2565
2533
  }
2566
2534
  // ── Positioning ──────────────────────────────────────────────────────────
2567
- _resolvePosition(el) {
2568
- const requested = this.clxTooltipPosition();
2535
+ _resolvePosition(requested, trigger, bubble) {
2569
2536
  const vw = window.innerWidth;
2570
2537
  const vh = window.innerHeight;
2571
- const trigger = this._el.nativeElement.getBoundingClientRect();
2572
- const bubble = el.getBoundingClientRect();
2573
2538
  const fits = {
2574
2539
  top: trigger.top - bubble.height - GAP >= 0,
2575
2540
  bottom: trigger.bottom + bubble.height + GAP <= vh,
@@ -2578,23 +2543,19 @@ class ClxTooltipDirective {
2578
2543
  };
2579
2544
  if (fits[requested])
2580
2545
  return requested;
2581
- // Flip axis
2582
2546
  const opposite = {
2583
2547
  top: 'bottom', bottom: 'top', left: 'right', right: 'left',
2584
2548
  };
2585
2549
  const opp = opposite[requested];
2586
2550
  if (fits[opp])
2587
2551
  return opp;
2588
- // Fallback: any position that fits
2589
2552
  for (const pos of ['top', 'bottom', 'left', 'right']) {
2590
2553
  if (fits[pos])
2591
2554
  return pos;
2592
2555
  }
2593
2556
  return requested; // last resort
2594
2557
  }
2595
- _placeAt(el, pos) {
2596
- const trigger = this._el.nativeElement.getBoundingClientRect();
2597
- const bubble = el.getBoundingClientRect();
2558
+ _placeAt(el, trigger, bubble, pos) {
2598
2559
  let top = 0;
2599
2560
  let left = 0;
2600
2561
  switch (pos) {
@@ -2615,36 +2576,86 @@ class ClxTooltipDirective {
2615
2576
  left = trigger.right + GAP;
2616
2577
  break;
2617
2578
  }
2618
- // Clamp to viewport edges with 4px margin
2619
2579
  const margin = 4;
2620
2580
  left = Math.max(margin, Math.min(left, window.innerWidth - bubble.width - margin));
2621
2581
  top = Math.max(margin, Math.min(top, window.innerHeight - bubble.height - margin));
2622
2582
  el.style.top = `${top}px`;
2623
2583
  el.style.left = `${left}px`;
2624
2584
  }
2625
- // ── Helpers ──────────────────────────────────────────────────────────────
2626
- _destroyBubble() {
2627
- if (!this._ref)
2585
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: ClxTooltipService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2586
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: ClxTooltipService, providedIn: 'root' });
2587
+ }
2588
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: ClxTooltipService, decorators: [{
2589
+ type: Injectable,
2590
+ args: [{ providedIn: 'root' }]
2591
+ }], ctorParameters: () => [] });
2592
+
2593
+ class ClxTooltipDirective {
2594
+ // ── Inputs ───────────────────────────────────────────────────────────────
2595
+ clxTooltip = input.required(...(ngDevMode ? [{ debugName: "clxTooltip" }] : /* istanbul ignore next */ []));
2596
+ clxTooltipPosition = input('top', ...(ngDevMode ? [{ debugName: "clxTooltipPosition" }] : /* istanbul ignore next */ []));
2597
+ clxTooltipColor = input('slate', ...(ngDevMode ? [{ debugName: "clxTooltipColor" }] : /* istanbul ignore next */ []));
2598
+ clxTooltipSize = input('md', ...(ngDevMode ? [{ debugName: "clxTooltipSize" }] : /* istanbul ignore next */ []));
2599
+ clxTooltipDelay = input(0, ...(ngDevMode ? [{ debugName: "clxTooltipDelay" }] : /* istanbul ignore next */ []));
2600
+ // ── DI ───────────────────────────────────────────────────────────────────
2601
+ _el = inject(ElementRef);
2602
+ _tooltip = inject(ClxTooltipService);
2603
+ _platformId = inject(PLATFORM_ID);
2604
+ _destroyRef = inject(DestroyRef);
2605
+ // ── State ────────────────────────────────────────────────────────────────
2606
+ _showTimer = null;
2607
+ _hideTimer = null;
2608
+ _abort = new AbortController();
2609
+ constructor() {
2610
+ if (!isPlatformBrowser(this._platformId))
2628
2611
  return;
2629
- this._appRef.detachView(this._ref.hostView);
2630
- this._ref.destroy();
2631
- this._ref = null;
2632
- this._el.nativeElement.removeAttribute('aria-describedby');
2612
+ const host = this._el.nativeElement;
2613
+ const { signal } = this._abort;
2614
+ host.addEventListener('mouseenter', () => this._scheduleShow(), { signal });
2615
+ host.addEventListener('mouseleave', () => this._scheduleHide(), { signal });
2616
+ host.addEventListener('focus', () => this._scheduleShow(), { signal });
2617
+ host.addEventListener('blur', () => this._scheduleHide(), { signal });
2618
+ // Touch devices fire 'mouseenter'/'focus' on tap but never a real
2619
+ // 'mouseleave' — dismiss immediately (no fade) so the bubble can't
2620
+ // outlive the tap and float over whatever it triggered (e.g. a modal
2621
+ // opening on the same gesture). Capture-phase 'click' fires before the
2622
+ // host's own (click) handler — the one that actually opens the modal —
2623
+ // so the bubble is gone from the DOM before the modal is created.
2624
+ host.addEventListener('touchend', () => this._tooltip.hideImmediate(host), { signal });
2625
+ host.addEventListener('touchcancel', () => this._tooltip.hideImmediate(host), { signal });
2626
+ host.addEventListener('click', () => this._tooltip.hideImmediate(host), { signal, capture: true });
2627
+ this._destroyRef.onDestroy(() => {
2628
+ this._abort.abort();
2629
+ this._clearTimers();
2630
+ this._tooltip.hideImmediate(host);
2631
+ });
2633
2632
  }
2634
- _forceDestroyBubble() {
2635
- if (this._ref) {
2636
- const el = this._ref.location.nativeElement;
2637
- this._animate.stop(el);
2638
- this._destroyBubble();
2633
+ // ── Show / Hide ──────────────────────────────────────────────────────────
2634
+ _scheduleShow() {
2635
+ this._clearHide();
2636
+ const delay = this.clxTooltipDelay();
2637
+ if (delay > 0) {
2638
+ this._showTimer = setTimeout(() => this._show(), delay);
2639
2639
  }
2640
- if (this._pendingRef) {
2641
- const el = this._pendingRef.location.nativeElement;
2642
- this._animate.stop(el);
2643
- this._appRef.detachView(this._pendingRef.hostView);
2644
- this._pendingRef.destroy();
2645
- this._pendingRef = null;
2640
+ else {
2641
+ this._show();
2646
2642
  }
2647
2643
  }
2644
+ _scheduleHide() {
2645
+ this._clearShow();
2646
+ this._hideTimer = setTimeout(() => this._hide(), 80);
2647
+ }
2648
+ _show() {
2649
+ this._tooltip.show(this._el.nativeElement, {
2650
+ text: this.clxTooltip(),
2651
+ position: this.clxTooltipPosition(),
2652
+ color: this.clxTooltipColor(),
2653
+ size: this.clxTooltipSize(),
2654
+ });
2655
+ }
2656
+ _hide() {
2657
+ this._tooltip.hide(this._el.nativeElement);
2658
+ }
2648
2659
  _clearShow() {
2649
2660
  if (this._showTimer !== null) {
2650
2661
  clearTimeout(this._showTimer);
@@ -6114,12 +6125,11 @@ class ClxColorPickerComponent {
6114
6125
  <div class="absolute inset-0" [style.background-color]="_hueBackground()"></div>
6115
6126
  <div class="absolute inset-0" style="background: linear-gradient(to right, #ffffff, transparent)"></div>
6116
6127
  <div class="absolute inset-0" style="background: linear-gradient(to top, #000000, transparent)"></div>
6117
- <!-- Cursor -->
6128
+ <!-- Cursor — positioned via transform (compositor-only) instead of left/top so
6129
+ every pointermove during a drag doesn't force a layout recalculation. -->
6118
6130
  <div
6119
- class="absolute w-3.5 h-3.5 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6120
- [style.left.%]="_saturation() * 100"
6121
- [style.top.%]="(1 - _brightness()) * 100"
6122
- style="transform: translate(-50%, -50%)">
6131
+ class="absolute top-0 left-0 w-3.5 h-3.5 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6132
+ [style.transform]="'translate(calc(' + (_saturation() * 100) + '% - 50%), calc(' + ((1 - _brightness()) * 100) + '% - 50%))'">
6123
6133
  </div>
6124
6134
  </div>
6125
6135
 
@@ -6132,10 +6142,9 @@ class ClxColorPickerComponent {
6132
6142
  style="background: linear-gradient(to right,#ff0000,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)">
6133
6143
  </div>
6134
6144
  <div
6135
- class="absolute top-1/2 w-4 h-4 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6136
- [style.left.%]="(_hue() / 360) * 100"
6137
- [style.background-color]="_hueBackground()"
6138
- style="transform: translate(-50%, -50%)">
6145
+ class="absolute top-1/2 left-0 w-4 h-4 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6146
+ [style.transform]="'translate(calc(' + ((_hue() / 360) * 100) + '% - 50%), -50%)'"
6147
+ [style.background-color]="_hueBackground()">
6139
6148
  </div>
6140
6149
  </div>
6141
6150
 
@@ -6283,12 +6292,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
6283
6292
  <div class="absolute inset-0" [style.background-color]="_hueBackground()"></div>
6284
6293
  <div class="absolute inset-0" style="background: linear-gradient(to right, #ffffff, transparent)"></div>
6285
6294
  <div class="absolute inset-0" style="background: linear-gradient(to top, #000000, transparent)"></div>
6286
- <!-- Cursor -->
6295
+ <!-- Cursor — positioned via transform (compositor-only) instead of left/top so
6296
+ every pointermove during a drag doesn't force a layout recalculation. -->
6287
6297
  <div
6288
- class="absolute w-3.5 h-3.5 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6289
- [style.left.%]="_saturation() * 100"
6290
- [style.top.%]="(1 - _brightness()) * 100"
6291
- style="transform: translate(-50%, -50%)">
6298
+ class="absolute top-0 left-0 w-3.5 h-3.5 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6299
+ [style.transform]="'translate(calc(' + (_saturation() * 100) + '% - 50%), calc(' + ((1 - _brightness()) * 100) + '% - 50%))'">
6292
6300
  </div>
6293
6301
  </div>
6294
6302
 
@@ -6301,10 +6309,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
6301
6309
  style="background: linear-gradient(to right,#ff0000,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)">
6302
6310
  </div>
6303
6311
  <div
6304
- class="absolute top-1/2 w-4 h-4 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6305
- [style.left.%]="(_hue() / 360) * 100"
6306
- [style.background-color]="_hueBackground()"
6307
- style="transform: translate(-50%, -50%)">
6312
+ class="absolute top-1/2 left-0 w-4 h-4 rounded-full border-2 border-white shadow ring-1 ring-black/20 pointer-events-none"
6313
+ [style.transform]="'translate(calc(' + ((_hue() / 360) * 100) + '% - 50%), -50%)'"
6314
+ [style.background-color]="_hueBackground()">
6308
6315
  </div>
6309
6316
  </div>
6310
6317
 
@@ -9399,6 +9406,9 @@ class ClxAppLayoutComponent {
9399
9406
  collapsed = signal(false, ...(ngDevMode ? [{ debugName: "collapsed" }] : /* istanbul ignore next */ []));
9400
9407
  _hovered = signal(false, ...(ngDevMode ? [{ debugName: "_hovered" }] : /* istanbul ignore next */ []));
9401
9408
  _isMobile = signal(true, ...(ngDevMode ? [{ debugName: "_isMobile" }] : /* istanbul ignore next */ []));
9409
+ /** True only for the duration of an explicit toggle/hover transition — see _sidebarCls(). */
9410
+ _animating = signal(false, ...(ngDevMode ? [{ debugName: "_animating" }] : /* istanbul ignore next */ []));
9411
+ _animatingTimer;
9402
9412
  collapsedChange = output();
9403
9413
  expandedChange = output();
9404
9414
  constructor() {
@@ -9422,12 +9432,28 @@ class ClxAppLayoutComponent {
9422
9432
  width = 'w-72';
9423
9433
  }
9424
9434
  const isOverlay = isDesktopCollapsed && hovered;
9435
+ // `width` only transitions while `_animating` is true (set for the duration of an explicit
9436
+ // collapse/expand/hover toggle — see _withTransition()). Left permanently on, this transition
9437
+ // would also animate every layout pass a native window resize causes (the sidebar's computed
9438
+ // width changes as a flex/fixed side-effect of the viewport reflow even when its Tailwind
9439
+ // width class hasn't), turning a single instant reflow into a 300ms interpolated one and
9440
+ // dragging the rest of the flex-sibling content along with it — the classic "resize lags,
9441
+ // catches up after you stop" symptom.
9442
+ const transition = this._animating()
9443
+ ? 'transition-[width,transform] duration-300 ease-in-out lg:transition-[width] will-change-[width]'
9444
+ : 'transition-none';
9425
9445
  return [
9426
9446
  `fixed inset-y-0 left-0 z-30 ${width} flex flex-col`,
9447
+ // `contain: layout paint` (via arbitrary Tailwind classes below) scopes the reflow/repaint
9448
+ // the width transition forces to the sidebar's own subtree instead of letting the browser
9449
+ // consider it a candidate to affect the rest of the document — the sidebar is `fixed` so it
9450
+ // was never going to move sibling content either way, but without containment the engine
9451
+ // still walks a wider invalidation region on every animation frame.
9452
+ '[contain:layout_paint]',
9427
9453
  'bg-clx-surface border-r border-clx-border overflow-hidden shrink-0',
9428
- 'transition-[width,transform] duration-300 ease-in-out',
9454
+ transition,
9429
9455
  isOverlay ? 'shadow-2xl' : '',
9430
- 'lg:transition-[width] lg:relative lg:inset-auto lg:z-30 lg:translate-x-0',
9456
+ 'lg:relative lg:inset-auto lg:z-30 lg:translate-x-0',
9431
9457
  (!this._isMobile() || this._sidebarOpen()) ? 'translate-x-0' : '-translate-x-full',
9432
9458
  ].join(' ');
9433
9459
  }, ...(ngDevMode ? [{ debugName: "_sidebarCls" }] : /* istanbul ignore next */ []));
@@ -9458,23 +9484,38 @@ class ClxAppLayoutComponent {
9458
9484
  }
9459
9485
  ngOnDestroy() {
9460
9486
  this._mql?.removeEventListener('change', this._mqlListener);
9487
+ clearTimeout(this._animatingTimer);
9488
+ }
9489
+ /** Arms the width/transform transition for one toggle's duration, then switches it back off
9490
+ * so a later window resize reflow doesn't get caught by a live CSS transition on `width`. */
9491
+ _withTransition(fn) {
9492
+ this._animating.set(true);
9493
+ fn();
9494
+ clearTimeout(this._animatingTimer);
9495
+ this._animatingTimer = setTimeout(() => this._animating.set(false), 320);
9461
9496
  }
9462
9497
  _toggleCollapsed() {
9463
- const next = !this.collapsed();
9464
- this.collapsed.set(next);
9465
- this._hovered.set(false);
9466
- this.collapsedChange.emit(next);
9498
+ this._withTransition(() => {
9499
+ const next = !this.collapsed();
9500
+ this.collapsed.set(next);
9501
+ this._hovered.set(false);
9502
+ this.collapsedChange.emit(next);
9503
+ });
9467
9504
  }
9468
9505
  _onSidebarEnter() {
9469
9506
  if (this.collapsed() && !this._isMobile()) {
9470
- this._hovered.set(true);
9471
- this.expandedChange.emit(true);
9507
+ this._withTransition(() => {
9508
+ this._hovered.set(true);
9509
+ this.expandedChange.emit(true);
9510
+ });
9472
9511
  }
9473
9512
  }
9474
9513
  _onSidebarLeave() {
9475
9514
  if (this._hovered()) {
9476
- this._hovered.set(false);
9477
- this.expandedChange.emit(false);
9515
+ this._withTransition(() => {
9516
+ this._hovered.set(false);
9517
+ this.expandedChange.emit(false);
9518
+ });
9478
9519
  }
9479
9520
  }
9480
9521
  toggleSidebar() { this._sidebarOpen.update(v => !v); }
@@ -10016,9 +10057,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
10016
10057
 
10017
10058
  /** Optional replacement for the auto-generated `<h1>` — project this when the title needs
10018
10059
  * inline content next to it (a status badge, a monospace order number, extra spans) that a
10019
- * plain `title` string input can't express. The `title`/`subtitle` inputs are ignored when
10020
- * this is present; ClxPageHeaderComponent's own text-2xl/font-weight/truncate styling is
10021
- * NOT applied automatically to projected content — style the projected `<h1>` yourself. */
10060
+ * plain `headerTitle` string input can't express. The `headerTitle`/`subtitle` inputs are
10061
+ * ignored when this is present; ClxPageHeaderComponent's own text-2xl/font-weight/truncate
10062
+ * styling is NOT applied automatically to projected content — style the projected `<h1>` yourself. */
10022
10063
  class ClxPageHeaderTitleDirective {
10023
10064
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: ClxPageHeaderTitleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
10024
10065
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.15", type: ClxPageHeaderTitleDirective, isStandalone: true, selector: "[clxPageHeaderTitle]", ngImport: i0 });
@@ -10042,12 +10083,17 @@ const TEXT_SIZE_CLASS = {
10042
10083
  * hand-rolled `<h1 class="text-2xl font-semibold">` + `<p>` pattern repeated across every page.
10043
10084
  *
10044
10085
  * For detail pages whose title needs inline content (a status badge, a monospace order
10045
- * number) that a plain `title` string can't express, project a `[clxPageHeaderTitle]`
10046
- * block instead of using the `title`/`subtitle` inputs.
10086
+ * number) that a plain `headerTitle` string can't express, project a `[clxPageHeaderTitle]`
10087
+ * block instead of using the `headerTitle`/`subtitle` inputs.
10047
10088
  */
10048
10089
  class ClxPageHeaderComponent {
10049
10090
  _themeSvc = inject(ClxThemeService);
10050
- title = input('', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
10091
+ // Named `headerTitle` (not `title`) deliberately `title` is also a native HTML attribute,
10092
+ // and Angular doesn't strip it from the host element just because it's also bound to an
10093
+ // @Input(). `<clx-page-header title="X">` used to leave a real `title="X"` attribute on the
10094
+ // component's host, which the browser reads as a native tooltip — every page header showed
10095
+ // a redundant browser tooltip repeating its own title on hover.
10096
+ headerTitle = input('', ...(ngDevMode ? [{ debugName: "headerTitle" }] : /* istanbul ignore next */ []));
10051
10097
  subtitle = input(undefined, ...(ngDevMode ? [{ debugName: "subtitle" }] : /* istanbul ignore next */ []));
10052
10098
  /** Set to show the back button — `[showBack]="true" (back)="goBack()"`. */
10053
10099
  showBack = input(false, ...(ngDevMode ? [{ debugName: "showBack" }] : /* istanbul ignore next */ []));
@@ -10070,7 +10116,7 @@ class ClxPageHeaderComponent {
10070
10116
  return `${TEXT_SIZE_CLASS[s.size]} font-[${s.weight}] ${colorCls} mt-0.5`;
10071
10117
  }, ...(ngDevMode ? [{ debugName: "subtitleClass" }] : /* istanbul ignore next */ []));
10072
10118
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: ClxPageHeaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
10073
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.15", type: ClxPageHeaderComponent, isStandalone: true, selector: "clx-page-header", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, showBack: { classPropertyName: "showBack", publicName: "showBack", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { back: "back" }, host: { classAttribute: "flex items-center gap-3 flex-wrap" }, queries: [{ propertyName: "_titleSlot", first: true, predicate: ClxPageHeaderTitleDirective, descendants: true, isSignal: true }], exportAs: ["clxPageHeader"], ngImport: i0, template: `
10119
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.15", type: ClxPageHeaderComponent, isStandalone: true, selector: "clx-page-header", inputs: { headerTitle: { classPropertyName: "headerTitle", publicName: "headerTitle", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, showBack: { classPropertyName: "showBack", publicName: "showBack", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { back: "back" }, host: { classAttribute: "flex items-center gap-3 flex-wrap" }, queries: [{ propertyName: "_titleSlot", first: true, predicate: ClxPageHeaderTitleDirective, descendants: true, isSignal: true }], exportAs: ["clxPageHeader"], ngImport: i0, template: `
10074
10120
  @if (showBack()) {
10075
10121
  <button clx-button variant="ghost" [color]="_color()" icon="arrow_back" [iconOnly]="true"
10076
10122
  (click)="back.emit()"></button>
@@ -10083,7 +10129,7 @@ class ClxPageHeaderComponent {
10083
10129
  </div>
10084
10130
  } @else {
10085
10131
  <div class="min-w-0">
10086
- <h1 [class]="titleClass()">{{ title() }}</h1>
10132
+ <h1 [class]="titleClass()">{{ headerTitle() }}</h1>
10087
10133
  @if (subtitle()) {
10088
10134
  <p [class]="subtitleClass()">{{ subtitle() }}</p>
10089
10135
  }
@@ -10116,7 +10162,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
10116
10162
  </div>
10117
10163
  } @else {
10118
10164
  <div class="min-w-0">
10119
- <h1 [class]="titleClass()">{{ title() }}</h1>
10165
+ <h1 [class]="titleClass()">{{ headerTitle() }}</h1>
10120
10166
  @if (subtitle()) {
10121
10167
  <p [class]="subtitleClass()">{{ subtitle() }}</p>
10122
10168
  }
@@ -10126,7 +10172,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
10126
10172
  </div>
10127
10173
  `,
10128
10174
  }]
10129
- }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], showBack: [{ type: i0.Input, args: [{ isSignal: true, alias: "showBack", required: false }] }], back: [{ type: i0.Output, args: ["back"] }], _titleSlot: [{ type: i0.ContentChild, args: [i0.forwardRef(() => ClxPageHeaderTitleDirective), { isSignal: true }] }] } });
10175
+ }], propDecorators: { headerTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerTitle", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], showBack: [{ type: i0.Input, args: [{ isSignal: true, alias: "showBack", required: false }] }], back: [{ type: i0.Output, args: ["back"] }], _titleSlot: [{ type: i0.ContentChild, args: [i0.forwardRef(() => ClxPageHeaderTitleDirective), { isSignal: true }] }] } });
10130
10176
 
10131
10177
  class ClxProfileComponent {
10132
10178
  // ── Inputs ──────────────────────────────────────────────────────────────────
@@ -10726,9 +10772,9 @@ class ClxAlertComponent {
10726
10772
  @if (_timerTotal() > 0) {
10727
10773
  <div class="absolute bottom-0 left-0 right-0 h-1 bg-clx-surface-2 rounded-b-3xl overflow-hidden">
10728
10774
  <div
10729
- class="h-full transition-none rounded-b-3xl"
10775
+ class="h-full w-full origin-left transition-none rounded-b-3xl"
10730
10776
  [class]="_progressColor()"
10731
- [ngStyle]="{ width: _progressPct() + '%' }">
10777
+ [ngStyle]="{ transform: 'scaleX(' + (_progressPct() / 100) + ')' }">
10732
10778
  </div>
10733
10779
  </div>
10734
10780
  }
@@ -10798,9 +10844,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
10798
10844
  @if (_timerTotal() > 0) {
10799
10845
  <div class="absolute bottom-0 left-0 right-0 h-1 bg-clx-surface-2 rounded-b-3xl overflow-hidden">
10800
10846
  <div
10801
- class="h-full transition-none rounded-b-3xl"
10847
+ class="h-full w-full origin-left transition-none rounded-b-3xl"
10802
10848
  [class]="_progressColor()"
10803
- [ngStyle]="{ width: _progressPct() + '%' }">
10849
+ [ngStyle]="{ transform: 'scaleX(' + (_progressPct() / 100) + ')' }">
10804
10850
  </div>
10805
10851
  </div>
10806
10852
  }
@@ -11099,9 +11145,9 @@ class ClxToastComponent {
11099
11145
  @if (entry().showProgress && entry().duration > 0) {
11100
11146
  <div class="absolute bottom-0 left-0 right-0 h-0.5 bg-clx-surface-2">
11101
11147
  <div
11102
- class="h-full transition-none"
11148
+ class="h-full w-full origin-left transition-none"
11103
11149
  [class]="_progressColor()"
11104
- [ngStyle]="{ width: _progressPct() + '%' }"
11150
+ [ngStyle]="{ transform: 'scaleX(' + (_progressPct() / 100) + ')' }"
11105
11151
  ></div>
11106
11152
  </div>
11107
11153
  }
@@ -11170,9 +11216,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
11170
11216
  @if (entry().showProgress && entry().duration > 0) {
11171
11217
  <div class="absolute bottom-0 left-0 right-0 h-0.5 bg-clx-surface-2">
11172
11218
  <div
11173
- class="h-full transition-none"
11219
+ class="h-full w-full origin-left transition-none"
11174
11220
  [class]="_progressColor()"
11175
- [ngStyle]="{ width: _progressPct() + '%' }"
11221
+ [ngStyle]="{ transform: 'scaleX(' + (_progressPct() / 100) + ')' }"
11176
11222
  ></div>
11177
11223
  </div>
11178
11224
  }