@radix-ng/primitives 1.0.0-beta.0 → 1.0.0-beta.2

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.
Files changed (117) hide show
  1. package/fesm2022/radix-ng-primitives-accordion.mjs +2 -2
  2. package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
  3. package/fesm2022/radix-ng-primitives-calendar.mjs +109 -84
  4. package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
  5. package/fesm2022/radix-ng-primitives-checkbox.mjs +2 -2
  6. package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
  7. package/fesm2022/radix-ng-primitives-collapsible.mjs +1 -1
  8. package/fesm2022/radix-ng-primitives-collapsible.mjs.map +1 -1
  9. package/fesm2022/radix-ng-primitives-combobox.mjs +1923 -0
  10. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -0
  11. package/fesm2022/radix-ng-primitives-context-menu.mjs +1 -1
  12. package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
  13. package/fesm2022/radix-ng-primitives-core.mjs +591 -470
  14. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  15. package/fesm2022/radix-ng-primitives-cropper.mjs +287 -308
  16. package/fesm2022/radix-ng-primitives-cropper.mjs.map +1 -1
  17. package/fesm2022/radix-ng-primitives-date-field.mjs +66 -15
  18. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  19. package/fesm2022/radix-ng-primitives-dialog.mjs +1 -1
  20. package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
  21. package/fesm2022/radix-ng-primitives-drawer.mjs +7 -106
  22. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  23. package/fesm2022/radix-ng-primitives-editable.mjs +305 -24
  24. package/fesm2022/radix-ng-primitives-editable.mjs.map +1 -1
  25. package/fesm2022/radix-ng-primitives-field.mjs +86 -6
  26. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  27. package/fesm2022/radix-ng-primitives-fieldset.mjs +1 -1
  28. package/fesm2022/radix-ng-primitives-fieldset.mjs.map +1 -1
  29. package/fesm2022/radix-ng-primitives-focus-scope.mjs +1 -1
  30. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  31. package/fesm2022/radix-ng-primitives-form.mjs +207 -0
  32. package/fesm2022/radix-ng-primitives-form.mjs.map +1 -0
  33. package/fesm2022/radix-ng-primitives-input.mjs +85 -4
  34. package/fesm2022/radix-ng-primitives-input.mjs.map +1 -1
  35. package/fesm2022/radix-ng-primitives-menu.mjs +413 -5
  36. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  37. package/fesm2022/radix-ng-primitives-menubar.mjs +1 -1
  38. package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
  39. package/fesm2022/radix-ng-primitives-meter.mjs +1 -1
  40. package/fesm2022/radix-ng-primitives-meter.mjs.map +1 -1
  41. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +1 -1
  42. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  43. package/fesm2022/radix-ng-primitives-number-field.mjs +2 -2
  44. package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
  45. package/fesm2022/radix-ng-primitives-popover.mjs +1 -1
  46. package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
  47. package/fesm2022/radix-ng-primitives-popper.mjs +22 -5
  48. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  49. package/fesm2022/radix-ng-primitives-portal.mjs.map +1 -1
  50. package/fesm2022/radix-ng-primitives-preview-card.mjs +1 -1
  51. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  52. package/fesm2022/radix-ng-primitives-progress.mjs +1 -1
  53. package/fesm2022/radix-ng-primitives-progress.mjs.map +1 -1
  54. package/fesm2022/radix-ng-primitives-roving-focus.mjs +1 -1
  55. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  56. package/fesm2022/radix-ng-primitives-scroll-area.mjs +923 -0
  57. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -0
  58. package/fesm2022/radix-ng-primitives-select.mjs +421 -224
  59. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  60. package/fesm2022/radix-ng-primitives-slider.mjs +1 -1
  61. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  62. package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
  63. package/fesm2022/radix-ng-primitives-switch.mjs +3 -2
  64. package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
  65. package/fesm2022/radix-ng-primitives-tabs.mjs +12 -3
  66. package/fesm2022/radix-ng-primitives-tabs.mjs.map +1 -1
  67. package/fesm2022/radix-ng-primitives-time-field.mjs +27 -3
  68. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  69. package/fesm2022/radix-ng-primitives-toast.mjs +839 -0
  70. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -0
  71. package/fesm2022/radix-ng-primitives-toggle-group.mjs +1 -1
  72. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  73. package/fesm2022/radix-ng-primitives-toolbar.mjs +2 -2
  74. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  75. package/fesm2022/radix-ng-primitives-tooltip.mjs +11 -3
  76. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  77. package/package.json +18 -2
  78. package/schematics/ng-add/index.js +57 -0
  79. package/schematics/ng-add/index.js.map +1 -1
  80. package/schematics/ng-add/schema.d.ts +1 -0
  81. package/schematics/ng-add/schema.json +6 -0
  82. package/types/radix-ng-primitives-accordion.d.ts +3 -2
  83. package/types/radix-ng-primitives-calendar.d.ts +38 -18
  84. package/types/radix-ng-primitives-checkbox.d.ts +5 -5
  85. package/types/radix-ng-primitives-collapsible.d.ts +2 -1
  86. package/types/radix-ng-primitives-combobox.d.ts +1265 -0
  87. package/types/radix-ng-primitives-context-menu.d.ts +3 -2
  88. package/types/radix-ng-primitives-core.d.ts +187 -56
  89. package/types/radix-ng-primitives-cropper.d.ts +89 -56
  90. package/types/radix-ng-primitives-date-field.d.ts +11 -5
  91. package/types/radix-ng-primitives-dialog.d.ts +2 -1
  92. package/types/radix-ng-primitives-drawer.d.ts +5 -27
  93. package/types/radix-ng-primitives-editable.d.ts +90 -13
  94. package/types/radix-ng-primitives-field.d.ts +74 -4
  95. package/types/radix-ng-primitives-fieldset.d.ts +3 -2
  96. package/types/radix-ng-primitives-focus-scope.d.ts +2 -1
  97. package/types/radix-ng-primitives-form.d.ts +124 -0
  98. package/types/radix-ng-primitives-input.d.ts +75 -5
  99. package/types/radix-ng-primitives-menu.d.ts +16 -4
  100. package/types/radix-ng-primitives-menubar.d.ts +2 -1
  101. package/types/radix-ng-primitives-meter.d.ts +3 -2
  102. package/types/radix-ng-primitives-navigation-menu.d.ts +1 -1
  103. package/types/radix-ng-primitives-number-field.d.ts +6 -6
  104. package/types/radix-ng-primitives-popover.d.ts +2 -1
  105. package/types/radix-ng-primitives-popper.d.ts +19 -2
  106. package/types/radix-ng-primitives-preview-card.d.ts +1 -1
  107. package/types/radix-ng-primitives-progress.d.ts +3 -2
  108. package/types/radix-ng-primitives-roving-focus.d.ts +4 -3
  109. package/types/radix-ng-primitives-scroll-area.d.ts +253 -0
  110. package/types/radix-ng-primitives-select.d.ts +296 -136
  111. package/types/radix-ng-primitives-slider.d.ts +1 -1
  112. package/types/radix-ng-primitives-switch.d.ts +1 -1
  113. package/types/radix-ng-primitives-tabs.d.ts +1 -1
  114. package/types/radix-ng-primitives-toast.d.ts +378 -0
  115. package/types/radix-ng-primitives-toggle-group.d.ts +2 -1
  116. package/types/radix-ng-primitives-toolbar.d.ts +3 -2
  117. package/types/radix-ng-primitives-tooltip.d.ts +3 -2
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { forwardRef, input, booleanAttribute, output, linkedSignal, untracked, Directive, inject, InjectionToken, computed, APP_ID, Injectable, DOCUMENT, PLATFORM_ID, DestroyRef, signal, afterNextRender, effect, ElementRef, Injector } from '@angular/core';
2
+ import { forwardRef, input, booleanAttribute, output, linkedSignal, untracked, Directive, inject, InjectionToken, computed, APP_ID, Injectable, DOCUMENT, PLATFORM_ID, DestroyRef, signal, afterNextRender, effect, ElementRef, assertInInjectionContext, Injector } from '@angular/core';
3
3
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
4
  import { getLocalTimeZone, CalendarDateTime, ZonedDateTime, getDayOfWeek, DateFormatter, createCalendar, toCalendar, CalendarDate, Time, startOfMonth, endOfMonth, today } from '@internationalized/date';
5
5
  import { isPlatformBrowser, DOCUMENT as DOCUMENT$1 } from '@angular/common';
