@tedi-design-system/angular 9.0.0-rc.3 → 9.0.0-rc.5

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