@radix-ng/primitives 1.0.0-beta.1 → 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 (85) 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 +14 -1
  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 +480 -469
  14. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  15. package/fesm2022/radix-ng-primitives-cropper.mjs +1 -1
  16. package/fesm2022/radix-ng-primitives-cropper.mjs.map +1 -1
  17. package/fesm2022/radix-ng-primitives-date-field.mjs +11 -0
  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 +1 -1
  22. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  23. package/fesm2022/radix-ng-primitives-editable.mjs +1 -1
  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 +4 -4
  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 +1 -1
  48. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  49. package/fesm2022/radix-ng-primitives-preview-card.mjs +1 -1
  50. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  51. package/fesm2022/radix-ng-primitives-progress.mjs +1 -1
  52. package/fesm2022/radix-ng-primitives-progress.mjs.map +1 -1
  53. package/fesm2022/radix-ng-primitives-roving-focus.mjs +1 -1
  54. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  55. package/fesm2022/radix-ng-primitives-scroll-area.mjs +3 -3
  56. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
  57. package/fesm2022/radix-ng-primitives-select.mjs +421 -224
  58. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  59. package/fesm2022/radix-ng-primitives-slider.mjs +1 -1
  60. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  61. package/fesm2022/radix-ng-primitives-switch.mjs +3 -2
  62. package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
  63. package/fesm2022/radix-ng-primitives-tabs.mjs +1 -1
  64. package/fesm2022/radix-ng-primitives-tabs.mjs.map +1 -1
  65. package/fesm2022/radix-ng-primitives-time-field.mjs +27 -3
  66. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  67. package/fesm2022/radix-ng-primitives-toast.mjs +1 -1
  68. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
  69. package/fesm2022/radix-ng-primitives-toggle-group.mjs +1 -1
  70. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  71. package/fesm2022/radix-ng-primitives-toolbar.mjs +2 -2
  72. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  73. package/fesm2022/radix-ng-primitives-tooltip.mjs +2 -2
  74. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  75. package/package.json +9 -1
  76. package/schematics/ng-add/index.js +57 -0
  77. package/schematics/ng-add/index.js.map +1 -1
  78. package/schematics/ng-add/schema.d.ts +1 -0
  79. package/schematics/ng-add/schema.json +6 -0
  80. package/types/radix-ng-primitives-combobox.d.ts +1265 -0
  81. package/types/radix-ng-primitives-core.d.ts +148 -56
  82. package/types/radix-ng-primitives-field.d.ts +71 -2
  83. package/types/radix-ng-primitives-form.d.ts +124 -0
  84. package/types/radix-ng-primitives-input.d.ts +75 -5
  85. package/types/radix-ng-primitives-select.d.ts +292 -132