@@ -183,16 +183,25 @@ function snapValueToStep(value, min, max, step) {
183
183
 
184
184
  // Thanks for idea.
185
185
  // https://github.com/unovue/reka-ui/blob/v2/packages/core/src/shared/createContext.ts
186
+ /**
187
+ * Base URL of the documentation site. Each primitive's docs are also served as plain
188
+ * Markdown at `/<section>/<slug>.md`, which both humans and AI agents can open.
189
+ */
190
+ const DOCS_BASE_URL = 'https://radix-ng.com';
186
191
  /**
187
192
  * Creates a context with injector and provider functions for a given type
188
193
  * @template T The type of the context value
189
194
  * @param description Descriptive string for the context (used in token creation)
195
+ * @param docs Documentation path for the owning primitive (e.g. `'components/accordion'`),
196
+ * appended to the missing-context error as a link to the required anatomy
190
197
  * @returns A tuple containing:
191
198
  * - injectContext: Function to retrieve the context value
192
199
  * - provideContext: Function to create a provider for the context
193
200
  */
194
- function createContext(description) {
195
- const CONTEXT_TOKEN = new InjectionToken(`${description}Context`);
201
+ function createContext(description, docs) {
202
+ // Normalize so both `'FooRoot'` and `'FooRootContext'` read as `FooRootContext`.
203
+ const contextName = `${description.replace(/\s*Context$/, '')}Context`;
204
+ const CONTEXT_TOKEN = new InjectionToken(contextName);
196
205
  /**
197
206
  * Retrieves the context value from Angular's dependency injection
198
207
  * @param optional If true, returns null when context is not provided instead of throwing
@@ -200,9 +209,14 @@ function createContext(description) {
200
209
  * @throws Error when context is not provided and not optional
201
210
  */
202
211
  const injectContext = (optional = false) => {
203
- const value = optional ? inject(CONTEXT_TOKEN, { optional: true }) : inject(CONTEXT_TOKEN);
212
+ // Always inject optionally so a missing context produces our own descriptive
213
+ // error instead of Angular's generic NullInjectorError. This also catches a
214
+ // provided factory that returns null/undefined for the non-optional case.
215
+ const value = inject(CONTEXT_TOKEN, { optional: true });
204
216
  if (value == null && !optional) {
205
- throw new Error(`No context provided for token ${CONTEXT_TOKEN.toString()}`);
217
+ const docsHint = docs ? ` See ${DOCS_BASE_URL}/${docs}.md for the required part hierarchy.` : '';
218
+ throw new Error(`No \`${contextName}\` found. This part must be placed inside the directive ` +
219
+ `that provides \`${contextName}\` (usually the primitive's root).${docsHint}`);
206
220
  }
207
221
  return value;
208
222
  };
@@ -371,6 +385,12 @@ function areAllDaysBetweenValid(start, end, isUnavailable, isDisabled) {
371
385
  }
372
386
  return true;
373
387
  }
388
+ /**
389
+ * The granularities that require a time component (and therefore a `CalendarDateTime`
390
+ * rather than a `CalendarDate`). Single source of truth — used by both the default-date
391
+ * builder and the segment-value initializer.
392
+ */
393
+ const TIME_GRANULARITIES = ['hour', 'minute', 'second'];
374
394
  /**
375
395
  * A helper function used throughout the various date builders
376
396
  * to generate a default `DateValue` using the `defaultValue`,
@@ -383,20 +403,25 @@ function areAllDaysBetweenValid(start, end, isUnavailable, isDisabled) {
383
403
  */
384
404
  function getDefaultDate(props) {
385
405
  const { defaultValue, defaultPlaceholder, granularity = 'day', locale = 'en' } = props;
386
- if (Array.isArray(defaultValue) && defaultValue.length)
387
- return defaultValue[defaultValue.length - 1].copy();
388
- if (defaultValue && !Array.isArray(defaultValue))
406
+ if (Array.isArray(defaultValue)) {
407
+ // Multiple-selection calendars pass an array. The view follows the most recently
408
+ // selected date — consistent with calendar-root's value→placeholder sync. An empty
409
+ // array means "no selection", so fall through to the placeholder / today.
410
+ if (defaultValue.length)
411
+ return defaultValue[defaultValue.length - 1].copy();
412
+ }
413
+ else if (defaultValue) {
389
414
  return defaultValue.copy();
415
+ }
390
416
  if (defaultPlaceholder)
391
417
  return defaultPlaceholder.copy();
392
418
  const date = new Date();
393
419
  const year = date.getFullYear();
394
420
  const month = date.getMonth() + 1;
395
421
  const day = date.getDate();
396
- const calendarDateTimeGranularities = ['hour', 'minute', 'second'];
397
422
  const defaultFormatter = new DateFormatter(locale);
398
423
  const calendar = createCalendar(defaultFormatter.resolvedOptions().calendar);
399
- if (calendarDateTimeGranularities.includes(granularity ?? 'day'))
424
+ if (TIME_GRANULARITIES.includes(granularity ?? 'day'))
400
425
  return toCalendar(new CalendarDateTime(year, month, day, 0, 0, 0), calendar);
401
426
  return toCalendar(new CalendarDate(year, month, day), calendar);
402
427
  }
@@ -494,7 +519,10 @@ function getWeekNumber(date, locale = 'en-US', firstDayOfWeek) {
494
519
  const prevYearDate = new CalendarDate(date.year - 1, 12, 31);
495
520
  return getWeekNumber(prevYearDate, locale, firstDayOfWeek);
496
521
  }
497
- const days = getDaysBetween(firstWeekStart, date);
522
+ // `getDaysBetween` is exclusive of both ends, so extend the end by a day to count the
523
+ // full span from `firstWeekStart` through `date` (otherwise every 7-day boundary is
524
+ // under-counted by one and the week number lags).
525
+ const days = getDaysBetween(firstWeekStart, date.add({ days: 1 }));
498
526
  // Week number is days divided by 7 plus 1
499
527
  return Math.floor(days.length / 7) + 1;
500
528
  }
@@ -507,6 +535,22 @@ const defaultPartOptions = {
507
535
  minute: 'numeric',
508
536
  second: 'numeric'
509
537
  };
538
+ /**
539
+ * Constructing a `DateFormatter` (which wraps `Intl.DateTimeFormat`) is by far the most
540
+ * expensive operation here, and `createContent` formats every segment on each keystroke.
541
+ * Reuse instances keyed by locale + options — the set of distinct combinations is tiny and
542
+ * the instances are immutable.
543
+ */
544
+ const formatterCache = new Map();
545
+ function getDateFormatter(locale, options) {
546
+ const key = `${locale}|${JSON.stringify(options)}`;
547
+ let formatter = formatterCache.get(key);
548
+ if (!formatter) {
549
+ formatter = new DateFormatter(locale, options);
550
+ formatterCache.set(key, formatter);
551
+ }
552
+ return formatter;
553
+ }
510
554
  /**
511
555
  * Creates a wrapper around the `DateFormatter`, which is
512
556
  * an improved version of the {@link Intl.DateTimeFormat} API,
@@ -524,7 +568,7 @@ function createFormatter(initialLocale, opts = {}) {
524
568
  return locale;
525
569
  }
526
570
  function custom(date, options) {
527
- return new DateFormatter(locale, { ...opts, ...options }).format(date);
571
+ return getDateFormatter(locale, { ...opts, ...options }).format(date);
528
572
  }
529
573
  function selectedDate(date, includeTime = true) {
530
574
  if (hasTime(date) && includeTime) {
@@ -540,31 +584,31 @@ function createFormatter(initialLocale, opts = {}) {
540
584
  }
541
585
  }
542
586
  function fullMonthAndYear(date, options = {}) {
543
- return new DateFormatter(locale, { ...opts, month: 'long', year: 'numeric', ...options }).format(date);
587
+ return getDateFormatter(locale, { ...opts, month: 'long', year: 'numeric', ...options }).format(date);
544
588
  }
545
589
  function fullMonth(date, options = {}) {
546
- return new DateFormatter(locale, { ...opts, month: 'long', ...options }).format(date);
590
+ return getDateFormatter(locale, { ...opts, month: 'long', ...options }).format(date);
547
591
  }
548
592
  function fullYear(date, options = {}) {
549
- return new DateFormatter(locale, { ...opts, year: 'numeric', ...options }).format(date);
593
+ return getDateFormatter(locale, { ...opts, year: 'numeric', ...options }).format(date);
550
594
  }
551
595
  function toParts(date, options) {
552
596
  if (isZonedDateTime(date)) {
553
- return new DateFormatter(locale, {
597
+ return getDateFormatter(locale, {
554
598
  ...opts,
555
599
  ...options,
556
600
  timeZone: date.timeZone
557
601
  }).formatToParts(toDate(date));
558
602
  }
559
603
  else {
560
- return new DateFormatter(locale, { ...opts, ...options }).formatToParts(toDate(date));
604
+ return getDateFormatter(locale, { ...opts, ...options }).formatToParts(toDate(date));
561
605
  }
562
606
  }
563
607
  function dayOfWeek(date, length = 'narrow') {
564
- return new DateFormatter(locale, { ...opts, weekday: length }).format(date);
608
+ return getDateFormatter(locale, { ...opts, weekday: length }).format(date);
565
609
  }
566
610
  function dayPeriod(date, hourCycle = undefined) {
567
- const parts = new DateFormatter(locale, {
611
+ const parts = getDateFormatter(locale, {
568
612
  ...opts,
569
613
  hour: 'numeric',
570
614
  minute: 'numeric',
@@ -828,7 +872,6 @@ function normalizeHour12(hourCycle) {
828
872
  return undefined;
829
873
  }
830
874
 
831
- const calendarDateTimeGranularities = ['hour', 'minute', 'second'];
832
875
  function syncTimeSegmentValues(props) {
833
876
  return Object.fromEntries(TIME_SEGMENT_PARTS.map((part) => {
834
877
  if (part === 'dayPeriod')
@@ -836,7 +879,7 @@ function syncTimeSegmentValues(props) {
836
879
  return [part, props.value[part]];
837
880
  }));
838
881
  }
839
- function initializeSegmentValues(granularity) {
882
+ function initializeSegmentValues(granularity, isTimeValue = false) {
840
883
  const initialParts = EDITABLE_SEGMENT_PARTS.map((part) => {
841
884
  if (part === 'dayPeriod')
842
885
  return [part, 'AM'];
@@ -844,12 +887,16 @@ function initializeSegmentValues(granularity) {
844
887
  }).filter(([key]) => {
845
888
  if (key === 'literal' || key === null)
846
889
  return false;
890
+ // A time-only field has no date segments, so they must not be seeded — otherwise they
891
+ // stay null forever and block committing the value (every segment must be filled).
892
+ if (isTimeValue && isDateSegmentPart(key))
893
+ return false;
847
894
  if (granularity === 'minute' && key === 'second')
848
895
  return false;
849
896
  if (granularity === 'hour' && (key === 'second' || key === 'minute'))
850
897
  return false;
851
898
  if (granularity === 'day')
852
- return !calendarDateTimeGranularities.includes(key) && key !== 'dayPeriod';
899
+ return !TIME_GRANULARITIES.includes(key) && key !== 'dayPeriod';
853
900
  else
854
901
  return true;
855
902
  });
@@ -1051,6 +1098,20 @@ function getSegmentElements(parentElement) {
1051
1098
  */
1052
1099
 
1053
1100
  // https://github.com/unovue/reka-ui/blob/v2/packages/core/src/shared/date/useDateField.ts
1101
+ /** Convert a canonical 24-hour value (0-23) to its 12-hour clock equivalent (1-12). */
1102
+ function to12Hour(hour) {
1103
+ const h = hour % 12;
1104
+ return h === 0 ? 12 : h;
1105
+ }
1106
+ /** Combine a 12-hour clock value (1-12) with a day period into a canonical 24-hour value (0-23). */
1107
+ function to24Hour(hour, period) {
1108
+ const h = hour % 12;
1109
+ return period === 'PM' ? h + 12 : h;
1110
+ }
1111
+ /** The day period a canonical 24-hour value belongs to. */
1112
+ function dayPeriodForHour(hour) {
1113
+ return hour >= 12 ? 'PM' : 'AM';
1114
+ }
1054
1115
  function commonSegmentAttrs(props) {
1055
1116
  return {
1056
1117
  role: 'spinbutton',
@@ -1063,120 +1124,79 @@ function commonSegmentAttrs(props) {
1063
1124
  style: 'caret-color: transparent;'
1064
1125
  };
1065
1126
  }
1066
- function daySegmentAttrs(props) {
1127
+ /**
1128
+ * Shared spinbutton attributes for the numeric segments (day/month/year/hour/minute/second).
1129
+ * Each segment differs only in its field, bounds, label and optional value-text formatting.
1130
+ */
1131
+ function numericSegmentAttrs(props, config) {
1067
1132
  const { segmentValues, placeholder } = props;
1068
- const isEmpty = segmentValues.day === null;
1069
- const date = segmentValues.day ? placeholder.set({ day: segmentValues.day }) : placeholder;
1070
- const valueNow = date.day;
1071
- const valueMin = 1;
1072
- const valueMax = getDaysInMonth(date);
1073
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1133
+ const { field } = config;
1134
+ if (config.timePart && (!(field in segmentValues) || !(field in placeholder)))
1135
+ return {};
1136
+ const value = segmentValues[field];
1137
+ const isEmpty = value == null;
1138
+ const date = value != null ? placeholder.set({ [field]: value }) : placeholder;
1139
+ const valueNow = date[field];
1140
+ const valueMax = typeof config.valueMax === 'function' ? config.valueMax(date) : config.valueMax;
1141
+ const valueText = isEmpty ? 'Empty' : (config.valueText?.(date) ?? `${valueNow}`);
1074
1142
  return {
1075
1143
  ...commonSegmentAttrs(props),
1076
- 'aria-label': 'day,',
1077
- 'aria-valuemin': valueMin,
1144
+ 'aria-label': config.label,
1145
+ 'aria-valuemin': config.valueMin,
1078
1146
  'aria-valuemax': valueMax,
1079
1147
  'aria-valuenow': valueNow,
1080
1148
  'aria-valuetext': valueText,
1081
1149
  'data-placeholder': isEmpty ? '' : undefined
1082
1150
  };
1083
1151
  }
1152
+ function daySegmentAttrs(props) {
1153
+ return numericSegmentAttrs(props, {
1154
+ field: 'day',
1155
+ label: 'day,',
1156
+ valueMin: 1,
1157
+ valueMax: (date) => getDaysInMonth(date)
1158
+ });
1159
+ }
1084
1160
  function monthSegmentAttrs(props) {
1085
- const { segmentValues, placeholder, formatter } = props;
1086
- const isEmpty = segmentValues.month === null;
1087
- const date = segmentValues.month ? placeholder.set({ month: segmentValues.month }) : placeholder;
1088
- const valueNow = date.month;
1089
- const valueMin = 1;
1090
- const valueMax = 12;
1091
- const valueText = isEmpty ? 'Empty' : `${valueNow} - ${formatter.fullMonth(toDate(date))}`;
1092
- return {
1093
- ...commonSegmentAttrs(props),
1094
- 'aria-label': 'month, ',
1095
- contenteditable: true,
1096
- 'aria-valuemin': valueMin,
1097
- 'aria-valuemax': valueMax,
1098
- 'aria-valuenow': valueNow,
1099
- 'aria-valuetext': valueText,
1100
- 'data-placeholder': isEmpty ? '' : undefined
1101
- };
1161
+ return numericSegmentAttrs(props, {
1162
+ field: 'month',
1163
+ label: 'month, ',
1164
+ valueMin: 1,
1165
+ valueMax: 12,
1166
+ valueText: (date) => `${date.month} - ${props.formatter.fullMonth(toDate(date))}`
1167
+ });
1102
1168
  }
1103
1169
  function yearSegmentAttrs(props) {
1104
- const { segmentValues, placeholder } = props;
1105
- const isEmpty = segmentValues.year === null;
1106
- const date = segmentValues.year ? placeholder.set({ year: segmentValues.year }) : placeholder;
1107
- const valueMin = 1;
1108
- const valueMax = 9999;
1109
- const valueNow = date.year;
1110
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1111
- return {
1112
- ...commonSegmentAttrs(props),
1113
- 'aria-label': 'year, ',
1114
- 'aria-valuemin': valueMin,
1115
- 'aria-valuemax': valueMax,
1116
- 'aria-valuenow': valueNow,
1117
- 'aria-valuetext': valueText,
1118
- 'data-placeholder': isEmpty ? '' : undefined
1119
- };
1170
+ return numericSegmentAttrs(props, { field: 'year', label: 'year, ', valueMin: 1, valueMax: 9999 });
1120
1171
  }
1121
1172
  function hourSegmentAttrs(props) {
1122
- const { segmentValues, hourCycle, placeholder } = props;
1123
- if (!('hour' in segmentValues) || !('hour' in placeholder))
1124
- return {};
1125
- const isEmpty = segmentValues.hour === null;
1126
- const date = segmentValues.hour ? placeholder.set({ hour: segmentValues.hour }) : placeholder;
1127
- const valueMin = hourCycle === 12 ? 1 : 0;
1128
- const valueMax = hourCycle === 12 ? 12 : 23;
1129
- const valueNow = date.hour;
1130
- const valueText = isEmpty ? 'Empty' : `${valueNow} ${segmentValues.dayPeriod ?? ''}`;
1131
- return {
1132
- ...commonSegmentAttrs(props),
1133
- 'aria-label': 'hour, ',
1134
- 'aria-valuemin': valueMin,
1135
- 'aria-valuemax': valueMax,
1136
- 'aria-valuenow': valueNow,
1137
- 'aria-valuetext': valueText,
1138
- 'data-placeholder': isEmpty ? '' : undefined
1139
- };
1173
+ const is12h = props.hourCycle === 12;
1174
+ return numericSegmentAttrs(props, {
1175
+ field: 'hour',
1176
+ label: 'hour, ',
1177
+ valueMin: is12h ? 1 : 0,
1178
+ valueMax: is12h ? 12 : 23,
1179
+ valueText: (date) => `${date.hour} ${props.segmentValues.dayPeriod ?? ''}`,
1180
+ timePart: true
1181
+ });
1140
1182
  }
1141
1183
  function minuteSegmentAttrs(props) {
1142
- const { segmentValues, placeholder } = props;
1143
- if (!('minute' in segmentValues) || !('minute' in placeholder))
1144
- return {};
1145
- const isEmpty = segmentValues.minute === null;
1146
- const date = segmentValues.minute ? placeholder.set({ minute: segmentValues.minute }) : placeholder;
1147
- const valueNow = date.minute;
1148
- const valueMin = 0;
1149
- const valueMax = 59;
1150
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1151
- return {
1152
- ...commonSegmentAttrs(props),
1153
- 'aria-label': 'minute, ',
1154
- 'aria-valuemin': valueMin,
1155
- 'aria-valuemax': valueMax,
1156
- 'aria-valuenow': valueNow,
1157
- 'aria-valuetext': valueText,
1158
- 'data-placeholder': isEmpty ? '' : undefined
1159
- };
1184
+ return numericSegmentAttrs(props, {
1185
+ field: 'minute',
1186
+ label: 'minute, ',
1187
+ valueMin: 0,
1188
+ valueMax: 59,
1189
+ timePart: true
1190
+ });
1160
1191
  }
1161
1192
  function secondSegmentAttrs(props) {
1162
- const { segmentValues, placeholder } = props;
1163
- if (!('second' in segmentValues) || !('second' in placeholder))
1164
- return {};
1165
- const isEmpty = segmentValues.second === null;
1166
- const date = segmentValues.second ? placeholder.set({ second: segmentValues.second }) : placeholder;
1167
- const valueNow = date.second;
1168
- const valueMin = 0;
1169
- const valueMax = 59;
1170
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1171
- return {
1172
- ...commonSegmentAttrs(props),
1173
- 'aria-label': 'second, ',
1174
- 'aria-valuemin': valueMin,
1175
- 'aria-valuemax': valueMax,
1176
- 'aria-valuenow': valueNow,
1177
- 'aria-valuetext': valueText,
1178
- 'data-placeholder': isEmpty ? '' : undefined
1179
- };
1193
+ return numericSegmentAttrs(props, {
1194
+ field: 'second',
1195
+ label: 'second, ',
1196
+ valueMin: 0,
1197
+ valueMax: 59,
1198
+ timePart: true
1199
+ });
1180
1200
  }
1181
1201
  function dayPeriodSegmentAttrs(props) {
1182
1202
  const { segmentValues } = props;
@@ -1184,7 +1204,7 @@ function dayPeriodSegmentAttrs(props) {
1184
1204
  return {};
1185
1205
  const valueMin = 0;
1186
1206
  const valueMax = 12;
1187
- const valueNow = segmentValues.hour ? (segmentValues.hour > 12 ? segmentValues.hour - 12 : segmentValues.hour) : 0;
1207
+ const valueNow = segmentValues.hour != null ? (segmentValues.hour > 12 ? segmentValues.hour - 12 : segmentValues.hour) : 0;
1188
1208
  const valueText = segmentValues.dayPeriod ?? 'AM';
1189
1209
  return {
1190
1210
  ...commonSegmentAttrs(props),
@@ -1296,76 +1316,54 @@ function useDateField(props) {
1296
1316
  .cycle(...cycleArgs)[part];
1297
1317
  return dateRef.set({ [part]: prevValue }).cycle(...cycleArgs)[part];
1298
1318
  }
1299
- function updateDayOrMonth(max, num, prev) {
1319
+ /**
1320
+ * Shared two-digit entry state machine for the numeric segments (day, month, hour,
1321
+ * minute, second). Types one digit into `prev`, deciding the new value and whether focus
1322
+ * should advance to the next segment.
1323
+ *
1324
+ * @param emptyZero what a leading `0` produces on an empty segment — `0` for time parts
1325
+ * (midnight/`:00` are valid), `null` for day/month (no zeroth day/month).
1326
+ * @param moveOnOverflow also advance focus when the two-digit total exceeds `max`
1327
+ * (day/month behavior; time parts only advance on `num > maxStart`).
1328
+ */
1329
+ function updateNumberSegment(num, prev, max, { emptyZero = true, moveOnOverflow = false } = {}) {
1300
1330
  let moveToNext = false;
1301
1331
  const maxStart = Math.floor(max / 10);
1302
- /**
1303
- * If the user has left the segment, we want to reset the
1304
- * `prev` value so that we can start the segment over again
1305
- * when the user types a number.
1306
- */
1332
+ // If the user has left the segment, reset `prev` so typing restarts the segment.
1307
1333
  if (props.hasLeftFocus()) {
1308
1334
  props.hasLeftFocus.set(false);
1309
1335
  prev = null;
1310
1336
  }
1311
1337
  if (prev === null) {
1312
- /**
1313
- * If the user types a 0 as the first number, we want
1314
- * to keep track of that so that when they type the next
1315
- * number, we can move to the next segment.
1316
- */
1338
+ // A leading 0 is tracked so the next digit can advance to the next segment.
1317
1339
  if (num === 0) {
1318
1340
  props.lastKeyZero.set(true);
1319
- return { value: null, moveToNext };
1341
+ return { value: emptyZero ? 0 : null, moveToNext };
1320
1342
  }
1321
- /**
1322
- * If the last key was a 0, or if the first number is
1323
- * greater than the max start digit (0-3 in most cases), then
1324
- * we want to move to the next segment, since it's not possible
1325
- * to continue typing a valid number in this segment.
1326
- */
1343
+ // If the last key was 0, or the first digit can't start a valid two-digit number
1344
+ // (> the max start digit), advance to the next segment.
1327
1345
  if (props.lastKeyZero() || num > maxStart) {
1328
- // move to next
1329
1346
  moveToNext = true;
1330
1347
  }
1331
1348
  props.lastKeyZero.set(false);
1332
- /**
1333
- * If none of the above conditions are met, then we can just
1334
- * return the number as the segment value and continue typing
1335
- * in this segment.
1336
- */
1337
1349
  return { value: num, moveToNext };
1338
1350
  }
1339
- /**
1340
- * If the number of digits is 2, or if the total with the existing digit
1341
- * and the pressed digit is greater than the maximum value for this
1342
- * month, then we will reset the segment as if the user had pressed the
1343
- * backspace key and then typed the number.
1344
- */
1351
+ // Either the segment already holds two digits, or appending this digit overflows `max`:
1352
+ // reset the segment as if backspaced and then typed.
1345
1353
  const digits = prev.toString().length;
1346
1354
  const total = Number.parseInt(prev.toString() + num.toString());
1347
- /**
1348
- * If the number of digits is 2, or if the total with the existing digit
1349
- * and the pressed digit is greater than the maximum value for this
1350
- * month, then we will reset the segment as if the user had pressed the
1351
- * backspace key and then typed the number.
1352
- */
1353
1355
  if (digits === 2 || total > max) {
1354
- /**
1355
- * As we're doing elsewhere, we're checking if the number is greater
1356
- * than the max start digit (0-3 in most months), and if so, we're
1357
- * going to move to the next segment.
1358
- */
1359
- if (num > maxStart || total > max) {
1360
- // move to next
1356
+ if (num > maxStart || (moveOnOverflow && total > max)) {
1361
1357
  moveToNext = true;
1362
1358
  }
1363
1359
  return { value: num, moveToNext };
1364
1360
  }
1365
- // move to next
1366
1361
  moveToNext = true;
1367
1362
  return { value: total, moveToNext };
1368
1363
  }
1364
+ function updateDayOrMonth(max, num, prev) {
1365
+ return updateNumberSegment(num, prev, max, { emptyZero: false, moveOnOverflow: true });
1366
+ }
1369
1367
  function updateYear(num, prev) {
1370
1368
  let moveToNext = false;
1371
1369
  /**
@@ -1388,148 +1386,11 @@ function useDateField(props) {
1388
1386
  const int = Number.parseInt(str);
1389
1387
  return { value: int, moveToNext };
1390
1388
  }
1391
- function updateHour(num, prev) {
1392
- const max = 24;
1393
- let moveToNext = false;
1394
- const maxStart = Math.floor(max / 10);
1395
- /**
1396
- * If the user has left the segment, we want to reset the
1397
- * `prev` value so that we can start the segment over again
1398
- * when the user types a number.
1399
- */
1400
- // probably not implement, kind of weird
1401
- if (props.hasLeftFocus()) {
1402
- props.hasLeftFocus.set(false);
1403
- prev = null;
1404
- }
1405
- if (prev === null) {
1406
- /**
1407
- * If the user types a 0 as the first number, we want
1408
- * to keep track of that so that when they type the next
1409
- * number, we can move to the next segment.
1410
- */
1411
- if (num === 0) {
1412
- props.lastKeyZero.set(true);
1413
- return { value: 0, moveToNext };
1414
- }
1415
- /**
1416
- * If the last key was a 0, or if the first number is
1417
- * greater than the max start digit (0-3 in most cases), then
1418
- * we want to move to the next segment, since it's not possible
1419
- * to continue typing a valid number in this segment.
1420
- */
1421
- if (props.lastKeyZero() || num > maxStart) {
1422
- // move to next
1423
- moveToNext = true;
1424
- }
1425
- props.lastKeyZero.set(false);
1426
- /**
1427
- * If none of the above conditions are met, then we can just
1428
- * return the number as the segment value and continue typing
1429
- * in this segment.
1430
- */
1431
- return { value: num, moveToNext };
1432
- }
1433
- /**
1434
- * If the number of digits is 2, or if the total with the existing digit
1435
- * and the pressed digit is greater than the maximum value for this
1436
- * month, then we will reset the segment as if the user had pressed the
1437
- * backspace key and then typed the number.
1438
- */
1439
- const digits = prev.toString().length;
1440
- const total = Number.parseInt(prev.toString() + num.toString());
1441
- /**
1442
- * If the number of digits is 2, or if the total with the existing digit
1443
- * and the pressed digit is greater than the maximum value for this
1444
- * month, then we will reset the segment as if the user had pressed the
1445
- * backspace key and then typed the number.
1446
- */
1447
- if (digits === 2 || total > max) {
1448
- /**
1449
- * As we're doing elsewhere, we're checking if the number is greater
1450
- * than the max start digit (0-3 in most months), and if so, we're
1451
- * going to move to the next segment.
1452
- */
1453
- if (num > maxStart) {
1454
- // move to next
1455
- moveToNext = true;
1456
- }
1457
- return { value: num, moveToNext };
1458
- }
1459
- // move to next
1460
- moveToNext = true;
1461
- return { value: total, moveToNext };
1389
+ function updateHour(num, prev, max) {
1390
+ return updateNumberSegment(num, prev, max);
1462
1391
  }
1463
1392
  function updateMinuteOrSecond(num, prev) {
1464
- const max = 59;
1465
- let moveToNext = false;
1466
- const maxStart = Math.floor(max / 10);
1467
- /**
1468
- * If the user has left the segment, we want to reset the
1469
- * `prev` value so that we can start the segment over again
1470
- * when the user types a number.
1471
- */
1472
- if (props.hasLeftFocus()) {
1473
- props.hasLeftFocus.set(false);
1474
- prev = null;
1475
- }
1476
- if (prev === null) {
1477
- /**
1478
- * If the user types a 0 as the first number, we want
1479
- * to keep track of that so that when they type the next
1480
- * number, we can move to the next segment.
1481
- */
1482
- if (num === 0) {
1483
- props.lastKeyZero.set(true);
1484
- return { value: 0, moveToNext };
1485
- }
1486
- /**
1487
- * If the last key was a 0, or if the first number is
1488
- * greater than the max start digit (0-3 in most cases), then
1489
- * we want to move to the next segment, since it's not possible
1490
- * to continue typing a valid number in this segment.
1491
- */
1492
- if (props.lastKeyZero() || num > maxStart) {
1493
- // move to next
1494
- moveToNext = true;
1495
- }
1496
- props.lastKeyZero.set(false);
1497
- /**
1498
- * If none of the above conditions are met, then we can just
1499
- * return the number as the segment value and continue typing
1500
- * in this segment.
1501
- */
1502
- return { value: num, moveToNext };
1503
- }
1504
- /**
1505
- * If the number of digits is 2, or if the total with the existing digit
1506
- * and the pressed digit is greater than the maximum value for this
1507
- * month, then we will reset the segment as if the user had pressed the
1508
- * backspace key and then typed the number.
1509
- */
1510
- const digits = prev.toString().length;
1511
- const total = Number.parseInt(prev.toString() + num.toString());
1512
- /**
1513
- * If the number of digits is 2, or if the total with the existing digit
1514
- * and the pressed digit is greater than the maximum value for this
1515
- * month, then we will reset the segment as if the user had pressed the
1516
- * backspace key and then typed the number.
1517
- */
1518
- if (digits === 2 || total > max) {
1519
- /**
1520
- * As we're doing elsewhere, we're checking if the number is greater
1521
- * than the max start digit (0-3 in most months), and if so, we're
1522
- * going to move to the next segment.
1523
- */
1524
- if (num > maxStart) {
1525
- // move to next
1526
- moveToNext = true;
1527
- }
1528
- return { value: num, moveToNext };
1529
- }
1530
- // move to next
1531
- moveToNext = true;
1532
- return { value: total, moveToNext };
1393
+ return updateNumberSegment(num, prev, 59);
1533
1394
  }
1534
1395
  function minuteSecondIncrementation({ e, part, dateRef, prevValue }) {
1535
1396
  const step = props.step()[part] ?? 1;
@@ -1657,22 +1518,24 @@ function useDateField(props) {
1657
1518
  hourCycle
1658
1519
  })
1659
1520
  }));
1660
- if ('dayPeriod' in props.segmentValues() && values.hour != null) {
1661
- if (values.hour < 12)
1662
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1663
- else if (values.hour)
1664
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1521
+ // Keep the day period in sync with the (just-updated) 24-hour value.
1522
+ const updatedHour = props.segmentValues().hour;
1523
+ if ('dayPeriod' in props.segmentValues() && updatedHour != null) {
1524
+ props.segmentValues.update((prev) => ({ ...prev, dayPeriod: dayPeriodForHour(updatedHour) }));
1665
1525
  }
1666
1526
  return;
1667
1527
  }
1668
1528
  if (isNumberString(e.key)) {
1669
1529
  const num = Number.parseInt(e.key);
1670
- const { value, moveToNext } = updateHour(num, prevValue);
1671
- if ('dayPeriod' in props.segmentValues() && value && value > 12)
1672
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1673
- else if ('dayPeriod' in props.segmentValues() && value)
1674
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1675
- props.segmentValues.update((prev) => ({ ...prev, hour: value }));
1530
+ const is12h = hourCycle === 12;
1531
+ const period = values.dayPeriod ?? 'AM';
1532
+ // Run the two-digit entry machine in the user-visible clock space (1-12 in 12h
1533
+ // mode, 0-23 otherwise), then store the canonical 24-hour value. The day period
1534
+ // is owned by its own segment, so typing the hour must not flip it.
1535
+ const prevDisplay = prevValue === null ? null : is12h ? to12Hour(prevValue) : prevValue;
1536
+ const { value, moveToNext } = updateHour(num, prevDisplay, is12h ? 12 : 23);
1537
+ const hour = value === null ? null : is12h ? to24Hour(value, period) : value;
1538
+ props.segmentValues.update((prev) => ({ ...prev, hour }));
1676
1539
  if (moveToNext)
1677
1540
  props.focusNext();
1678
1541
  }
@@ -1681,68 +1544,26 @@ function useDateField(props) {
1681
1544
  props.segmentValues.update((prev) => ({ ...prev, hour: deleteValue(prevValue) }));
1682
1545
  }
1683
1546
  }
1684
- function handleMinuteSegmentKeydown(e) {
1685
- const dateRef = props.placeholder();
1686
- const values = props.segmentValues();
1687
- if (!isAcceptableSegmentKey(e.key) ||
1688
- isSegmentNavigationKey(e.key) ||
1689
- !('minute' in dateRef) ||
1690
- !('minute' in values))
1691
- return;
1692
- const prevValue = values.minute;
1693
- if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1694
- props.segmentValues.update((prev) => ({
1695
- ...prev,
1696
- minute: minuteSecondIncrementation({
1697
- e,
1698
- part: 'minute',
1699
- dateRef: props.placeholder(),
1700
- prevValue
1701
- })
1702
- }));
1703
- }
1704
- if (isNumberString(e.key)) {
1705
- const num = Number.parseInt(e.key);
1706
- const { value, moveToNext } = updateMinuteOrSecond(num, prevValue);
1707
- props.segmentValues.update((prev) => ({ ...prev, minute: value }));
1708
- if (moveToNext)
1709
- props.focusNext();
1710
- }
1711
- if (e.key === BACKSPACE) {
1712
- props.hasLeftFocus.set(false);
1713
- props.segmentValues.update((prev) => ({ ...prev, minute: deleteValue(prevValue) }));
1714
- }
1715
- }
1716
- function handleSecondSegmentKeydown(e) {
1547
+ // Minute and second segments behave identically; only the field differs.
1548
+ function handleMinuteOrSecondSegmentKeydown(e, part) {
1717
1549
  const dateRef = props.placeholder();
1718
1550
  const values = props.segmentValues();
1719
- if (!isAcceptableSegmentKey(e.key) ||
1720
- isSegmentNavigationKey(e.key) ||
1721
- !('second' in dateRef) ||
1722
- !('second' in values))
1551
+ if (!isAcceptableSegmentKey(e.key) || isSegmentNavigationKey(e.key) || !(part in dateRef) || !(part in values))
1723
1552
  return;
1724
- const prevValue = values.second;
1553
+ const prevValue = values[part];
1725
1554
  if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1726
- props.segmentValues.update((prev) => ({
1727
- ...prev,
1728
- second: minuteSecondIncrementation({
1729
- e,
1730
- part: 'second',
1731
- dateRef: props.placeholder(),
1732
- prevValue
1733
- })
1734
- }));
1555
+ const next = minuteSecondIncrementation({ e, part, dateRef: props.placeholder(), prevValue });
1556
+ props.segmentValues.update((prev) => ({ ...prev, [part]: next }));
1735
1557
  }
1736
1558
  if (isNumberString(e.key)) {
1737
- const num = Number.parseInt(e.key);
1738
- const { value, moveToNext } = updateMinuteOrSecond(num, prevValue);
1739
- props.segmentValues.update((prev) => ({ ...prev, second: value }));
1559
+ const { value, moveToNext } = updateMinuteOrSecond(Number.parseInt(e.key), prevValue);
1560
+ props.segmentValues.update((prev) => ({ ...prev, [part]: value }));
1740
1561
  if (moveToNext)
1741
1562
  props.focusNext();
1742
1563
  }
1743
1564
  if (e.key === BACKSPACE) {
1744
1565
  props.hasLeftFocus.set(false);
1745
- props.segmentValues.update((prev) => ({ ...prev, second: deleteValue(prevValue) }));
1566
+ props.segmentValues.update((prev) => ({ ...prev, [part]: deleteValue(prevValue) }));
1746
1567
  }
1747
1568
  }
1748
1569
  function handleDayPeriodSegmentKeydown(e) {
@@ -1751,24 +1572,24 @@ function useDateField(props) {
1751
1572
  !('dayPeriod' in props.segmentValues()))
1752
1573
  return;
1753
1574
  const values = props.segmentValues();
1754
- if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1755
- if (values.dayPeriod === 'AM') {
1756
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1757
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour + 12 }));
1575
+ const setPeriod = (period) => {
1576
+ if (values.dayPeriod === period)
1758
1577
  return;
1759
- }
1760
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1761
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour - 12 }));
1578
+ // Re-anchor the canonical 24-hour value to the new period without ever leaving
1579
+ // range; keep it null while the hour segment is still empty.
1580
+ const hour = values.hour == null ? null : to24Hour(to12Hour(values.hour), period);
1581
+ props.segmentValues.update((prev) => ({ ...prev, dayPeriod: period, hour }));
1582
+ };
1583
+ if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1584
+ setPeriod(values.dayPeriod === 'AM' ? 'PM' : 'AM');
1762
1585
  return;
