@tedi-design-system/angular 9.0.0-rc.2 → 9.0.0-rc.4

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.
@@ -19258,9 +19258,32 @@ function normalizeTime(input) {
19258
19258
  }
19259
19259
 
19260
19260
  const DEFAULT_ITEM_HEIGHT = 40;
19261
- const SMOOTH_SCROLL_LOCK_MS = 400;
19262
- const INSTANT_SCROLL_LOCK_MS = 50;
19261
+ /** Quiet period before a programmatic scroll counts as finished. */
19262
+ const SCROLL_SETTLE_QUIET_MS = 200;
19263
+ /** Releases a column whose programmatic scroll never fired a scroll event. */
19264
+ const SCROLL_SETTLE_MAX_WAIT_MS = 1000;
19263
19265
  const SCROLL_DEBOUNCE_MS = 150;
19266
+ /**
19267
+ * How long a gesture stays armed before it has scrolled anything. A pointer can
19268
+ * go down on a column's padding and never move it, and nothing else would clear
19269
+ * the flag. Cancelled as soon as the gesture does scroll.
19270
+ */
19271
+ const GESTURE_WITHOUT_SCROLL_MS = 500;
19272
+ /**
19273
+ * The gestures that can scroll a column. Scrollbars are hidden and scrolling
19274
+ * keys are handled in `onColumnKeydown`, so a scroll with none of these behind
19275
+ * it was not started by the user.
19276
+ */
19277
+ const USER_GESTURE_EVENTS = [
19278
+ "wheel",
19279
+ "touchstart",
19280
+ "touchmove",
19281
+ "pointerdown",
19282
+ ];
19283
+ const USER_GESTURE_OPTIONS = {
19284
+ capture: true,
19285
+ passive: true,
19286
+ };
19264
19287
  class TimePickerComponent {
19265
19288
  /** Selected time in `HH:mm` format. Two-way bindable. */
19266
19289
  value = model(null, ...(ngDevMode ? [{ debugName: "value" }] : []));
@@ -19288,12 +19311,35 @@ class TimePickerComponent {
19288
19311
  onChange = () => { };
19289
19312
  onTouched = () => { };
19290
19313
  initialized = false;
19314
+ /**
19315
+ * Held until the programmatic scroll has actually settled, not for a fixed
19316
+ * time: `scrollTo` reports its own events plus the scroll-snap adjustment on
19317
+ * top, and the engine decides when those arrive.
19318
+ */
19291
19319
  isProgrammaticScroll = {
19292
19320
  hour: false,
19293
19321
  minute: false,
19294
19322
  };
19323
+ isSmoothProgrammaticScroll = {
19324
+ hour: false,
19325
+ minute: false,
19326
+ };
19327
+ /** Only a scroll with a gesture behind it may change the value. */
19328
+ hasUserGesture = {
19329
+ hour: false,
19330
+ minute: false,
19331
+ };
19332
+ /** Guards against a correction and the engine trading scrolls indefinitely. */
19333
+ hasCorrectedScroll = {
19334
+ hour: false,
19335
+ minute: false,
19336
+ };
19337
+ programmaticScrollTarget = {};
19295
19338
  scrollLockTimer = {};
19339
+ /** Latest moment a programmatic scroll may still own its column. */
19340
+ scrollLockDeadline = {};
19296
19341
  scrollDebounceTimer = {};
19342
+ gestureTimer = {};
19297
19343
  cachedItemHeight = null;
19298
19344
  resizeObserver = null;
19299
19345
  hourScrollIndex = signal(0, ...(ngDevMode ? [{ debugName: "hourScrollIndex" }] : []));
@@ -19370,21 +19416,53 @@ class TimePickerComponent {
19370
19416
  this.initialized = true;
19371
19417
  this.cachedItemHeight = this.measureItemHeight();
19372
19418
  if (typeof ResizeObserver !== "undefined") {
19373
- this.resizeObserver = new ResizeObserver(() => (this.cachedItemHeight = this.measureItemHeight()));
19419
+ this.resizeObserver = new ResizeObserver(() => this.syncItemHeight());
19374
19420
  this.resizeObserver.observe(this.el.nativeElement);
19375
19421
  }
19422
+ const host = this.el.nativeElement;
19423
+ USER_GESTURE_EVENTS.forEach((name) => {
19424
+ host.addEventListener(name, this.handleUserGesture, USER_GESTURE_OPTIONS);
19425
+ });
19376
19426
  requestAnimationFrame(() => this.alignScroll("instant"));
19377
19427
  }
19378
- measureItemHeight() {
19428
+ /**
19429
+ * The height a row is actually rendered at, or 0 when it has no box at all
19430
+ * (the picker is hidden, or has not been laid out yet).
19431
+ */
19432
+ measureItemBox() {
19379
19433
  const root = this.el.nativeElement;
19380
19434
  const item = root.querySelector(".tedi-time-picker__item");
19381
- return item?.offsetHeight || DEFAULT_ITEM_HEIGHT;
19435
+ return item?.offsetHeight ?? 0;
19436
+ }
19437
+ measureItemHeight() {
19438
+ return this.measureItemBox() || DEFAULT_ITEM_HEIGHT;
19382
19439
  }
19383
19440
  getItemHeight() {
19384
19441
  return this.cachedItemHeight ?? this.measureItemHeight();
19385
19442
  }
19443
+ /**
19444
+ * Column offsets are derived from the item height, so when the height changes
19445
+ * (a breakpoint switch, or the first real measurement after
19446
+ * `DEFAULT_ITEM_HEIGHT` stood in) they no longer land on the highlighted row.
19447
+ */
19448
+ syncItemHeight() {
19449
+ // A hidden element measures 0 and ResizeObserver fires for it, so keep the
19450
+ // last height actually rendered rather than caching the fallback.
19451
+ const height = this.measureItemBox();
19452
+ if (!height || height === this.cachedItemHeight)
19453
+ return;
19454
+ this.cachedItemHeight = height;
19455
+ if (this.variant() !== "scroll")
19456
+ return;
19457
+ this.scrollColumnToIndex("hour", this.hourScrollIndex(), "instant");
19458
+ this.scrollColumnToIndex("minute", this.minuteScrollIndex(), "instant");
19459
+ }
19386
19460
  ngOnDestroy() {
19387
19461
  this.resizeObserver?.disconnect();
19462
+ const host = this.el.nativeElement;
19463
+ USER_GESTURE_EVENTS.forEach((name) => {
19464
+ host.removeEventListener(name, this.handleUserGesture, USER_GESTURE_OPTIONS);
19465
+ });
19388
19466
  Object.keys(this.scrollLockTimer).forEach((k) => {
19389
19467
  const t = this.scrollLockTimer[k];
19390
19468
  if (t)
@@ -19395,6 +19473,11 @@ class TimePickerComponent {
19395
19473
  if (t)
19396
19474
  clearTimeout(t);
19397
19475
  });
19476
+ Object.keys(this.gestureTimer).forEach((k) => {
19477
+ const t = this.gestureTimer[k];
19478
+ if (t)
19479
+ clearTimeout(t);
19480
+ });
19398
19481
  }
19399
19482
  writeValue(value) {
19400
19483
  this.value.set(value);
@@ -19445,6 +19528,9 @@ class TimePickerComponent {
19445
19528
  onHourClick(hour) {
19446
19529
  if (this.isDisabled())
19447
19530
  return;
19531
+ // The pointer went down to pick a row, not to scroll the column.
19532
+ this.hasUserGesture.hour = false;
19533
+ this.clearGestureTimer("hour");
19448
19534
  const idx = parseInt(hour, 10);
19449
19535
  this.scrollColumnToIndex("hour", idx, "smooth");
19450
19536
  this.selectHour(hour);
@@ -19453,6 +19539,8 @@ class TimePickerComponent {
19453
19539
  onMinuteClick(minute) {
19454
19540
  if (this.isDisabled())
19455
19541
  return;
19542
+ this.hasUserGesture.minute = false;
19543
+ this.clearGestureTimer("minute");
19456
19544
  const list = this.minutes();
19457
19545
  const idx = list.indexOf(minute);
19458
19546
  if (idx >= 0)
@@ -19522,33 +19610,125 @@ class TimePickerComponent {
19522
19610
  return null;
19523
19611
  }
19524
19612
  }
19613
+ handleUserGesture = (event) => {
19614
+ const target = event.target;
19615
+ const column = target?.closest?.(".tedi-time-picker__column") ?? null;
19616
+ if (!column)
19617
+ return;
19618
+ let type = null;
19619
+ if (column === this.getColumnElement("hour"))
19620
+ type = "hour";
19621
+ if (column === this.getColumnElement("minute"))
19622
+ type = "minute";
19623
+ if (!type)
19624
+ return;
19625
+ this.hasUserGesture[type] = true;
19626
+ this.hasCorrectedScroll[type] = false;
19627
+ this.armGestureTimer(type);
19628
+ };
19629
+ /**
19630
+ * Bounds a gesture that never scrolls. Without it one pointer-down on a
19631
+ * column's padding would leave it marked user-driven forever, and the next
19632
+ * offset the engine handed it would be committed as a value.
19633
+ */
19634
+ armGestureTimer(type) {
19635
+ this.clearGestureTimer(type);
19636
+ this.gestureTimer[type] = setTimeout(() => {
19637
+ this.hasUserGesture[type] = false;
19638
+ delete this.gestureTimer[type];
19639
+ }, GESTURE_WITHOUT_SCROLL_MS);
19640
+ }
19641
+ clearGestureTimer(type) {
19642
+ const existing = this.gestureTimer[type];
19643
+ if (existing)
19644
+ clearTimeout(existing);
19645
+ delete this.gestureTimer[type];
19646
+ }
19647
+ /**
19648
+ * Precedence, highest first: a gesture on this column (the offset is the
19649
+ * user's, and it takes over any animation still running), a programmatic
19650
+ * scroll (its own echo, never re-read as a selection), or neither, which
19651
+ * means the engine moved it and the column goes back on the value's row.
19652
+ */
19525
19653
  onColumnScroll(type) {
19526
19654
  const column = this.getColumnElement(type);
19527
19655
  if (!column)
19528
19656
  return;
19657
+ if (this.hasUserGesture[type]) {
19658
+ // The debounce owns the flag from here, not the no-scroll timeout.
19659
+ this.clearGestureTimer(type);
19660
+ this.clearProgrammaticScroll(type);
19661
+ this.setScrollIndex(type, this.indexFromScroll(type, column));
19662
+ this.scheduleScrollSelection(type);
19663
+ return;
19664
+ }
19665
+ if (this.isProgrammaticScroll[type]) {
19666
+ // Keep the lock open for as long as events keep arriving, so a column
19667
+ // that is still settling (a snap adjustment on top of the scroll, a slow
19668
+ // frame) stays owned by the scroll that started it.
19669
+ this.releaseScrollLockAfter(type, SCROLL_SETTLE_QUIET_MS);
19670
+ // A non-smooth programmatic scroll has already set the authoritative
19671
+ // index. Re-deriving it from scrollTop would move the highlight to a
19672
+ // neighbouring row whenever the measured item height and the rendered one
19673
+ // disagree. Smooth ones fall through: their highlight tracks the running
19674
+ // animation.
19675
+ if (this.isSmoothProgrammaticScroll[type]) {
19676
+ this.setScrollIndex(type, this.indexFromScroll(type, column));
19677
+ return;
19678
+ }
19679
+ // A snap adjustment settles on the row next to the offset it was given,
19680
+ // so anything further out is not this scroll finishing.
19681
+ const target = this.programmaticScrollTarget[type] ?? column.scrollTop;
19682
+ if (Math.abs(column.scrollTop - target) <= this.getItemHeight())
19683
+ return;
19684
+ }
19685
+ this.restoreColumnPosition(type);
19686
+ }
19687
+ indexFromScroll(type, column) {
19529
19688
  const list = type === "hour" ? this.hours : this.minutes();
19530
19689
  const rawIndex = Math.round(column.scrollTop / this.getItemHeight());
19531
- const index = Math.max(0, Math.min(rawIndex, list.length - 1));
19690
+ return Math.max(0, Math.min(rawIndex, list.length - 1));
19691
+ }
19692
+ setScrollIndex(type, index) {
19532
19693
  if (type === "hour")
19533
19694
  this.hourScrollIndex.set(index);
19534
19695
  else
19535
19696
  this.minuteScrollIndex.set(index);
19536
- if (this.isProgrammaticScroll[type])
19537
- return;
19697
+ }
19698
+ scheduleScrollSelection(type) {
19538
19699
  const existing = this.scrollDebounceTimer[type];
19539
19700
  if (existing)
19540
19701
  clearTimeout(existing);
19541
19702
  this.scrollDebounceTimer[type] = setTimeout(() => {
19542
- const col = this.getColumnElement(type);
19543
- if (!col)
19703
+ delete this.scrollDebounceTimer[type];
19704
+ this.hasUserGesture[type] = false;
19705
+ this.clearGestureTimer(type);
19706
+ const column = this.getColumnElement(type);
19707
+ if (!column)
19544
19708
  return;
19545
- const finalIdx = Math.max(0, Math.min(Math.round(col.scrollTop / this.getItemHeight()), list.length - 1));
19709
+ const list = type === "hour" ? this.hours : this.minutes();
19710
+ const index = this.indexFromScroll(type, column);
19546
19711
  if (type === "hour")
19547
- this.selectHour(list[finalIdx]);
19712
+ this.selectHour(list[index]);
19548
19713
  else
19549
- this.selectMinute(list[finalIdx]);
19714
+ this.selectMinute(list[index]);
19550
19715
  }, SCROLL_DEBOUNCE_MS);
19551
19716
  }
19717
+ /**
19718
+ * Gecko hands a torn-down scroll container's offset to the one replacing it
19719
+ * in the same position, so a picker can arrive already scrolled to the
19720
+ * previous picker's row. Nothing marks that as not-the-user, which is how
19721
+ * `09:30` came to render and report `14:06`.
19722
+ */
19723
+ restoreColumnPosition(type) {
19724
+ if (this.hasCorrectedScroll[type])
19725
+ return;
19726
+ const index = type === "hour" ? this.selectedHourIndex() : this.selectedMinuteIndex();
19727
+ // Only a correction that moved the column spends the one-shot budget.
19728
+ if (this.scrollColumnToIndex(type, index, "instant")) {
19729
+ this.hasCorrectedScroll[type] = true;
19730
+ }
19731
+ }
19552
19732
  /** Roving-tabindex helper for dropdown items: only the selected item (or the first
19553
19733
  * when nothing is selected yet) is in the tab sequence. */
19554
19734
  getDropdownTabIndex(index) {
@@ -19638,21 +19818,30 @@ class TimePickerComponent {
19638
19818
  return;
19639
19819
  const targetHour = this.selectedHourIndex();
19640
19820
  const targetMinute = this.selectedMinuteIndex();
19821
+ // A fresh alignment is a fresh start for the correction guard.
19822
+ this.hasCorrectedScroll.hour = false;
19823
+ this.hasCorrectedScroll.minute = false;
19641
19824
  if (behavior !== "smooth") {
19642
19825
  this.hourScrollIndex.set(targetHour);
19643
19826
  this.minuteScrollIndex.set(targetMinute);
19644
19827
  }
19645
- if (!this.isProgrammaticScroll.hour) {
19828
+ // Only a smooth scroll is worth protecting from a realignment: it is still
19829
+ // animating towards the value that started it, and an instant scrollTo to
19830
+ // the same row would cut that animation short. An instant scroll has
19831
+ // already arrived, so a new value must be allowed to move the column even
19832
+ // while the column is settling.
19833
+ if (!this.isSmoothProgrammaticScroll.hour) {
19646
19834
  this.scrollColumnToIndex("hour", targetHour, behavior);
19647
19835
  }
19648
- if (!this.isProgrammaticScroll.minute) {
19836
+ if (!this.isSmoothProgrammaticScroll.minute) {
19649
19837
  this.scrollColumnToIndex("minute", targetMinute, behavior);
19650
19838
  }
19651
19839
  }
19840
+ /** Returns whether a scroll was actually issued. */
19652
19841
  scrollColumnToIndex(type, index, behavior = "auto") {
19653
19842
  const column = this.getColumnElement(type);
19654
19843
  if (!column)
19655
- return;
19844
+ return false;
19656
19845
  const target = index * this.getItemHeight();
19657
19846
  if (behavior !== "smooth") {
19658
19847
  if (type === "hour")
@@ -19661,20 +19850,46 @@ class TimePickerComponent {
19661
19850
  this.minuteScrollIndex.set(index);
19662
19851
  }
19663
19852
  if (Math.abs(column.scrollTop - target) < 1)
19664
- return;
19853
+ return false;
19665
19854
  this.isProgrammaticScroll[type] = true;
19666
- const existing = this.scrollLockTimer[type];
19667
- if (existing)
19668
- clearTimeout(existing);
19855
+ this.isSmoothProgrammaticScroll[type] = behavior === "smooth";
19856
+ this.programmaticScrollTarget[type] = target;
19669
19857
  if (typeof column.scrollTo === "function") {
19670
19858
  column.scrollTo({ top: target, behavior });
19671
19859
  }
19672
19860
  else {
19673
19861
  column.scrollTop = target;
19674
19862
  }
19863
+ this.scrollLockDeadline[type] = Date.now() + SCROLL_SETTLE_MAX_WAIT_MS;
19864
+ this.releaseScrollLockAfter(type, SCROLL_SETTLE_MAX_WAIT_MS);
19865
+ return true;
19866
+ }
19867
+ releaseScrollLockAfter(type, delay) {
19868
+ const existing = this.scrollLockTimer[type];
19869
+ if (existing)
19870
+ clearTimeout(existing);
19871
+ const deadline = this.scrollLockDeadline[type];
19872
+ const wait = deadline === undefined
19873
+ ? delay
19874
+ : Math.max(0, Math.min(delay, deadline - Date.now()));
19675
19875
  this.scrollLockTimer[type] = setTimeout(() => {
19676
19876
  this.isProgrammaticScroll[type] = false;
19677
- }, behavior === "smooth" ? SMOOTH_SCROLL_LOCK_MS : INSTANT_SCROLL_LOCK_MS);
19877
+ this.isSmoothProgrammaticScroll[type] = false;
19878
+ delete this.programmaticScrollTarget[type];
19879
+ delete this.scrollLockTimer[type];
19880
+ delete this.scrollLockDeadline[type];
19881
+ }, wait);
19882
+ }
19883
+ /** Drops every trace of a programmatic scroll, target offset included. */
19884
+ clearProgrammaticScroll(type) {
19885
+ const existing = this.scrollLockTimer[type];
19886
+ if (existing)
19887
+ clearTimeout(existing);
19888
+ delete this.scrollLockTimer[type];
19889
+ delete this.scrollLockDeadline[type];
19890
+ this.isProgrammaticScroll[type] = false;
19891
+ this.isSmoothProgrammaticScroll[type] = false;
19892
+ delete this.programmaticScrollTarget[type];
19678
19893
  }
19679
19894
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: TimePickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
19680
19895
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.24", type: TimePickerComponent, isStandalone: true, selector: "tedi-time-picker", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, timeSlots: { classPropertyName: "timeSlots", publicName: "timeSlots", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, showSlotIndicator: { classPropertyName: "showSlotIndicator", publicName: "showSlotIndicator", isSignal: true, isRequired: false, transformFunction: null }, minuteStep: { classPropertyName: "minuteStep", publicName: "minuteStep", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, border: { classPropertyName: "border", publicName: "border", isSignal: true, isRequired: false, transformFunction: null }, trapFocus: { classPropertyName: "trapFocus", publicName: "trapFocus", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", closeRequested: "closeRequested" }, host: { properties: { "class.tedi-time-picker--scroll": "variant() === 'scroll'", "class.tedi-time-picker--slots": "variant() === 'slots'", "class.tedi-time-picker--dropdown": "variant() === 'dropdown'", "class.tedi-time-picker--disabled": "isDisabled()", "class.tedi-time-picker--bordered": "border()" }, classAttribute: "tedi-time-picker" }, providers: [