@@ -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
@@ -205,8 +214,9 @@ function createContext(description) {
205
214
  // provided factory that returns null/undefined for the non-optional case.
206
215
  const value = inject(CONTEXT_TOKEN, { optional: true });
207
216
  if (value == null && !optional) {
208
- throw new Error(`No \`${description}Context\` found. A part of the \`${description}\` primitive ` +
209
- `must be used inside its root (the directive that provides \`${description}Context\`).`);
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}`);
210
220
  }
211
221
  return value;
212
222
  };
@@ -375,6 +385,12 @@ function areAllDaysBetweenValid(start, end, isUnavailable, isDisabled) {
375
385
  }
376
386
  return true;
377
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'];
378
394
  /**
379
395
  * A helper function used throughout the various date builders
380
396
  * to generate a default `DateValue` using the `defaultValue`,
@@ -387,20 +403,25 @@ function areAllDaysBetweenValid(start, end, isUnavailable, isDisabled) {
387
403
  */
388
404
  function getDefaultDate(props) {
389
405
  const { defaultValue, defaultPlaceholder, granularity = 'day', locale = 'en' } = props;
390
- if (Array.isArray(defaultValue) && defaultValue.length)
391
- return defaultValue[defaultValue.length - 1].copy();
392
- 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) {
393
414
  return defaultValue.copy();
415
+ }
394
416
  if (defaultPlaceholder)
395
417
  return defaultPlaceholder.copy();
396
418
  const date = new Date();
397
419
  const year = date.getFullYear();
398
420
  const month = date.getMonth() + 1;
399
421
  const day = date.getDate();
400
- const calendarDateTimeGranularities = ['hour', 'minute', 'second'];
401
422
  const defaultFormatter = new DateFormatter(locale);
402
423
  const calendar = createCalendar(defaultFormatter.resolvedOptions().calendar);
403
- if (calendarDateTimeGranularities.includes(granularity ?? 'day'))
424
+ if (TIME_GRANULARITIES.includes(granularity ?? 'day'))
404
425
  return toCalendar(new CalendarDateTime(year, month, day, 0, 0, 0), calendar);
405
426
  return toCalendar(new CalendarDate(year, month, day), calendar);
406
427
  }
@@ -498,7 +519,10 @@ function getWeekNumber(date, locale = 'en-US', firstDayOfWeek) {
498
519
  const prevYearDate = new CalendarDate(date.year - 1, 12, 31);
499
520
  return getWeekNumber(prevYearDate, locale, firstDayOfWeek);
500
521
  }
501
- 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 }));
502
526
  // Week number is days divided by 7 plus 1
503
527
  return Math.floor(days.length / 7) + 1;
504
528
  }
@@ -511,6 +535,22 @@ const defaultPartOptions = {
511
535
  minute: 'numeric',
512
536
  second: 'numeric'
513
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
+ }
514
554
  /**
515
555
  * Creates a wrapper around the `DateFormatter`, which is
516
556
  * an improved version of the {@link Intl.DateTimeFormat} API,
@@ -528,7 +568,7 @@ function createFormatter(initialLocale, opts = {}) {
528
568
  return locale;
529
569
  }
530
570
  function custom(date, options) {
531
- return new DateFormatter(locale, { ...opts, ...options }).format(date);
571
+ return getDateFormatter(locale, { ...opts, ...options }).format(date);
532
572
  }
533
573
  function selectedDate(date, includeTime = true) {
534
574
  if (hasTime(date) && includeTime) {
@@ -544,31 +584,31 @@ function createFormatter(initialLocale, opts = {}) {
544
584
  }
545
585
  }
546
586
  function fullMonthAndYear(date, options = {}) {
547
- return new DateFormatter(locale, { ...opts, month: 'long', year: 'numeric', ...options }).format(date);
587
+ return getDateFormatter(locale, { ...opts, month: 'long', year: 'numeric', ...options }).format(date);
548
588
  }
549
589
  function fullMonth(date, options = {}) {
550
- return new DateFormatter(locale, { ...opts, month: 'long', ...options }).format(date);
590
+ return getDateFormatter(locale, { ...opts, month: 'long', ...options }).format(date);
551
591
  }
552
592
  function fullYear(date, options = {}) {
553
- return new DateFormatter(locale, { ...opts, year: 'numeric', ...options }).format(date);
593
+ return getDateFormatter(locale, { ...opts, year: 'numeric', ...options }).format(date);
554
594
  }
555
595
  function toParts(date, options) {
556
596
  if (isZonedDateTime(date)) {
557
- return new DateFormatter(locale, {
597
+ return getDateFormatter(locale, {
558
598
  ...opts,
559
599
  ...options,
560
600
  timeZone: date.timeZone
561
601
  }).formatToParts(toDate(date));
562
602
  }
563
603
  else {
564
- return new DateFormatter(locale, { ...opts, ...options }).formatToParts(toDate(date));
604
+ return getDateFormatter(locale, { ...opts, ...options }).formatToParts(toDate(date));
565
605
  }
566
606
  }
567
607
  function dayOfWeek(date, length = 'narrow') {
568
- return new DateFormatter(locale, { ...opts, weekday: length }).format(date);
608
+ return getDateFormatter(locale, { ...opts, weekday: length }).format(date);
569
609
  }
570
610
  function dayPeriod(date, hourCycle = undefined) {
571
- const parts = new DateFormatter(locale, {
611
+ const parts = getDateFormatter(locale, {
572
612
  ...opts,
573
613
  hour: 'numeric',
574
614
  minute: 'numeric',
@@ -832,7 +872,6 @@ function normalizeHour12(hourCycle) {
832
872
  return undefined;
833
873
  }
834
874
 
835
- const calendarDateTimeGranularities = ['hour', 'minute', 'second'];
836
875
  function syncTimeSegmentValues(props) {
837
876
  return Object.fromEntries(TIME_SEGMENT_PARTS.map((part) => {
838
877
  if (part === 'dayPeriod')
@@ -840,7 +879,7 @@ function syncTimeSegmentValues(props) {
840
879
  return [part, props.value[part]];
841
880
  }));
842
881
  }
843
- function initializeSegmentValues(granularity) {
882
+ function initializeSegmentValues(granularity, isTimeValue = false) {
844
883
  const initialParts = EDITABLE_SEGMENT_PARTS.map((part) => {
845
884
  if (part === 'dayPeriod')
846
885
  return [part, 'AM'];
@@ -848,12 +887,16 @@ function initializeSegmentValues(granularity) {
848
887
  }).filter(([key]) => {
849
888
  if (key === 'literal' || key === null)
850
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;
851
894
  if (granularity === 'minute' && key === 'second')
852
895
  return false;
853
896
  if (granularity === 'hour' && (key === 'second' || key === 'minute'))
854
897
  return false;
855
898
  if (granularity === 'day')
856
- return !calendarDateTimeGranularities.includes(key) && key !== 'dayPeriod';
899
+ return !TIME_GRANULARITIES.includes(key) && key !== 'dayPeriod';
857
900
  else
858
901
  return true;
859
902
  });
@@ -1055,6 +1098,20 @@ function getSegmentElements(parentElement) {
1055
1098
  */
1056
1099
 
1057
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
+ }
1058
1115
  function commonSegmentAttrs(props) {
1059
1116
  return {
1060
1117
  role: 'spinbutton',
@@ -1067,120 +1124,79 @@ function commonSegmentAttrs(props) {
1067
1124
  style: 'caret-color: transparent;'
1068
1125
  };
1069
1126
  }
1070
- 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) {
1071
1132
  const { segmentValues, placeholder } = props;
1072
- const isEmpty = segmentValues.day === null;
1073
- const date = segmentValues.day ? placeholder.set({ day: segmentValues.day }) : placeholder;
1074
- const valueNow = date.day;
1075
- const valueMin = 1;
1076
- const valueMax = getDaysInMonth(date);
1077
- 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}`);
1078
1142
  return {
1079
1143
  ...commonSegmentAttrs(props),
1080
- 'aria-label': 'day,',
1081
- 'aria-valuemin': valueMin,
1144
+ 'aria-label': config.label,
1145
+ 'aria-valuemin': config.valueMin,
1082
1146
  'aria-valuemax': valueMax,
1083
1147
  'aria-valuenow': valueNow,
1084
1148
  'aria-valuetext': valueText,
1085
1149
  'data-placeholder': isEmpty ? '' : undefined
1086
1150
  };