1763
1586
  }
1764
- if (['a', 'A'].includes(e.key) && values.dayPeriod !== 'AM') {
1765
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1766
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour - 12 }));
1587
+ if (['a', 'A'].includes(e.key)) {
1588
+ setPeriod('AM');
1767
1589
  return;
1768
1590
  }
1769
- if (['p', 'P'].includes(e.key) && values.dayPeriod !== 'PM') {
1770
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1771
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour + 12 }));
1591
+ if (['p', 'P'].includes(e.key)) {
1592
+ setPeriod('PM');
1772
1593
  }
1773
1594
  }
1774
1595
  function handleSegmentKeydown(e) {
@@ -1783,8 +1604,8 @@ function useDateField(props) {
1783
1604
  day: handleDaySegmentKeydown,
1784
1605
  year: handleYearSegmentKeydown,
1785
1606
  hour: handleHourSegmentKeydown,
1786
- minute: handleMinuteSegmentKeydown,
1787
- second: handleSecondSegmentKeydown,
1607
+ minute: (e) => handleMinuteOrSecondSegmentKeydown(e, 'minute'),
1608
+ second: (e) => handleMinuteOrSecondSegmentKeydown(e, 'second'),
1788
1609
  dayPeriod: handleDayPeriodSegmentKeydown,
1789
1610
  timeZoneName: () => { }
1790
1611
  };
@@ -1868,84 +1689,6 @@ function injectId(prefix) {
1868
1689
  return inject(RdxIdGenerator).getId(prefix);
1869
1690
  }
1870
1691
 
1871
- /**
1872
- * Announces messages to screen readers through an `aria-live` region, without moving focus.
1873
- *
1874
- * Own replacement for CDK's `LiveAnnouncer` — lazily appends a visually hidden live region to
1875
- * the document body and writes messages into it. No-op on the server.
1876
- */
1877
- class RdxLiveAnnouncer {
1878
- constructor() {
1879
- this.document = inject(DOCUMENT);
1880
- this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1881
- this.liveElement = null;
1882
- inject(DestroyRef).onDestroy(() => {
1883
- clearTimeout(this.previousTimeout);
1884
- this.liveElement?.remove();
1885
- this.liveElement = null;
1886
- });
1887
- }
1888
- /**
1889
- * Announces a message to screen readers.
1890
- *
1891
- * @param message The message to announce.
1892
- * @param politeness The politeness of the announcer element (defaults to `'polite'`).
1893
- * @param duration If provided, the message is cleared after this many milliseconds.
1894
- */
1895
- announce(message, politeness = 'polite', duration) {
1896
- if (!this.isBrowser) {
1897
- return;
1898
- }
1899
- const liveElement = this.getLiveElement();
1900
- clearTimeout(this.previousTimeout);
1901
- liveElement.setAttribute('aria-live', politeness);
1902
- // Clear the live element first, then set the message after a tick so that screen
1903
- // readers reliably pick up the change even when the text is identical.
1904
- liveElement.textContent = '';
1905
- this.previousTimeout = setTimeout(() => {
1906
- liveElement.textContent = message;
1907
- if (typeof duration === 'number') {
1908
- this.previousTimeout = setTimeout(() => (liveElement.textContent = ''), duration);
1909
- }
1910
- });
1911
- }
1912
- /** Clears the current announcement. */
1913
- clear() {
1914
- if (this.liveElement) {
1915
- this.liveElement.textContent = '';
1916
- }
1917
- }
1918
- getLiveElement() {
1919
- if (this.liveElement) {
1920
- return this.liveElement;
1921
- }
1922
- const element = this.document.createElement('div');
1923
- element.classList.add('rdx-live-announcer');
1924
- element.setAttribute('aria-atomic', 'true');
1925
- element.setAttribute('aria-live', 'polite');
1926
- // Visually hide the region while keeping it available to assistive technology.
1927
- element.style.position = 'absolute';
1928
- element.style.width = '1px';
1929
- element.style.height = '1px';
1930
- element.style.margin = '-1px';
1931
- element.style.padding = '0';
1932
- element.style.border = '0';
1933
- element.style.overflow = 'hidden';
1934
- element.style.clip = 'rect(0 0 0 0)';
1935
- element.style.clipPath = 'inset(100%)';
1936
- element.style.whiteSpace = 'nowrap';
1937
- this.document.body.appendChild(element);
1938
- this.liveElement = element;
1939
- return element;
1940
- }
1941
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1942
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, providedIn: 'root' }); }
1943
- }
1944
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, decorators: [{
1945
- type: Injectable,
1946
- args: [{ providedIn: 'root' }]
1947
- }], ctorParameters: () => [] });
1948
-
1949
1692
  /** Narrows to `null | undefined`. */
