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