1087
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
+ }
1088
1160
  function monthSegmentAttrs(props) {
1089
- const { segmentValues, placeholder, formatter } = props;
1090
- const isEmpty = segmentValues.month === null;
1091
- const date = segmentValues.month ? placeholder.set({ month: segmentValues.month }) : placeholder;
1092
- const valueNow = date.month;
1093
- const valueMin = 1;
1094
- const valueMax = 12;
1095
- const valueText = isEmpty ? 'Empty' : `${valueNow} - ${formatter.fullMonth(toDate(date))}`;
1096
- return {
1097
- ...commonSegmentAttrs(props),
1098
- 'aria-label': 'month, ',
1099
- contenteditable: true,
1100
- 'aria-valuemin': valueMin,
1101
- 'aria-valuemax': valueMax,
1102
- 'aria-valuenow': valueNow,
1103
- 'aria-valuetext': valueText,
1104
- 'data-placeholder': isEmpty ? '' : undefined
1105
- };
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
+ });
1106
1168
  }
1107
1169
  function yearSegmentAttrs(props) {
1108
- const { segmentValues, placeholder } = props;
1109
- const isEmpty = segmentValues.year === null;
1110
- const date = segmentValues.year ? placeholder.set({ year: segmentValues.year }) : placeholder;
1111
- const valueMin = 1;
1112
- const valueMax = 9999;
1113
- const valueNow = date.year;
1114
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1115
- return {
1116
- ...commonSegmentAttrs(props),
1117
- 'aria-label': 'year, ',
1118
- 'aria-valuemin': valueMin,
1119
- 'aria-valuemax': valueMax,
1120
- 'aria-valuenow': valueNow,
1121
- 'aria-valuetext': valueText,
1122
- 'data-placeholder': isEmpty ? '' : undefined
1123
- };
1170
+ return numericSegmentAttrs(props, { field: 'year', label: 'year, ', valueMin: 1, valueMax: 9999 });
1124
1171
  }
1125
1172
  function hourSegmentAttrs(props) {
1126
- const { segmentValues, hourCycle, placeholder } = props;
1127
- if (!('hour' in segmentValues) || !('hour' in placeholder))
1128
- return {};
1129
- const isEmpty = segmentValues.hour === null;
1130
- const date = segmentValues.hour ? placeholder.set({ hour: segmentValues.hour }) : placeholder;
1131
- const valueMin = hourCycle === 12 ? 1 : 0;
1132
- const valueMax = hourCycle === 12 ? 12 : 23;
1133
- const valueNow = date.hour;
1134
- const valueText = isEmpty ? 'Empty' : `${valueNow} ${segmentValues.dayPeriod ?? ''}`;
1135
- return {
1136
- ...commonSegmentAttrs(props),
1137
- 'aria-label': 'hour, ',
1138
- 'aria-valuemin': valueMin,
1139
- 'aria-valuemax': valueMax,
1140
- 'aria-valuenow': valueNow,
1141
- 'aria-valuetext': valueText,
1142
- 'data-placeholder': isEmpty ? '' : undefined
1143
- };
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
+ });
1144
1182
  }
1145
1183
  function minuteSegmentAttrs(props) {
1146
- const { segmentValues, placeholder } = props;
1147
- if (!('minute' in segmentValues) || !('minute' in placeholder))
1148
- return {};
1149
- const isEmpty = segmentValues.minute === null;
1150
- const date = segmentValues.minute ? placeholder.set({ minute: segmentValues.minute }) : placeholder;
1151
- const valueNow = date.minute;
1152
- const valueMin = 0;
1153
- const valueMax = 59;
1154
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1155
- return {
1156
- ...commonSegmentAttrs(props),
1157
- 'aria-label': 'minute, ',
1158
- 'aria-valuemin': valueMin,
1159
- 'aria-valuemax': valueMax,
1160
- 'aria-valuenow': valueNow,
1161
- 'aria-valuetext': valueText,
1162
- 'data-placeholder': isEmpty ? '' : undefined
1163
- };
1184
+ return numericSegmentAttrs(props, {
1185
+ field: 'minute',
1186
+ label: 'minute, ',
1187
+ valueMin: 0,
1188
+ valueMax: 59,
1189
+ timePart: true
1190
+ });
1164
1191
  }