1950
1693
  function isNullish(value) {
1951
1694
  return value === null || value === undefined;
@@ -2024,6 +1767,127 @@ function equals(a, b, seen) {
2024
1767
  }
2025
1768
  }
2026
1769
 
1770
+ /**
1771
+ * Converts an item value to the string shown to the user.
1772
+ *
1773
+ * Strings pass through unchanged; `null`/`undefined` become an empty string; everything else is
1774
+ * coerced with `String()`. Primitives that hold object values (e.g. combobox) typically pass their
1775
+ * own `itemToStringLabel` to render a field off the object instead.
1776
+ */
1777
+ function itemToStringLabel(value) {
1778
+ if (typeof value === 'string') {
1779
+ return value;
1780
+ }
1781
+ if (isNullish(value)) {
1782
+ return '';
1783
+ }
1784
+ return String(value);
1785
+ }
1786
+ /**
1787
+ * Converts an item value to the string used for form serialization. Defaults to the same rules as
1788
+ * {@link itemToStringLabel}; kept as a separate export so a primitive can diverge label vs. value.
1789
+ */
1790
+ function itemToStringValue(value) {
1791
+ return itemToStringLabel(value);
1792
+ }
1793
+ /**
1794
+ * Compares two item values for equality using an optional {@link ItemValueComparator}.
1795
+ *
1796
+ * @example
1797
+ * isItemEqualToValue({ id: 1 }, { id: 1 }, 'id'); // true — compares the `id` key
1798
+ * isItemEqualToValue({ id: 1 }, { id: 1 }); // true — deep equality fallback
1799
+ */
1800
+ function isItemEqualToValue(a, b, comparator) {
1801
+ if (typeof comparator === 'function') {
1802
+ return comparator(a, b);
1803
+ }
1804
+ if (typeof comparator === 'string') {
1805
+ if (isNullish(a) || isNullish(b)) {
1806
+ return a === b;
1807
+ }
1808
+ return isEqual(a[comparator], b[comparator]);
1809
+ }
1810
+ return isEqual(a, b);
1811
+ }
1812
+
1813
+ /**
1814
+ * Announces messages to screen readers through an `aria-live` region, without moving focus.
1815
+ *
1816
+ * Own replacement for CDK's `LiveAnnouncer` — lazily appends a visually hidden live region to
1817
+ * the document body and writes messages into it. No-op on the server.
1818
+ */
1819
+ class RdxLiveAnnouncer {
1820
+ constructor() {
1821
+ this.document = inject(DOCUMENT);
1822
+ this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1823
+ this.liveElement = null;
1824
+ inject(DestroyRef).onDestroy(() => {
1825
+ clearTimeout(this.previousTimeout);
1826
+ this.liveElement?.remove();
1827
+ this.liveElement = null;
1828
+ });
1829
+ }
1830
+ /**
1831
+ * Announces a message to screen readers.
1832
+ *
1833
+ * @param message The message to announce.
1834
+ * @param politeness The politeness of the announcer element (defaults to `'polite'`).
1835
+ * @param duration If provided, the message is cleared after this many milliseconds.
1836
+ */
1837
+ announce(message, politeness = 'polite', duration) {
1838
+ if (!this.isBrowser) {
1839
+ return;
1840
+ }
1841
+ const liveElement = this.getLiveElement();
1842
+ clearTimeout(this.previousTimeout);
1843
+ liveElement.setAttribute('aria-live', politeness);
1844
+ // Clear the live element first, then set the message after a tick so that screen
1845
+ // readers reliably pick up the change even when the text is identical.
1846
+ liveElement.textContent = '';
1847
+ this.previousTimeout = setTimeout(() => {
1848
+ liveElement.textContent = message;
1849
+ if (typeof duration === 'number') {
1850
+ this.previousTimeout = setTimeout(() => (liveElement.textContent = ''), duration);
1851
+ }
1852
+ });
1853
+ }
1854
+ /** Clears the current announcement. */
1855
+ clear() {
1856
+ if (this.liveElement) {
1857
+ this.liveElement.textContent = '';
1858
+ }
1859
+ }
1860
+ getLiveElement() {
1861
+ if (this.liveElement) {
1862
+ return this.liveElement;
1863
+ }
1864
+ const element = this.document.createElement('div');
1865
+ element.classList.add('rdx-live-announcer');
1866
+ element.setAttribute('aria-atomic', 'true');
1867
+ element.setAttribute('aria-live', 'polite');
1868
+ // Visually hide the region while keeping it available to assistive technology.
1869
+ element.style.position = 'absolute';
1870
+ element.style.width = '1px';
1871
+ element.style.height = '1px';
1872
+ element.style.margin = '-1px';
1873
+ element.style.padding = '0';
1874
+ element.style.border = '0';
1875
+ element.style.overflow = 'hidden';
1876
+ element.style.clip = 'rect(0 0 0 0)';
1877
+ element.style.clipPath = 'inset(100%)';
1878
+ element.style.whiteSpace = 'nowrap';
1879
+ this.document.body.appendChild(element);
1880
+ this.liveElement = element;
1881
+ return element;
1882
+ }
1883
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1884
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, providedIn: 'root' }); }
1885
+ }
1886
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, decorators: [{
1887
+ type: Injectable,
1888
+ args: [{ providedIn: 'root' }]
1889
+ }], ctorParameters: () => [] });
1890
+
2027
1891
  /**
2028
1892
  * Creates an Angular provider that binds the given token to the existing instance
2029
1893
  * of the specified class. This is especially useful when you want multiple
@@ -2119,18 +1983,24 @@ function resizeEffect(options) {
2119
1983
  }
2120
1984
 
2121
1985
  /**
2122
- * Process-wide ownership of `document.body`'s overflow while one or more overlays lock scrolling.
1986
+ * Process-wide ownership of the document scroller's overflow while one or more overlays lock
1987
+ * scrolling.
2123
1988
  *
2124
1989
  * A single shared counter across every primitive that locks scroll is essential: with separate
2125
1990
  * per-primitive counters, a popover and a dialog open at the same time would each capture the
2126
- * other's already-locked `overflow: hidden` as the "original" value and restore it on close,
2127
- * leaving the page permanently unscrollable.
1991
+ * other's already-locked state as the "original" and restore it on close, leaving the page
1992
+ * permanently unscrollable.
2128
1993
  */
