@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.
- package/fesm2022/radix-ng-primitives-accordion.mjs +2 -2
- package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-calendar.mjs +14 -1
- package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-checkbox.mjs +2 -2
- package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-collapsible.mjs +1 -1
- package/fesm2022/radix-ng-primitives-collapsible.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-combobox.mjs +1923 -0
- package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-context-menu.mjs +1 -1
- package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-core.mjs +480 -469
- package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-cropper.mjs +1 -1
- package/fesm2022/radix-ng-primitives-cropper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-date-field.mjs +11 -0
- package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-dialog.mjs +1 -1
- package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-drawer.mjs +1 -1
- package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-editable.mjs +1 -1
- package/fesm2022/radix-ng-primitives-editable.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-field.mjs +86 -6
- package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-fieldset.mjs +1 -1
- package/fesm2022/radix-ng-primitives-fieldset.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-focus-scope.mjs +1 -1
- package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-form.mjs +207 -0
- package/fesm2022/radix-ng-primitives-form.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-input.mjs +85 -4
- package/fesm2022/radix-ng-primitives-input.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menu.mjs +4 -4
- package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menubar.mjs +1 -1
- package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-meter.mjs +1 -1
- package/fesm2022/radix-ng-primitives-meter.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-navigation-menu.mjs +1 -1
- package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-number-field.mjs +2 -2
- package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popover.mjs +1 -1
- package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popper.mjs +1 -1
- package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-preview-card.mjs +1 -1
- package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-progress.mjs +1 -1
- package/fesm2022/radix-ng-primitives-progress.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-roving-focus.mjs +1 -1
- package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-scroll-area.mjs +3 -3
- package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-select.mjs +421 -224
- package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-slider.mjs +1 -1
- package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-switch.mjs +3 -2
- package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-tabs.mjs +1 -1
- package/fesm2022/radix-ng-primitives-tabs.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-time-field.mjs +27 -3
- package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toast.mjs +1 -1
- package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toggle-group.mjs +1 -1
- package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toolbar.mjs +2 -2
- package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-tooltip.mjs +2 -2
- package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
- package/package.json +9 -1
- package/schematics/ng-add/index.js +57 -0
- package/schematics/ng-add/index.js.map +1 -1
- package/schematics/ng-add/schema.d.ts +1 -0
- package/schematics/ng-add/schema.json +6 -0
- package/types/radix-ng-primitives-combobox.d.ts +1265 -0
- package/types/radix-ng-primitives-core.d.ts +148 -56
- package/types/radix-ng-primitives-field.d.ts +71 -2
- package/types/radix-ng-primitives-form.d.ts +124 -0
- package/types/radix-ng-primitives-input.d.ts +75 -5
- 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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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)
|
|
391
|
-
|
|
392
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
587
|
+
return getDateFormatter(locale, { ...opts, month: 'long', year: 'numeric', ...options }).format(date);
|
|
548
588
|
}
|
|
549
589
|
function fullMonth(date, options = {}) {
|
|
550
|
-
return
|
|
590
|
+
return getDateFormatter(locale, { ...opts, month: 'long', ...options }).format(date);
|
|
551
591
|
}
|
|
552
592
|
function fullYear(date, options = {}) {
|
|
553
|
-
return
|
|
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
|
|
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
|
|
604
|
+
return getDateFormatter(locale, { ...opts, ...options }).formatToParts(toDate(date));
|
|
565
605
|
}
|
|
566
606
|
}
|
|
567
607
|
function dayOfWeek(date, length = 'narrow') {
|
|
568
|
-
return
|
|
608
|
+
return getDateFormatter(locale, { ...opts, weekday: length }).format(date);
|
|
569
609
|
}
|
|
570
610
|
function dayPeriod(date, hourCycle = undefined) {
|
|
571
|
-
const parts =
|
|
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 !
|
|
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
|
-
|
|
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
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const
|
|
1076
|
-
const
|
|
1077
|
-
const
|
|
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':
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
|
|
1689
|
-
|
|
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
|
|
1553
|
+
const prevValue = values[part];
|
|
1729
1554
|
if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
|
|
1730
|
-
props.
|
|
1731
|
-
|
|
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
|
|
1742
|
-
|
|
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,
|
|
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
|
-
|
|
1759
|
-
if (values.dayPeriod ===
|
|
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
|
-
|
|
1765
|
-
|
|
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)
|
|
1769
|
-
|
|
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)
|
|
1774
|
-
|
|
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:
|
|
1791
|
-
second:
|
|
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
|
|
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
|
|
2131
|
-
*
|
|
1991
|
+
* other's already-locked state as the "original" and restore it on close, leaving the page
|
|
1992
|
+
* permanently unscrollable.
|
|
2132
1993
|
*/
|
|
2133
|
-
let
|
|
1994
|
+
let original = null;
|
|
2134
1995
|
let scrollLockCount = 0;
|
|
2135
1996
|
/**
|
|
2136
|
-
* Locks
|
|
2137
|
-
*
|
|
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
|
-
|
|
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 &&
|
|
2164
|
-
|
|
2165
|
-
|
|
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
|