1165
1192
  function secondSegmentAttrs(props) {
1166
- const { segmentValues, placeholder } = props;
1167
- if (!('second' in segmentValues) || !('second' in placeholder))
1168
- return {};
1169
- const isEmpty = segmentValues.second === null;
1170
- const date = segmentValues.second ? placeholder.set({ second: segmentValues.second }) : placeholder;
1171
- const valueNow = date.second;
1172
- const valueMin = 0;
1173
- const valueMax = 59;
1174
- const valueText = isEmpty ? 'Empty' : `${valueNow}`;
1175
- return {
1176
- ...commonSegmentAttrs(props),
1177
- 'aria-label': 'second, ',
1178
- 'aria-valuemin': valueMin,
1179
- 'aria-valuemax': valueMax,
1180
- 'aria-valuenow': valueNow,
1181
- 'aria-valuetext': valueText,
1182
- 'data-placeholder': isEmpty ? '' : undefined
1183
- };
1193
+ return numericSegmentAttrs(props, {
1194
+ field: 'second',
1195
+ label: 'second, ',
1196
+ valueMin: 0,
1197
+ valueMax: 59,
1198
+ timePart: true
1199
+ });
1184
1200
  }
1185
1201
  function dayPeriodSegmentAttrs(props) {
1186
1202
  const { segmentValues } = props;
@@ -1188,7 +1204,7 @@ function dayPeriodSegmentAttrs(props) {
1188
1204
  return {};
1189
1205
  const valueMin = 0;
1190
1206
  const valueMax = 12;
1191
- 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;
1192
1208
  const valueText = segmentValues.dayPeriod ?? 'AM';
1193
1209
  return {
1194
1210
  ...commonSegmentAttrs(props),
@@ -1300,76 +1316,54 @@ function useDateField(props) {
1300
1316
  .cycle(...cycleArgs)[part];
1301
1317
  return dateRef.set({ [part]: prevValue }).cycle(...cycleArgs)[part];
1302
1318
  }
1303
- 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 } = {}) {
1304
1330
  let moveToNext = false;
1305
1331
  const maxStart = Math.floor(max / 10);
1306
- /**
1307
- * If the user has left the segment, we want to reset the
1308
- * `prev` value so that we can start the segment over again
1309
- * when the user types a number.
1310
- */
1332
+ // If the user has left the segment, reset `prev` so typing restarts the segment.
1311
1333
  if (props.hasLeftFocus()) {
1312
1334
  props.hasLeftFocus.set(false);
1313
1335
  prev = null;
1314
1336
  }
1315
1337
  if (prev === null) {
1316
- /**
1317
- * If the user types a 0 as the first number, we want
1318
- * to keep track of that so that when they type the next
1319
- * number, we can move to the next segment.
1320
- */
1338
+ // A leading 0 is tracked so the next digit can advance to the next segment.
1321
1339
  if (num === 0) {
1322
1340
  props.lastKeyZero.set(true);
1323
- return { value: null, moveToNext };
1341
+ return { value: emptyZero ? 0 : null, moveToNext };
1324
1342
  }
1325
- /**
1326
- * If the last key was a 0, or if the first number is
1327
- * greater than the max start digit (0-3 in most cases), then
1328
- * we want to move to the next segment, since it's not possible
1329
- * to continue typing a valid number in this segment.
1330
- */
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.
1331
1345
  if (props.lastKeyZero() || num > maxStart) {
1332
- // move to next
1333
1346
  moveToNext = true;
1334
1347
  }
1335
1348
  props.lastKeyZero.set(false);
1336
- /**
1337
- * If none of the above conditions are met, then we can just
1338
- * return the number as the segment value and continue typing
1339
- * in this segment.
1340
- */
1341
1349
  return { value: num, moveToNext };
1342
1350
  }
1343
- /**
1344
- * If the number of digits is 2, or if the total with the existing digit
1345
- * and the pressed digit is greater than the maximum value for this
1346
- * month, then we will reset the segment as if the user had pressed the
1347
- * backspace key and then typed the number.
1348
- */
1351
+ // Either the segment already holds two digits, or appending this digit overflows `max`:
1352
+ // reset the segment as if backspaced and then typed.
1349
1353
  const digits = prev.toString().length;
1350
1354
  const total = Number.parseInt(prev.toString() + num.toString());
1351
- /**
1352
- * If the number of digits is 2, or if the total with the existing digit
1353
- * and the pressed digit is greater than the maximum value for this
1354
- * month, then we will reset the segment as if the user had pressed the
1355
- * backspace key and then typed the number.
1356
- */
1357
1355
  if (digits === 2 || total > max) {
1358
- /**
1359
- * As we're doing elsewhere, we're checking if the number is greater
1360
- * than the max start digit (0-3 in most months), and if so, we're
1361
- * going to move to the next segment.
1362
- */
1363
- if (num > maxStart || total > max) {
1364
- // move to next
1356
+ if (num > maxStart || (moveOnOverflow && total > max)) {
1365
1357
  moveToNext = true;
1366
1358
  }
1367
1359
  return { value: num, moveToNext };
1368
1360
  }
1369
- // move to next
1370
1361
  moveToNext = true;
1371
1362
  return { value: total, moveToNext };
1372
1363
  }
1364
+ function updateDayOrMonth(max, num, prev) {
1365
+ return updateNumberSegment(num, prev, max, { emptyZero: false, moveOnOverflow: true });
1366
+ }
1373
1367
  function updateYear(num, prev) {
1374
1368
  let moveToNext = false;
1375
1369
  /**
@@ -1392,148 +1386,11 @@ function useDateField(props) {
1392
1386
  const int = Number.parseInt(str);
1393
1387
  return { value: int, moveToNext };
1394
1388
  }
1395
- function updateHour(num, prev) {
1396
- const max = 24;
1397
- let moveToNext = false;
1398
- const maxStart = Math.floor(max / 10);
1399
- /**
1400
- * If the user has left the segment, we want to reset the
1401
- * `prev` value so that we can start the segment over again
1402
- * when the user types a number.
1403
- */
1404
- // probably not implement, kind of weird
1405
- if (props.hasLeftFocus()) {
1406
- props.hasLeftFocus.set(false);
1407
- prev = null;
1408
- }
1409
- if (prev === null) {
1410
- /**
1411
- * If the user types a 0 as the first number, we want
1412
- * to keep track of that so that when they type the next
1413
- * number, we can move to the next segment.
1414
- */
1415
- if (num === 0) {
1416
- props.lastKeyZero.set(true);
1417
- return { value: 0, moveToNext };
1418
- }
1419
- /**
1420
- * If the last key was a 0, or if the first number is
1421
- * greater than the max start digit (0-3 in most cases), then
1422
- * we want to move to the next segment, since it's not possible
1423
- * to continue typing a valid number in this segment.
1424
- */
1425
- if (props.lastKeyZero() || num > maxStart) {
1426
- // move to next
1427
- moveToNext = true;
1428
- }
1429
- props.lastKeyZero.set(false);
1430
- /**
1431
- * If none of the above conditions are met, then we can just
1432
- * return the number as the segment value and continue typing
1433
- * in this segment.
1434
- */
1435
- return { value: num, moveToNext };
1436
- }
1437
- /**
1438
- * If the number of digits is 2, or if the total with the existing digit
1439
- * and the pressed digit is greater than the maximum value for this
1440
- * month, then we will reset the segment as if the user had pressed the
1441
- * backspace key and then typed the number.
1442
- */
1443
- const digits = prev.toString().length;
1444
- const total = Number.parseInt(prev.toString() + num.toString());
1445
- /**
1446
- * If the number of digits is 2, or if the total with the existing digit
1447
- * and the pressed digit is greater than the maximum value for this
1448
- * month, then we will reset the segment as if the user had pressed the
1449
- * backspace key and then typed the number.
1450
- */
1451
- if (digits === 2 || total > max) {
1452
- /**
1453
- * As we're doing elsewhere, we're checking if the number is greater
1454
- * than the max start digit (0-3 in most months), and if so, we're
1455
- * going to move to the next segment.
1456
- */
1457
- if (num > maxStart) {
1458
- // move to next
1459
- moveToNext = true;
1460
- }
1461
- return { value: num, moveToNext };
1462
- }
1463
- // move to next
1464
- moveToNext = true;
1465
- return { value: total, moveToNext };
1389
+ function updateHour(num, prev, max) {
1390
+ return updateNumberSegment(num, prev, max);
1466
1391
  }
1467
1392
  function updateMinuteOrSecond(num, prev) {
1468
- const max = 59;
1469
- let moveToNext = false;
1470
- const maxStart = Math.floor(max / 10);
1471
- /**
1472
- * If the user has left the segment, we want to reset the
1473
- * `prev` value so that we can start the segment over again
1474
- * when the user types a number.
1475
- */
1476
- if (props.hasLeftFocus()) {
1477
- props.hasLeftFocus.set(false);
1478
- prev = null;
1479
- }
1480
- if (prev === null) {
1481
- /**
1482
- * If the user types a 0 as the first number, we want
1483
- * to keep track of that so that when they type the next
1484
- * number, we can move to the next segment.
1485
- */
1486
- if (num === 0) {
1487
- props.lastKeyZero.set(true);
1488
- return { value: 0, moveToNext };
1489
- }
1490
- /**
1491
- * If the last key was a 0, or if the first number is
1492
- * greater than the max start digit (0-3 in most cases), then
1493
- * we want to move to the next segment, since it's not possible
1494
- * to continue typing a valid number in this segment.
1495
- */
1496
- if (props.lastKeyZero() || num > maxStart) {
1497
- // move to next
1498
- moveToNext = true;
1499
- }
1500
- props.lastKeyZero.set(false);
1501
- /**
1502
- * If none of the above conditions are met, then we can just
1503
- * return the number as the segment value and continue typing
1504
- * in this segment.
1505
- */
1506
- return { value: num, moveToNext };
1507
- }
1508
- /**
1509
- * If the number of digits is 2, or if the total with the existing digit
1510
- * and the pressed digit is greater than the maximum value for this
1511
- * month, then we will reset the segment as if the user had pressed the
1512
- * backspace key and then typed the number.
1513
- */
1514
- const digits = prev.toString().length;
1515
- const total = Number.parseInt(prev.toString() + num.toString());
1516
- /**
1517
- * If the number of digits is 2, or if the total with the existing digit
1518
- * and the pressed digit is greater than the maximum value for this
1519
- * month, then we will reset the segment as if the user had pressed the
1520
- * backspace key and then typed the number.
1521
- */
1522
- if (digits === 2 || total > max) {
1523
- /**
1524
- * As we're doing elsewhere, we're checking if the number is greater
1525
- * than the max start digit (0-3 in most months), and if so, we're
1526
- * going to move to the next segment.
1527
- */
1528
- if (num > maxStart) {
1529
- // move to next
1530
- moveToNext = true;
1531
- }
1532
- return { value: num, moveToNext };
1533
- }
1534
- // move to next
1535
- moveToNext = true;
1536
- return { value: total, moveToNext };
1393
+ return updateNumberSegment(num, prev, 59);
1537
1394
  }
1538
1395
  function minuteSecondIncrementation({ e, part, dateRef, prevValue }) {
1539
1396
  const step = props.step()[part] ?? 1;
@@ -1661,22 +1518,24 @@ function useDateField(props) {
1661
1518
  hourCycle
1662
1519
  })
1663
1520
  }));