2129
- let originalBodyOverflow = null;
1994
+ let original = null;
2130
1995
  let scrollLockCount = 0;
2131
1996
  /**
2132
- * Locks `document.body` scrolling while `active()` is `true`, and restores the original overflow
2133
- * when it becomes `false` or the calling context is destroyed.
1997
+ * Locks page scrolling while `active()` is `true`, and restores the original state when it becomes
1998
+ * `false` or the calling context is destroyed.
1999
+ *
2000
+ * Locks **both** `<body>` and `<html>`: a `body { overflow: hidden }` lock alone does *not* stop the
2001
+ * page when `<html>` is the scroller (e.g. a global `overflow-y: scroll`, as Storybook sets), because
2002
+ * body-overflow only propagates to the viewport when `<html>`'s overflow is `visible`. The width of
2003
+ * the removed scrollbar is added as `padding-right` on `<html>` so the page doesn't shift.
2134
2004
  *
2135
2005
  * Lock ownership is shared across all callers via a single module-level counter, so nested or
2136
2006
  * concurrent overlays compose correctly. Must be called in an injection context.
@@ -2142,10 +2012,22 @@ function useScrollLock(active) {
2142
2012
  if (isLocked) {
2143
2013
  return;
2144
2014
  }
2145
- const body = document.body;
2146
2015
  if (scrollLockCount === 0) {
2147
- originalBodyOverflow = body.style.overflow;
2016
+ const html = document.documentElement;
2017
+ const body = document.body;
2018
+ const win = document.defaultView;
2019
+ const scrollbarWidth = win ? Math.max(0, win.innerWidth - html.clientWidth) : 0;
2020
+ original = {
2021
+ bodyOverflow: body.style.overflow,
2022
+ htmlOverflow: html.style.overflow,
2023
+ htmlPaddingRight: html.style.paddingRight
2024
+ };
2148
2025
  body.style.overflow = 'hidden';
2026
+ html.style.overflow = 'hidden';
2027
+ if (scrollbarWidth > 0) {
2028
+ const currentPadding = win ? parseFloat(win.getComputedStyle(html).paddingRight) || 0 : 0;
2029
+ html.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
2030
+ }
2149
2031
  }
2150
2032
  scrollLockCount++;
2151
2033
  isLocked = true;
@@ -2156,9 +2038,12 @@ function useScrollLock(active) {
2156
2038
  }
2157
2039
  scrollLockCount--;
2158
2040
  isLocked = false;
2159
- if (scrollLockCount === 0 && originalBodyOverflow !== null) {
2160
- document.body.style.overflow = originalBodyOverflow;
2161
- originalBodyOverflow = null;
2041
+ if (scrollLockCount === 0 && original !== null) {
2042
+ const html = document.documentElement;
2043
+ document.body.style.overflow = original.bodyOverflow;
2044
+ html.style.overflow = original.htmlOverflow;
2045
+ html.style.paddingRight = original.htmlPaddingRight;
2046
+ original = null;
2162
2047
  }
2163
2048
  };
2164
2049
  effect(() => {
@@ -2256,6 +2141,56 @@ function findNextFocusableElement(elements, currentElement, options, iterations
2256
2141
  return candidate;
2257
2142
  }
2258
2143
 
2144
+ /**
2145
+ * Creates locale-aware `contains` / `startsWith` / `endsWith` predicates.
2146
+ *
2147
+ * Matching uses `Intl.Collator` with `sensitivity: 'base'` and `usage: 'search'` by default, so
2148
+ * comparisons ignore case and diacritics. An empty (or whitespace-only) `query` matches everything,
2149
+ * which is the natural "no filter applied" state for a combobox.
2150
+ *
2151
+ * @example
2152
+ * const { contains } = useFilter();
2153
+ * contains('Äpfel', 'ap'); // true
2154
+ */
2155
+ function useFilter(options) {
2156
+ const { locale, ...collatorOptions } = options ?? {};
2157
+ const collator = new Intl.Collator(locale, {
2158
+ usage: 'search',
2159
+ sensitivity: 'base',
2160
+ ...collatorOptions
2161
+ });
2162
+ // `Intl.Collator` only compares whole strings, so we scan every substring window of `text`
2163
+ // the same length as `query` and treat a collator match as a hit. This keeps the locale rules
2164
+ // (case/diacritic folding) consistent with `===`-style equality the collator provides.
2165
+ const matchesAt = (text, query, start) => collator.compare(text.slice(start, start + query.length), query) === 0;
2166
+ const isEmpty = (query) => query.length === 0;
2167
+ return {
2168
+ contains(text, query) {
2169
+ if (isEmpty(query)) {
2170
+ return true;
2171
+ }
2172
+ for (let i = 0; i + query.length <= text.length; i++) {
2173
+ if (matchesAt(text, query, i)) {
2174
+ return true;
2175
+ }
2176
+ }
2177
+ return false;
2178
+ },
2179
+ startsWith(text, query) {
2180
+ if (isEmpty(query)) {
2181
+ return true;
2182
+ }
2183
+ return query.length <= text.length && matchesAt(text, query, 0);
2184
+ },
2185
+ endsWith(text, query) {
2186
+ if (isEmpty(query)) {
2187
+ return true;
2188
+ }
2189
+ return query.length <= text.length && matchesAt(text, query, text.length - query.length);
2190
+ }
2191
+ };
2192
+ }
2193
+
2259
2194
  const graceAreaContainers = new WeakMap();