1664
- if ('dayPeriod' in props.segmentValues() && values.hour != null) {
1665
- if (values.hour < 12)
1666
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1667
- else if (values.hour)
1668
- 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) }));
1669
1525
  }
1670
1526
  return;
1671
1527
  }
1672
1528
  if (isNumberString(e.key)) {
1673
1529
  const num = Number.parseInt(e.key);
1674
- const { value, moveToNext } = updateHour(num, prevValue);
1675
- if ('dayPeriod' in props.segmentValues() && value && value > 12)
1676
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1677
- else if ('dayPeriod' in props.segmentValues() && value)
1678
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1679
- 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 }));
1680
1539
  if (moveToNext)
1681
1540
  props.focusNext();
1682
1541
  }
@@ -1685,68 +1544,26 @@ function useDateField(props) {
1685
1544
  props.segmentValues.update((prev) => ({ ...prev, hour: deleteValue(prevValue) }));
1686
1545
  }
1687
1546
  }
1688
- function handleMinuteSegmentKeydown(e) {
1689
- const dateRef = props.placeholder();
1690
- const values = props.segmentValues();
1691
- if (!isAcceptableSegmentKey(e.key) ||
1692
- isSegmentNavigationKey(e.key) ||
1693
- !('minute' in dateRef) ||
1694
- !('minute' in values))
1695
- return;
1696
- const prevValue = values.minute;
1697
- if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1698
- props.segmentValues.update((prev) => ({
1699
- ...prev,
1700
- minute: minuteSecondIncrementation({
1701
- e,
1702
- part: 'minute',
1703
- dateRef: props.placeholder(),
1704
- prevValue
1705
- })
1706
- }));
1707
- }
1708
- if (isNumberString(e.key)) {
1709
- const num = Number.parseInt(e.key);
1710
- const { value, moveToNext } = updateMinuteOrSecond(num, prevValue);
1711
- props.segmentValues.update((prev) => ({ ...prev, minute: value }));
1712
- if (moveToNext)
1713
- props.focusNext();
1714
- }
1715
- if (e.key === BACKSPACE) {
1716
- props.hasLeftFocus.set(false);
1717
- props.segmentValues.update((prev) => ({ ...prev, minute: deleteValue(prevValue) }));
1718
- }
1719
- }
1720
- function handleSecondSegmentKeydown(e) {
1547
+ // Minute and second segments behave identically; only the field differs.
1548
+ function handleMinuteOrSecondSegmentKeydown(e, part) {
1721
1549
  const dateRef = props.placeholder();
1722
1550
  const values = props.segmentValues();
1723
- if (!isAcceptableSegmentKey(e.key) ||
1724
- isSegmentNavigationKey(e.key) ||
1725
- !('second' in dateRef) ||
1726
- !('second' in values))
1551
+ if (!isAcceptableSegmentKey(e.key) || isSegmentNavigationKey(e.key) || !(part in dateRef) || !(part in values))
1727
1552
  return;
1728
- const prevValue = values.second;
1553
+ const prevValue = values[part];
1729
1554
  if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1730
- props.segmentValues.update((prev) => ({
1731
- ...prev,
1732
- second: minuteSecondIncrementation({
1733
- e,
1734
- part: 'second',
1735
- dateRef: props.placeholder(),
1736
- prevValue
1737
- })
1738
- }));
1555
+ const next = minuteSecondIncrementation({ e, part, dateRef: props.placeholder(), prevValue });
1556
+ props.segmentValues.update((prev) => ({ ...prev, [part]: next }));
1739
1557
  }
1740
1558
  if (isNumberString(e.key)) {
1741
- const num = Number.parseInt(e.key);
1742
- const { value, moveToNext } = updateMinuteOrSecond(num, prevValue);
1743
- 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 }));
1744
1561
  if (moveToNext)