2260
2195
  function createSignalEvent() {
2261
2196
  const handlers = new Set();
@@ -2468,6 +2403,192 @@ function getHullPresorted(points) {
2468
2403
  return upper.concat(lower);
2469
2404
  }
2470
2405
 
2406
+ /**
2407
+ * Highlight-model list navigation over a set of items, decoupled from DOM focus.
2408
+ *
2409
+ * Unlike roving `tabindex`, the highlight is pure state: callers move it with the keyboard while DOM
2410
+ * focus stays on a single controlling element (e.g. a combobox `<input>`), which exposes
2411
+ * {@link ListHighlight.activeId} as `aria-activedescendant`. Navigation only ever lands on items for
2412
+ * which `isNavigable` returns `true`, so hidden (filtered-out) and disabled items are skipped. A
2413
+ * self-healing effect clears the highlight if its item stops being navigable or leaves the list, so
2414
+ * `activeId` never references a detached or hidden element.
2415
+ *
2416
+ * Must be called in an injection context, or given an `injector`.
2417
+ */
2418
+ function useListHighlight(options) {
2419
+ const { items, isNavigable, getId, loop, injector } = options;
2420
+ const highlighted = signal(null, ...(ngDevMode ? [{ debugName: "highlighted" }] : /* istanbul ignore next */ []));
2421
+ const navigable = computed(() => items().filter((item) => isNavigable(item)), ...(ngDevMode ? [{ debugName: "navigable" }] : /* istanbul ignore next */ []));
2422
+ const activeId = computed(() => {
2423
+ const item = highlighted();
2424
+ return item === null ? undefined : getId(item);
2425
+ }, ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
2426
+ const setIfNavigable = (item) => {
2427
+ if (item === null || isNavigable(item)) {
2428
+ highlighted.set(item);
2429
+ }
2430
+ };
2431
+ const step = (direction) => {
2432
+ const list = navigable();
2433
+ if (list.length === 0) {
2434
+ highlighted.set(null);
2435
+ return;
2436
+ }
2437
+ const current = highlighted();
2438
+ const currentIndex = current === null ? -1 : list.indexOf(current);
2439
+ // No valid anchor → enter from the appropriate end.
2440
+ if (currentIndex === -1) {
2441
+ highlighted.set(direction === 1 ? list[0] : list[list.length - 1]);
2442
+ return;
2443
+ }
2444
+ let nextIndex = currentIndex + direction;
2445
+ const shouldLoop = loop ? loop() : true;
2446
+ if (nextIndex < 0) {
2447
+ nextIndex = shouldLoop ? list.length - 1 : 0;
2448
+ }
2449
+ else if (nextIndex >= list.length) {
2450
+ nextIndex = shouldLoop ? 0 : list.length - 1;
2451
+ }
2452
+ highlighted.set(list[nextIndex]);
2453
+ };
2454
+ // Self-heal: drop a highlight that is no longer navigable (filtered out, disabled, destroyed).
2455
+ effect(() => {
2456
+ const list = navigable();
2457
+ const current = untracked(highlighted);
2458
+ if (current !== null && !list.includes(current)) {
2459
+ highlighted.set(null);
2460
+ }
2461
+ }, injector ? { injector } : undefined);
2462
+ return {
2463
+ highlightedItem: highlighted.asReadonly(),
2464
+ activeId,
2465
+ first() {
2466
+ const list = navigable();
2467
+ highlighted.set(list.length > 0 ? list[0] : null);
2468
+ },
2469
+ last() {
2470
+ const list = navigable();
2471
+ highlighted.set(list.length > 0 ? list[list.length - 1] : null);
2472
+ },
2473
+ next() {
2474
+ step(1);
2475
+ },
2476
+ previous() {
2477
+ step(-1);
2478
+ },
2479
+ set: setIfNavigable,
2480
+ clear() {
2481
+ highlighted.set(null);
2482
+ }
2483
+ };
2484
+ }
2485
+
2486
+ /** Pointer travel (px) before a press becomes a drag — below this a press stays a click/tap. */
2487
+ const DRAG_THRESHOLD = 4;
2488
+ /**
2489
+ * Shared pointer-drag lifecycle for gesture primitives (drawer swipe, toast swipe, etc.).
2490
+ *
2491
+ * A press only becomes a drag once the pointer moves past {@link DRAG_THRESHOLD}; until then it is a
2492
+ * plain tap, so clicks on buttons inside the element keep working (the gesture never captures the
2493
+ * pointer for a tap). Once dragging, the pointer is captured so a drag that leaves the element still
2494
+ * completes, and `lostpointercapture` / `pointercancel` count as a non-committed end — a swallowed
2495
+ * `pointerup` (native context menu, OS gesture, tab switch) can never wedge the gesture. Only the
2496
+ * primary pointer is tracked, so a second finger can't start a parallel gesture. No-op outside the
2497
+ * browser, keeping SSR safe.
2498
+ *
2499
+ * `onEnd` is NOT called if the host is destroyed mid-drag — callers that pair `onStart`/`onEnd`
2500
+ * (e.g. to pause/resume timers) should balance that case in their own `DestroyRef` cleanup.
2501
+ *
2502
+ * Must be called from an injection context (a directive/component constructor).
2503
+ */
2504
+ function usePointerDrag(handlers) {
2505
+ assertInInjectionContext(usePointerDrag);
2506
+ if (!isPlatformBrowser(inject(PLATFORM_ID))) {
2507
+ return;
2508
+ }
2509
+ const host = inject(ElementRef).nativeElement;
2510
+ let pointerId = null;
2511
+ let downEvent = null;
2512
+ let dragging = false;
2513
+ const removeWindowListeners = () => {
2514
+ window.removeEventListener('pointermove', onPointerMove);
2515
+ window.removeEventListener('pointerup', onPointerUp);
2516
+ window.removeEventListener('pointercancel', onPointerUp);
2517
+ };
2518
+ const reset = (event, committed) => {
2519
+ if (pointerId === null) {
2520
+ return;
2521
+ }
2522
+ const wasDragging = dragging;
2523
+ try {
2524
+ host.releasePointerCapture?.(pointerId);
2525
+ }
2526
+ catch {
2527
+ // Capture may already be gone (e.g. on lostpointercapture); ignore.
2528
+ }
2529
+ pointerId = null;
2530
+ downEvent = null;
2531
+ dragging = false;
2532
+ removeWindowListeners();
2533
+ // A press that never crossed the threshold was a tap — leave the click untouched.
2534
+ if (wasDragging) {
2535
+ handlers.onEnd(event, committed);
2536
+ }
2537
+ };
2538
+ function onPointerMove(event) {
2539
+ if (event.pointerId !== pointerId || !downEvent) {
2540
+ return;
2541
+ }
2542
+ if (!dragging) {
2543
+ const dx = event.clientX - downEvent.clientX;
2544
+ const dy = event.clientY - downEvent.clientY;
2545
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) {
2546
+ return;
2547
+ }
2548
+ dragging = true;
2549
+ try {
2550
+ host.setPointerCapture?.(pointerId);
2551
+ }
2552
+ catch {
2553
+ // Not all environments support pointer capture; the window listeners still drive the gesture.
2554
+ }
2555
+ handlers.onStart(downEvent);
2556
+ }
2557
+ if (handlers.onMove(event) === false) {
2558
+ reset(event, false);
2559
+ }
2560
+ }
2561
+ function onPointerUp(event) {
2562
+ if (event.pointerId !== pointerId) {
2563
+ return;
2564
+ }
2565
+ reset(event, event.type === 'pointerup');
2566
+ }
2567
+ const onLostCapture = (event) => {
2568
+ if (event.pointerId === pointerId) {
2569
+ reset(event, false);
2570
+ }
2571
+ };
2572
+ const onPointerDown = (event) => {
2573
+ if (pointerId !== null || !event.isPrimary || event.button !== 0 || !handlers.canStart(event)) {
2574
+ return;
2575
+ }
2576
+ pointerId = event.pointerId;
2577
+ downEvent = event;
2578
+ dragging = false;
2579
+ window.addEventListener('pointermove', onPointerMove);
2580
+ window.addEventListener('pointerup', onPointerUp);
2581
+ window.addEventListener('pointercancel', onPointerUp);
2582
+ };
2583
+ host.addEventListener('pointerdown', onPointerDown);
2584
+ host.addEventListener('lostpointercapture', onLostCapture);
2585
+ inject(DestroyRef).onDestroy(() => {
2586
+ host.removeEventListener('pointerdown', onPointerDown);
2587
+ host.removeEventListener('lostpointercapture', onLostCapture);
2588
+ removeWindowListeners();
2589
+ });
2590
+ }
2591
+
2471
2592
  /**
2472
2593
  * Grace period (ms) added to an element's declared transition duration before the
2473
2594
  * safety-net timer force-completes a transition. Only matters when the real
@@ -2650,5 +2771,5 @@ var RdxPositionAlign;
2650
2771
  * Generated bundle index. Do not edit.
2651
2772
  */
2652
2773
 
2653
- export { A, ALT, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ASTERISK, BACKSPACE, CAPS_LOCK, CONTROL, CTRL, DELETE, END, ENTER, ESCAPE, F1, F10, F11, F12, F2, F3, F4, F5, F6, F7, F8, F9, HOME, META, P, PAGE_DOWN, PAGE_UP, RdxControlValueAccessor, RdxIdGenerator, RdxLiveAnnouncer, RdxPositionAlign, RdxPositionSide, SHIFT, SPACE, SPACE_CODE, TAB, a, areAllDaysBetweenValid, clamp, createContent, createContext, createFormatter, createMonth, createMonths, elementSize, getActiveElement, getDaysBetween, getDaysInMonth, getDefaultDate, getDefaultTime, getLastFirstDayOfWeek, getMaxTransitionDuration, getNextLastDayOfWeek, getOptsByGranularity, getPlaceholder, getSegmentElements, getWeekNumber, handleAndDispatchCustomEvent, handleCalendarInitialFocus, hasTime, initializeSegmentValues, injectControlValueAccessor, injectDocument, injectId, isAcceptableSegmentKey, isAfter, isAfterOrSame, isBefore, isBeforeOrSame, isBetween, isBetweenInclusive, isCalendarDateTime, isEqual, isNullish, isNumberString, isSegmentNavigationKey, isZonedDateTime, j, k, n, normalizeDateStep, normalizeHour12, normalizeHourCycle, p, provideToken, provideValueAccessor, resizeEffect, roundToStepPrecision, segmentBuilders, snapValueToStep, syncSegmentValues, syncTimeSegmentValues, toDate, useArrowNavigation, useDateField, useGraceArea, useScrollLock, useTransitionStatus, watch };
2774
+ export { A, ALT, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ASTERISK, BACKSPACE, CAPS_LOCK, CONTROL, CTRL, DELETE, END, ENTER, ESCAPE, F1, F10, F11, F12, F2, F3, F4, F5, F6, F7, F8, F9, HOME, META, P, PAGE_DOWN, PAGE_UP, RdxControlValueAccessor, RdxIdGenerator, RdxLiveAnnouncer, RdxPositionAlign, RdxPositionSide, SHIFT, SPACE, SPACE_CODE, TAB, TIME_GRANULARITIES, a, areAllDaysBetweenValid, clamp, createContent, createContext, createFormatter, createMonth, createMonths, elementSize, getActiveElement, getDaysBetween, getDaysInMonth, getDefaultDate, getDefaultTime, getLastFirstDayOfWeek, getMaxTransitionDuration, getNextLastDayOfWeek, getOptsByGranularity, getPlaceholder, getSegmentElements, getWeekNumber, handleAndDispatchCustomEvent, handleCalendarInitialFocus, hasTime, initializeSegmentValues, injectControlValueAccessor, injectDocument, injectId, isAcceptableSegmentKey, isAfter, isAfterOrSame, isBefore, isBeforeOrSame, isBetween, isBetweenInclusive, isCalendarDateTime, isEqual, isItemEqualToValue, isNullish, isNumberString, isSegmentNavigationKey, isZonedDateTime, itemToStringLabel, itemToStringValue, j, k, n, normalizeDateStep, normalizeHour12, normalizeHourCycle, p, provideToken, provideValueAccessor, resizeEffect, roundToStepPrecision, segmentBuilders, snapValueToStep, syncSegmentValues, syncTimeSegmentValues, toDate, useArrowNavigation, useDateField, useFilter, useGraceArea, useListHighlight, usePointerDrag, useScrollLock, useTransitionStatus, watch };
2654
2775
  //# sourceMappingURL=radix-ng-primitives-core.mjs.map