1745
1562
  props.focusNext();
1746
1563
  }
1747
1564
  if (e.key === BACKSPACE) {
1748
1565
  props.hasLeftFocus.set(false);
1749
- props.segmentValues.update((prev) => ({ ...prev, second: deleteValue(prevValue) }));
1566
+ props.segmentValues.update((prev) => ({ ...prev, [part]: deleteValue(prevValue) }));
1750
1567
  }
1751
1568
  }
1752
1569
  function handleDayPeriodSegmentKeydown(e) {
@@ -1755,24 +1572,24 @@ function useDateField(props) {
1755
1572
  !('dayPeriod' in props.segmentValues()))
1756
1573
  return;
1757
1574
  const values = props.segmentValues();
1758
- if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
1759
- if (values.dayPeriod === 'AM') {
1760
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1761
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour + 12 }));
1575
+ const setPeriod = (period) => {
1576
+ if (values.dayPeriod === period)
1762
1577
  return;
1763
- }
1764
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1765
- 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');
1766
1585
  return;
1767
1586
  }
1768
- if (['a', 'A'].includes(e.key) && values.dayPeriod !== 'AM') {
1769
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'AM' }));
1770
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour - 12 }));
1587
+ if (['a', 'A'].includes(e.key)) {
1588
+ setPeriod('AM');
1771
1589
  return;
1772
1590
  }
1773
- if (['p', 'P'].includes(e.key) && values.dayPeriod !== 'PM') {
1774
- props.segmentValues.update((prev) => ({ ...prev, dayPeriod: 'PM' }));
1775
- props.segmentValues.update((prev) => ({ ...prev, hour: values.hour + 12 }));
1591
+ if (['p', 'P'].includes(e.key)) {
1592
+ setPeriod('PM');
1776
1593
  }
1777
1594
  }
1778
1595
  function handleSegmentKeydown(e) {
@@ -1787,8 +1604,8 @@ function useDateField(props) {
1787
1604
  day: handleDaySegmentKeydown,
1788
1605
  year: handleYearSegmentKeydown,
1789
1606
  hour: handleHourSegmentKeydown,
1790
- minute: handleMinuteSegmentKeydown,
1791
- second: handleSecondSegmentKeydown,
1607
+ minute: (e) => handleMinuteOrSecondSegmentKeydown(e, 'minute'),
1608
+ second: (e) => handleMinuteOrSecondSegmentKeydown(e, 'second'),
1792
1609
  dayPeriod: handleDayPeriodSegmentKeydown,
1793
1610
  timeZoneName: () => { }
1794
1611
  };
@@ -1872,84 +1689,6 @@ function injectId(prefix) {
1872
1689
  return inject(RdxIdGenerator).getId(prefix);
1873
1690
  }
1874
1691
 
1875
- /**
1876
- * Announces messages to screen readers through an `aria-live` region, without moving focus.
1877
- *
1878
- * Own replacement for CDK's `LiveAnnouncer` — lazily appends a visually hidden live region to
1879
- * the document body and writes messages into it. No-op on the server.
1880
- */
1881
- class RdxLiveAnnouncer {
1882
- constructor() {
1883
- this.document = inject(DOCUMENT);
1884
- this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1885
- this.liveElement = null;
1886
- inject(DestroyRef).onDestroy(() => {
1887
- clearTimeout(this.previousTimeout);
1888
- this.liveElement?.remove();
1889
- this.liveElement = null;
1890
- });
1891
- }
1892
- /**
1893
- * Announces a message to screen readers.
1894
- *
1895
- * @param message The message to announce.
1896
- * @param politeness The politeness of the announcer element (defaults to `'polite'`).
1897
- * @param duration If provided, the message is cleared after this many milliseconds.
1898
- */
1899
- announce(message, politeness = 'polite', duration) {
1900
- if (!this.isBrowser) {
1901
- return;
1902
- }
1903
- const liveElement = this.getLiveElement();
1904
- clearTimeout(this.previousTimeout);
1905
- liveElement.setAttribute('aria-live', politeness);
1906
- // Clear the live element first, then set the message after a tick so that screen
1907
- // readers reliably pick up the change even when the text is identical.
1908
- liveElement.textContent = '';
1909
- this.previousTimeout = setTimeout(() => {
1910
- liveElement.textContent = message;
1911
- if (typeof duration === 'number') {
1912
- this.previousTimeout = setTimeout(() => (liveElement.textContent = ''), duration);
1913
- }
1914
- });
1915
- }
1916
- /** Clears the current announcement. */
1917
- clear() {
1918
- if (this.liveElement) {
1919
- this.liveElement.textContent = '';
1920
- }
1921
- }
1922
- getLiveElement() {
1923
- if (this.liveElement) {
1924
- return this.liveElement;
1925
- }
1926
- const element = this.document.createElement('div');
1927
- element.classList.add('rdx-live-announcer');
1928
- element.setAttribute('aria-atomic', 'true');
1929
- element.setAttribute('aria-live', 'polite');
1930
- // Visually hide the region while keeping it available to assistive technology.
1931
- element.style.position = 'absolute';
1932
- element.style.width = '1px';
1933
- element.style.height = '1px';
1934
- element.style.margin = '-1px';
1935
- element.style.padding = '0';
1936
- element.style.border = '0';
1937
- element.style.overflow = 'hidden';
1938
- element.style.clip = 'rect(0 0 0 0)';
1939
- element.style.clipPath = 'inset(100%)';
1940
- element.style.whiteSpace = 'nowrap';
1941
- this.document.body.appendChild(element);
1942
- this.liveElement = element;
1943
- return element;
1944
- }
1945
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1946
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, providedIn: 'root' }); }
1947
- }
1948
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxLiveAnnouncer, decorators: [{
1949
- type: Injectable,
1950
- args: [{ providedIn: 'root' }]
1951
- }], ctorParameters: () => [] });
1952
-
1953
1692
  /** Narrows to `null | undefined`. */
1954
1693
  function isNullish(value) {
1955
1694
  return value === null || value === undefined;
@@ -2028,6 +1767,127 @@ function equals(a, b, seen) {
2028
1767
  }
2029
1768
  }
2030
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
+
2031
1891
  /**
2032
1892
  * Creates an Angular provider that binds the given token to the existing instance
2033
1893
  * of the specified class. This is especially useful when you want multiple
@@ -2123,18 +1983,24 @@ function resizeEffect(options) {
2123
1983
  }
2124
1984
 
2125
1985
  /**
2126
- * 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.
2127
1988
  *
2128
1989
  * A single shared counter across every primitive that locks scroll is essential: with separate
2129
1990
  * per-primitive counters, a popover and a dialog open at the same time would each capture the
2130
- * other's already-locked `overflow: hidden` as the "original" value and restore it on close,
2131
- * 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.
2132
1993
  */
2133
- let originalBodyOverflow = null;
1994
+ let original = null;
2134
1995
  let scrollLockCount = 0;
2135
1996
  /**
2136
- * Locks `document.body` scrolling while `active()` is `true`, and restores the original overflow
2137
- * 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.
2138
2004
  *
2139
2005
  * Lock ownership is shared across all callers via a single module-level counter, so nested or
2140
2006
  * concurrent overlays compose correctly. Must be called in an injection context.
@@ -2146,10 +2012,22 @@ function useScrollLock(active) {
2146
2012
  if (isLocked) {
2147
2013
  return;
2148
2014
  }
2149
- const body = document.body;
2150
2015
  if (scrollLockCount === 0) {
2151
- 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
+ };
2152
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
+ }
2153
2031
  }
2154
2032
  scrollLockCount++;
2155
2033
  isLocked = true;
@@ -2160,9 +2038,12 @@ function useScrollLock(active) {
2160
2038
  }
2161
2039
  scrollLockCount--;
2162
2040
  isLocked = false;
2163
- if (scrollLockCount === 0 && originalBodyOverflow !== null) {
2164
- document.body.style.overflow = originalBodyOverflow;
2165
- 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;
2166
2047
  }
2167
2048
  };
2168
2049
  effect(() => {
@@ -2260,6 +2141,56 @@ function findNextFocusableElement(elements, currentElement, options, iterations
2260
2141
  return candidate;
2261
2142
  }
2262
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
+
2263
2194
  const graceAreaContainers = new WeakMap();
2264
2195
  function createSignalEvent() {
2265
2196
  const handlers = new Set();
@@ -2472,6 +2403,86 @@ function getHullPresorted(points) {
2472
2403
  return upper.concat(lower);
2473
2404
  }
2474
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
+
2475
2486
  /** Pointer travel (px) before a press becomes a drag — below this a press stays a click/tap. */
2476
2487
  const DRAG_THRESHOLD = 4;
2477
2488
  /**
@@ -2760,5 +2771,5 @@ var RdxPositionAlign;
2760
2771
  * Generated bundle index. Do not edit.
2761
2772
  */
2762
2773
 
2763
- 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, usePointerDrag, 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 };
2764
2775
  //# sourceMappingURL=radix-ng-primitives-core.mjs.map